From 1cf94e96b294a2aff319ece9bbc3fbf5522977d0 Mon Sep 17 00:00:00 2001 From: Danny Gershman Date: Fri, 15 May 2026 00:30:17 -0400 Subject: [PATCH 1/2] feat(ui): render colored label pills from GitHub label colors (#277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fetch label color data from the GitHub GraphQL API and render per-label colored pills in the session header, sidebar, review board, and ticket board. Labels without color (GitLab) fall back to the existing gold theme. Uses the W3C relative luminance formula for contrast-aware text. 🤖 Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude Crow-Session: 5B4B439E-1D4E-4303-BB71-50C0E328B1F8 --- .../CrowCore/Sources/CrowCore/AppState.swift | 6 +-- .../CrowCore/Models/AssignedIssue.swift | 4 +- .../Sources/CrowCore/Models/LabelInfo.swift | 16 ++++++ .../CrowCore/Models/ReviewRequest.swift | 4 +- .../CrowCoreTests/AppStateLookupTests.swift | 44 +++++++++++++--- .../CrowCoreTests/AssignedIssueTests.swift | 4 +- .../CrowUI/Sources/CrowUI/CorveilTheme.swift | 52 ++++++++++++++++--- Sources/Crow/App/IssueTracker.swift | 24 ++++++--- 8 files changed, 122 insertions(+), 32 deletions(-) create mode 100644 Packages/CrowCore/Sources/CrowCore/Models/LabelInfo.swift diff --git a/Packages/CrowCore/Sources/CrowCore/AppState.swift b/Packages/CrowCore/Sources/CrowCore/AppState.swift index f7b665e..0022cfb 100644 --- a/Packages/CrowCore/Sources/CrowCore/AppState.swift +++ b/Packages/CrowCore/Sources/CrowCore/AppState.swift @@ -139,7 +139,7 @@ public final class AppState { if !ignoreReviewLabels.isEmpty { let lowerLabels = Set(ignoreReviewLabels.map { $0.lowercased() }) result = result.filter { request in - !request.labels.contains(where: { lowerLabels.contains($0.lowercased()) }) + !request.labels.contains(where: { lowerLabels.contains($0.name.lowercased()) }) } } return result @@ -372,7 +372,7 @@ public final class AppState { issue.title.lowercased().contains(query) || issue.repo.lowercased().contains(query) || "#\(issue.number)".contains(query) - || issue.labels.contains(where: { $0.lowercased().contains(query) }) + || issue.labels.contains(where: { $0.name.lowercased().contains(query) }) } } @@ -416,7 +416,7 @@ public final class AppState { } /// Labels for a session, sourced from its linked AssignedIssue or ReviewRequest. - public func labels(forSession session: Session) -> [String] { + public func labels(forSession session: Session) -> [LabelInfo] { if let issue = assignedIssue(for: session) { return issue.labels } diff --git a/Packages/CrowCore/Sources/CrowCore/Models/AssignedIssue.swift b/Packages/CrowCore/Sources/CrowCore/Models/AssignedIssue.swift index e3a47e8..5c979e0 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/AssignedIssue.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/AssignedIssue.swift @@ -8,7 +8,7 @@ public struct AssignedIssue: Identifiable, Codable, Sendable { public var state: String // "open", "closed" public var url: String public var repo: String // "org/repo" - public var labels: [String] + public var labels: [LabelInfo] public var provider: Provider /// PR number linked via closing issue references, if any. public var prNumber: Int? @@ -20,7 +20,7 @@ public struct AssignedIssue: Identifiable, Codable, Sendable { public init( id: String, number: Int, title: String, state: String, - url: String, repo: String, labels: [String] = [], + url: String, repo: String, labels: [LabelInfo] = [], provider: Provider, prNumber: Int? = nil, prURL: String? = nil, updatedAt: Date? = nil, projectStatus: TicketStatus = .unknown ) { diff --git a/Packages/CrowCore/Sources/CrowCore/Models/LabelInfo.swift b/Packages/CrowCore/Sources/CrowCore/Models/LabelInfo.swift new file mode 100644 index 0000000..a569edd --- /dev/null +++ b/Packages/CrowCore/Sources/CrowCore/Models/LabelInfo.swift @@ -0,0 +1,16 @@ +import Foundation + +/// A label with an optional color, sourced from GitHub/GitLab issue or PR metadata. +public struct LabelInfo: Codable, Sendable, Hashable, Identifiable { + public var id: String { name } + /// Display name of the label (e.g. "bug", "enhancement"). + public let name: String + /// Hex color without "#" prefix (e.g. "d73a4a"). Nil for providers that + /// don't supply color (GitLab REST API). + public let color: String? + + public init(name: String, color: String? = nil) { + self.name = name + self.color = color + } +} diff --git a/Packages/CrowCore/Sources/CrowCore/Models/ReviewRequest.swift b/Packages/CrowCore/Sources/CrowCore/Models/ReviewRequest.swift index aef841f..cbc9293 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/ReviewRequest.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/ReviewRequest.swift @@ -12,7 +12,7 @@ public struct ReviewRequest: Identifiable, Codable, Sendable { public var baseBranch: String public var isDraft: Bool public var requestedAt: Date? - public var labels: [String] + public var labels: [LabelInfo] public var provider: Provider public var reviewSessionID: UUID? // set if a review session already exists @@ -27,7 +27,7 @@ public struct ReviewRequest: Identifiable, Codable, Sendable { baseBranch: String, isDraft: Bool = false, requestedAt: Date? = nil, - labels: [String] = [], + labels: [LabelInfo] = [], provider: Provider = .github, reviewSessionID: UUID? = nil ) { diff --git a/Packages/CrowCore/Tests/CrowCoreTests/AppStateLookupTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/AppStateLookupTests.swift index ed0bf6c..88ec6b8 100644 --- a/Packages/CrowCore/Tests/CrowCoreTests/AppStateLookupTests.swift +++ b/Packages/CrowCore/Tests/CrowCoreTests/AppStateLookupTests.swift @@ -9,13 +9,13 @@ import Testing let issue = AssignedIssue( id: "github:org/repo#42", number: 42, title: "Bug fix", state: "open", url: "https://github.com/org/repo/issues/42", - repo: "org/repo", labels: ["bug", "priority"], provider: .github + repo: "org/repo", labels: [LabelInfo(name: "bug", color: "d73a4a"), LabelInfo(name: "priority")], provider: .github ) appState.assignedIssues = [issue] let session = Session(name: "fix-bug", ticketURL: "https://github.com/org/repo/issues/42") let result = appState.assignedIssue(for: session) #expect(result?.id == "github:org/repo#42") - #expect(result?.labels == ["bug", "priority"]) + #expect(result?.labels == [LabelInfo(name: "bug", color: "d73a4a"), LabelInfo(name: "priority")]) } @MainActor @Test func assignedIssueForSessionReturnsNilWithoutTicketURL() { @@ -56,11 +56,11 @@ import Testing id: "github:org/repo#123", prNumber: 123, title: "Fix", url: "https://github.com/org/repo/pull/123", repo: "org/repo", author: "alice", headBranch: "fix-branch", - baseBranch: "main", labels: ["bug", "urgent"] + baseBranch: "main", labels: [LabelInfo(name: "bug", color: "d73a4a"), LabelInfo(name: "urgent", color: "e4e669")] ) appState.reviewRequests = [request] let result = appState.reviewRequest(for: session) - #expect(result?.labels == ["bug", "urgent"]) + #expect(result?.labels == [LabelInfo(name: "bug", color: "d73a4a"), LabelInfo(name: "urgent", color: "e4e669")]) } @MainActor @Test func reviewRequestForSessionReturnsNilForWorkSessions() { @@ -74,11 +74,11 @@ import Testing let issue = AssignedIssue( id: "github:org/repo#1", number: 1, title: "T", state: "open", url: "https://github.com/org/repo/issues/1", - repo: "org/repo", labels: ["enhancement", "ui"], provider: .github + repo: "org/repo", labels: [LabelInfo(name: "enhancement", color: "a2eeef"), LabelInfo(name: "ui")], provider: .github ) appState.assignedIssues = [issue] let session = Session(name: "s", ticketURL: "https://github.com/org/repo/issues/1") - #expect(appState.labels(forSession: session) == ["enhancement", "ui"]) + #expect(appState.labels(forSession: session) == [LabelInfo(name: "enhancement", color: "a2eeef"), LabelInfo(name: "ui")]) } @MainActor @Test func labelsForSessionReturnsReviewLabels() { @@ -93,10 +93,10 @@ import Testing id: "github:org/repo#5", prNumber: 5, title: "PR", url: "https://github.com/org/repo/pull/5", repo: "org/repo", author: "bob", headBranch: "feat", - baseBranch: "main", labels: ["needs-review"] + baseBranch: "main", labels: [LabelInfo(name: "needs-review", color: "0075ca")] ) appState.reviewRequests = [request] - #expect(appState.labels(forSession: session) == ["needs-review"]) + #expect(appState.labels(forSession: session) == [LabelInfo(name: "needs-review", color: "0075ca")]) } @MainActor @Test func labelsForSessionReturnsEmptyWhenNoMatch() { @@ -104,3 +104,31 @@ import Testing let session = Session(name: "s") #expect(appState.labels(forSession: session).isEmpty) } + +// MARK: - LabelInfo Tests + +@Test func labelInfoCodableRoundTripWithColor() throws { + let label = LabelInfo(name: "bug", color: "d73a4a") + let data = try JSONEncoder().encode(label) + let decoded = try JSONDecoder().decode(LabelInfo.self, from: data) + #expect(decoded.name == "bug") + #expect(decoded.color == "d73a4a") +} + +@Test func labelInfoCodableRoundTripWithoutColor() throws { + let label = LabelInfo(name: "enhancement") + let data = try JSONEncoder().encode(label) + let decoded = try JSONDecoder().decode(LabelInfo.self, from: data) + #expect(decoded.name == "enhancement") + #expect(decoded.color == nil) +} + +@Test func labelInfoEquality() { + let a = LabelInfo(name: "bug", color: "d73a4a") + let b = LabelInfo(name: "bug", color: "d73a4a") + let c = LabelInfo(name: "bug", color: "ff0000") + let d = LabelInfo(name: "bug") + #expect(a == b) + #expect(a != c) + #expect(a != d) +} diff --git a/Packages/CrowCore/Tests/CrowCoreTests/AssignedIssueTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/AssignedIssueTests.swift index c935bcc..d173952 100644 --- a/Packages/CrowCore/Tests/CrowCoreTests/AssignedIssueTests.swift +++ b/Packages/CrowCore/Tests/CrowCoreTests/AssignedIssueTests.swift @@ -13,7 +13,7 @@ import Testing state: "open", url: "https://github.com/radiusmethod/crow/issues/64", repo: "radiusmethod/crow", - labels: ["enhancement", "testing"], + labels: [LabelInfo(name: "enhancement", color: "a2eeef"), LabelInfo(name: "testing")], provider: .github, prNumber: 100, prURL: "https://github.com/radiusmethod/crow/pull/100", @@ -26,7 +26,7 @@ import Testing #expect(decoded.number == 64) #expect(decoded.title == "Expand test coverage") #expect(decoded.state == "open") - #expect(decoded.labels == ["enhancement", "testing"]) + #expect(decoded.labels == [LabelInfo(name: "enhancement", color: "a2eeef"), LabelInfo(name: "testing")]) #expect(decoded.provider == .github) #expect(decoded.prNumber == 100) #expect(decoded.prURL == "https://github.com/radiusmethod/crow/pull/100") diff --git a/Packages/CrowUI/Sources/CrowUI/CorveilTheme.swift b/Packages/CrowUI/Sources/CrowUI/CorveilTheme.swift index 5b77ad5..6c0ee29 100644 --- a/Packages/CrowUI/Sources/CrowUI/CorveilTheme.swift +++ b/Packages/CrowUI/Sources/CrowUI/CorveilTheme.swift @@ -34,6 +34,37 @@ extension Color { opacity: opacity ) } + + /// Parses a 6-character hex string (with or without "#" prefix) into a Color. + /// Falls back to `CorveilTheme.gold` if the string is invalid. + public init(hexString: String) { + let hex = hexString.trimmingCharacters(in: CharacterSet(charactersIn: "#")) + guard hex.count == 6, let value = UInt(hex, radix: 16) else { + self = CorveilTheme.gold + return + } + self.init(hex: value) + } +} + +extension CorveilTheme { + /// Returns a text color that contrasts well against the given hex background, + /// using the W3C relative luminance formula. Matches GitHub's label rendering. + public static func contrastingTextColor(for hexString: String) -> Color { + let hex = hexString.trimmingCharacters(in: CharacterSet(charactersIn: "#")) + guard hex.count == 6, let value = UInt(hex, radix: 16) else { + return textSecondary + } + let r = Double((value >> 16) & 0xFF) / 255 + let g = Double((value >> 8) & 0xFF) / 255 + let b = Double(value & 0xFF) / 255 + + func linearize(_ c: Double) -> Double { + c <= 0.03928 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4) + } + let luminance = 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b) + return luminance > 0.179 ? Color(hex: 0x1A1D20) : .white + } } // MARK: - Status Color Extensions @@ -176,12 +207,14 @@ public struct CapsuleBadge: View { // MARK: - Label Pills /// Reusable horizontal row of label capsules with overflow count. +/// When labels include color data (GitHub), renders per-label colored pills. +/// Falls back to the gold theme for labels without color (GitLab). public struct LabelPillsView: View { - let labels: [String] + let labels: [LabelInfo] var maxVisible: Int var muted: Bool - public init(labels: [String], maxVisible: Int = 3, muted: Bool = false) { + public init(labels: [LabelInfo], maxVisible: Int = 3, muted: Bool = false) { self.labels = labels self.maxVisible = maxVisible self.muted = muted @@ -189,14 +222,19 @@ public struct LabelPillsView: View { public var body: some View { HStack(spacing: 4) { - ForEach(Array(labels.prefix(maxVisible)), id: \.self) { label in - Text(label) + ForEach(Array(labels.prefix(maxVisible))) { label in + let bgColor = label.color.map { Color(hexString: $0) } ?? CorveilTheme.gold + let fgColor: Color = muted + ? CorveilTheme.textMuted + : (label.color.map { CorveilTheme.contrastingTextColor(for: $0) } + ?? CorveilTheme.textSecondary) + Text(label.name) .font(.system(size: 10)) .padding(.horizontal, 6) .padding(.vertical, 2) - .background(CorveilTheme.gold.opacity(0.08)) - .foregroundStyle(muted ? CorveilTheme.textMuted : CorveilTheme.textSecondary) - .overlay(Capsule().strokeBorder(CorveilTheme.borderSubtle, lineWidth: 0.5)) + .background(bgColor.opacity(muted ? 0.05 : 0.15)) + .foregroundStyle(fgColor) + .overlay(Capsule().strokeBorder(bgColor.opacity(0.3), lineWidth: 0.5)) .clipShape(Capsule()) } if labels.count > maxVisible { diff --git a/Sources/Crow/App/IssueTracker.swift b/Sources/Crow/App/IssueTracker.swift index 8f0966b..966429d 100644 --- a/Sources/Crow/App/IssueTracker.swift +++ b/Sources/Crow/App/IssueTracker.swift @@ -312,7 +312,7 @@ final class IssueTracker { if !ignoreLabels.isEmpty { let lowerLabels = Set(ignoreLabels.map { $0.lowercased() }) reviews = reviews.filter { request in - !request.labels.contains(where: { lowerLabels.contains($0.lowercased()) }) + !request.labels.contains(where: { lowerLabels.contains($0.name.lowercased()) }) } } let currentIDs = Set(reviews.map(\.id)) @@ -377,7 +377,7 @@ final class IssueTracker { } for issue in issues where issue.state == "open" { - let labeled = issue.labels.contains { $0.caseInsensitiveCompare(Self.autoCreateLabel) == .orderedSame } + let labeled = issue.labels.contains { $0.name.caseInsensitiveCompare(Self.autoCreateLabel) == .orderedSame } guard labeled else { continue } guard !autoCreateInFlight.contains(issue.url) else { continue } @@ -533,7 +533,7 @@ final class IssueTracker { ... on Issue { number title url state updatedAt repository { nameWithOwner } - labels(first: 20) { nodes { name } } + labels(first: 20) { nodes { name color } } projectItems(first: 10) { nodes { fieldValueByName(name: "Status") { @@ -569,7 +569,7 @@ final class IssueTracker { ... on Issue { number title url state updatedAt repository { nameWithOwner } - labels(first: 20) { nodes { name } } + labels(first: 20) { nodes { name color } } } } } @@ -579,7 +579,7 @@ final class IssueTracker { number title url isDraft updatedAt headRefName baseRefName state author { login } repository { nameWithOwner } - labels(first: 20) { nodes { name } } + labels(first: 20) { nodes { name color } } } } } @@ -1000,7 +1000,11 @@ final class IssueTracker { let state = (node["state"] as? String ?? defaultState).lowercased() let repoName = (node["repository"] as? [String: Any])?["nameWithOwner"] as? String ?? "" let labels = ((node["labels"] as? [String: Any])?["nodes"] as? [[String: Any]])? - .compactMap { $0["name"] as? String } ?? [] + .compactMap { labelNode -> LabelInfo? in + guard let name = labelNode["name"] as? String else { return nil } + let color = labelNode["color"] as? String + return LabelInfo(name: name, color: color) + } ?? [] var updatedAt: Date? if let dateStr = node["updatedAt"] as? String { @@ -1114,7 +1118,11 @@ final class IssueTracker { let baseBranch = node["baseRefName"] as? String ?? "" let updatedAt = (node["updatedAt"] as? String).flatMap { dateFormatter.date(from: $0) } let labels = ((node["labels"] as? [String: Any])?["nodes"] as? [[String: Any]])? - .compactMap { $0["name"] as? String } ?? [] + .compactMap { labelNode -> LabelInfo? in + guard let name = labelNode["name"] as? String else { return nil } + let color = labelNode["color"] as? String + return LabelInfo(name: name, color: color) + } ?? [] requests.append(ReviewRequest( id: "github:\(repoName)#\(number)", @@ -1982,7 +1990,7 @@ final class IssueTracker { let url = item["web_url"] as? String else { return nil } let state = item["state"] as? String ?? "opened" - let labels = item["labels"] as? [String] ?? [] + let labels = (item["labels"] as? [String] ?? []).map { LabelInfo(name: $0) } let refs = item["references"] as? [String: Any] let fullRef = refs?["full"] as? String ?? "" From c0e120e6b0db46d66b7ecc8c94cd33b68f7d216d Mon Sep 17 00:00:00 2001 From: Danny Gershman Date: Fri, 15 May 2026 00:32:48 -0400 Subject: [PATCH 2/2] fix: update sessionLabels return type to [LabelInfo] in view files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SessionDetailView and SessionListView had explicit [String] type annotations on their sessionLabels computed properties, causing build failures after the labels(forSession:) return type changed to [LabelInfo]. 🤖 Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude Crow-Session: 5B4B439E-1D4E-4303-BB71-50C0E328B1F8 --- Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift | 2 +- Packages/CrowUI/Sources/CrowUI/SessionListView.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift b/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift index d759790..9bdcb22 100644 --- a/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift @@ -21,7 +21,7 @@ public struct SessionDetailView: View { appState.links(for: session.id) } - private var sessionLabels: [String] { + private var sessionLabels: [LabelInfo] { appState.labels(forSession: session) } diff --git a/Packages/CrowUI/Sources/CrowUI/SessionListView.swift b/Packages/CrowUI/Sources/CrowUI/SessionListView.swift index d7f8781..5b629df 100644 --- a/Packages/CrowUI/Sources/CrowUI/SessionListView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SessionListView.swift @@ -452,7 +452,7 @@ struct SessionRow: View { appState.hookState(for: session.id).claudeState } - private var sessionLabels: [String] { + private var sessionLabels: [LabelInfo] { appState.labels(forSession: session) }