From e17c271cd4192b8b6bee4e28fed0fc86ee81fd39 Mon Sep 17 00:00:00 2001 From: Danny Gershman Date: Wed, 3 Jun 2026 22:26:06 -0400 Subject: [PATCH] Add Session.codeProvider for cross-backend pairing (#414) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #410 / ADR 0005. Lets a Corveil-tasked session pair with a GitHub or GitLab CodeBackend by storing the code-source provider independently from the task-source provider. - Add `Session.codeProvider: Provider?` (nil means "follow `provider`"). - Update `findPRLink` in SessionService to take an explicit provider parameter; resolve at the call site via `codeProvider ?? provider ?? .github`. Legacy in-line `gh pr list` fallback stays GitHub-only. - Backward-compat decode test for state.json predating CROW-414, plus persistence round-trip coverage. 🐦‍⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude Crow-Session: F8E8977A-340D-4DF2-AA3C-3B1C21BCDD26 --- .../Sources/CrowCore/Models/Session.swift | 8 +++++ .../CrowCoreTests/SessionModelTests.swift | 33 ++++++++++++++++++- .../SessionRepositoryTests.swift | 4 ++- Sources/Crow/App/SessionService.swift | 6 ++-- 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/Packages/CrowCore/Sources/CrowCore/Models/Session.swift b/Packages/CrowCore/Sources/CrowCore/Models/Session.swift index 9ee6550b..20b93c2b 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/Session.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/Session.swift @@ -11,6 +11,11 @@ public struct Session: Identifiable, Codable, Sendable { public var ticketTitle: String? public var ticketNumber: Int? public var provider: Provider? + // Code-source provider, distinct from the task-source `provider`. Lets a + // Corveil-tasked session use a GitHub or GitLab `CodeBackend` (ADR 0005, + // CROW-414). `nil` means "follow `provider`"; callers resolve with + // `session.codeProvider ?? session.provider`. + public var codeProvider: Provider? public var createdAt: Date public var updatedAt: Date // Whether a review-kind session has had its initial `/crow-review-pr` @@ -43,6 +48,7 @@ public struct Session: Identifiable, Codable, Sendable { ticketTitle: String? = nil, ticketNumber: Int? = nil, provider: Provider? = nil, + codeProvider: Provider? = nil, createdAt: Date = Date(), updatedAt: Date = Date(), reviewPromptDispatched: Bool = false, @@ -58,6 +64,7 @@ public struct Session: Identifiable, Codable, Sendable { self.ticketTitle = ticketTitle self.ticketNumber = ticketNumber self.provider = provider + self.codeProvider = codeProvider self.createdAt = createdAt self.updatedAt = updatedAt self.reviewPromptDispatched = reviewPromptDispatched @@ -93,6 +100,7 @@ public struct Session: Identifiable, Codable, Sendable { ticketTitle = try container.decodeIfPresent(String.self, forKey: .ticketTitle) ticketNumber = try container.decodeIfPresent(Int.self, forKey: .ticketNumber) provider = try container.decodeIfPresent(Provider.self, forKey: .provider) + codeProvider = try container.decodeIfPresent(Provider.self, forKey: .codeProvider) createdAt = try container.decode(Date.self, forKey: .createdAt) updatedAt = try container.decode(Date.self, forKey: .updatedAt) reviewPromptDispatched = try container.decodeIfPresent(Bool.self, forKey: .reviewPromptDispatched) ?? true diff --git a/Packages/CrowCore/Tests/CrowCoreTests/SessionModelTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/SessionModelTests.swift index 7c4a4550..9fb699e3 100644 --- a/Packages/CrowCore/Tests/CrowCoreTests/SessionModelTests.swift +++ b/Packages/CrowCore/Tests/CrowCoreTests/SessionModelTests.swift @@ -11,6 +11,7 @@ import Testing #expect(session.ticketTitle == nil) #expect(session.ticketNumber == nil) #expect(session.provider == nil) + #expect(session.codeProvider == nil) #expect(session.createdAt <= Date()) #expect(session.updatedAt <= Date()) } @@ -22,7 +23,8 @@ import Testing ticketURL: "https://github.com/org/repo/issues/42", ticketTitle: "Fix the thing", ticketNumber: 42, - provider: .github + provider: .github, + codeProvider: .gitlab ) let data = try JSONEncoder().encode(session) let decoded = try JSONDecoder().decode(Session.self, from: data) @@ -34,6 +36,7 @@ import Testing #expect(decoded.ticketTitle == "Fix the thing") #expect(decoded.ticketNumber == 42) #expect(decoded.provider == .github) + #expect(decoded.codeProvider == .gitlab) } @Test func sessionCodableWithNilOptionals() throws { @@ -46,6 +49,34 @@ import Testing #expect(decoded.ticketTitle == nil) #expect(decoded.ticketNumber == nil) #expect(decoded.provider == nil) + #expect(decoded.codeProvider == nil) +} + +@Test func sessionBackwardCompatDecodingWithoutCodeProvider() throws { + // Persisted state.json predating CROW-414 has no `codeProvider`. Decode + // must succeed and default the field to nil so the runtime fallback + // (`session.codeProvider ?? session.provider`) routes legacy sessions + // to the same backend they used pre-split. + let id = UUID() + let date = Date() + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + let dateStr = formatter.string(from: date) + let json: [String: Any] = [ + "id": id.uuidString, + "name": "legacy", + "status": "active", + "kind": "work", + "provider": "github", + "createdAt": dateStr, + "updatedAt": dateStr, + ] + let data = try JSONSerialization.data(withJSONObject: json) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let session = try decoder.decode(Session.self, from: data) + #expect(session.provider == .github) + #expect(session.codeProvider == nil) } @Test func sessionBackwardCompatDecodingLastReviewedHeadSha() throws { diff --git a/Packages/CrowPersistence/Tests/CrowPersistenceTests/SessionRepositoryTests.swift b/Packages/CrowPersistence/Tests/CrowPersistenceTests/SessionRepositoryTests.swift index a70f3a1a..0908a780 100644 --- a/Packages/CrowPersistence/Tests/CrowPersistenceTests/SessionRepositoryTests.swift +++ b/Packages/CrowPersistence/Tests/CrowPersistenceTests/SessionRepositoryTests.swift @@ -156,7 +156,8 @@ import Testing ticketURL: "https://github.com/org/repo/issues/42", ticketTitle: "Fix the bug", ticketNumber: 42, - provider: .github + provider: .github, + codeProvider: .gitlab ) repo.save(session) @@ -165,6 +166,7 @@ import Testing #expect(found?.ticketTitle == "Fix the bug") #expect(found?.ticketNumber == 42) #expect(found?.provider == .github) + #expect(found?.codeProvider == .gitlab) #expect(found?.status == .inReview) } diff --git a/Sources/Crow/App/SessionService.swift b/Sources/Crow/App/SessionService.swift index 0805562e..b817b3aa 100644 --- a/Sources/Crow/App/SessionService.swift +++ b/Sources/Crow/App/SessionService.swift @@ -1144,14 +1144,14 @@ final class SessionService { } /// Check for a pull request on a branch and return a link if found. - private func findPRLink(branch: String, repoPath: String, sessionID: UUID) async -> SessionLink? { + private func findPRLink(branch: String, repoPath: String, sessionID: UUID, provider: Provider) async -> SessionLink? { guard let repoSlug = resolveRepoSlug(repoPath: repoPath) else { return nil } // Prefer CodeBackend.linkedPR (ADR 0005) when a manager is wired; fall // back to the inline `gh pr list` shell-out only when no manager is // available — otherwise we'd issue the identical command twice on the // common "no PR exists" path. if let manager = providerManager { - guard let backend = manager.codeBackend(for: .github), + guard let backend = manager.codeBackend(for: provider), let pr = try? await backend.linkedPR(repo: repoSlug, branch: branch) else { return nil } @@ -1207,7 +1207,7 @@ final class SessionService { let label = ticket.number.map { "Issue #\($0)" } ?? "Issue" links.append(SessionLink(sessionID: session.id, label: label, url: ticketURL, linkType: .ticket)) } - if let prLink = await findPRLink(branch: branch, repoPath: repoPath, sessionID: session.id) { + if let prLink = await findPRLink(branch: branch, repoPath: repoPath, sessionID: session.id, provider: session.codeProvider ?? session.provider ?? .github) { links.append(prLink) }