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
6 changes: 3 additions & 3 deletions Packages/CrowCore/Sources/CrowCore/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) })
}
}

Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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
) {
Expand Down
16 changes: 16 additions & 0 deletions Packages/CrowCore/Sources/CrowCore/Models/LabelInfo.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
) {
Expand Down
44 changes: 36 additions & 8 deletions Packages/CrowCore/Tests/CrowCoreTests/AppStateLookupTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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() {
Expand All @@ -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() {
Expand All @@ -93,14 +93,42 @@ 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() {
let appState = AppState()
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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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")
Expand Down
52 changes: 45 additions & 7 deletions Packages/CrowUI/Sources/CrowUI/CorveilTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -176,27 +207,34 @@ 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
}

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 {
Expand Down
2 changes: 1 addition & 1 deletion Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
2 changes: 1 addition & 1 deletion Packages/CrowUI/Sources/CrowUI/SessionListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
24 changes: 16 additions & 8 deletions Sources/Crow/App/IssueTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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 }

Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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 } }
}
}
}
Expand All @@ -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 } }
}
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)",
Expand Down Expand Up @@ -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 ?? ""

Expand Down
Loading