Skip to content

Commit 5baba1f

Browse files
committed
feat: show unresolved comments on PR badge and add merge button
PR badge now shows a balloon icon with unresolved thread count inline. Card detail header shows approval count (green checkmark) and unresolved thread count (orange bubble) as a clickable pill that links to the first unresolved comment when available, or the PR URL. Adds a green merge button in the header for approved PRs. Also fetches first unresolved review thread comment URL from the existing enrichPRDetails GraphQL query (no extra API calls).
1 parent 3ab9c88 commit 5baba1f

File tree

8 files changed

+115
-18
lines changed

8 files changed

+115
-18
lines changed

Sources/KanbanCode/CardDetailView.swift

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,17 @@ struct CardDetailView: View {
157157

158158
// Action pills
159159
HStack(spacing: 8) {
160+
// PR summary pill
161+
if let primary = card.link.prLink {
162+
prSummaryPill(primary: primary)
163+
}
164+
165+
// Merge button (approved PRs only)
166+
if let primary = card.link.prLink,
167+
primary.status == .approved {
168+
mergeButton(pr: primary)
169+
}
170+
160171
if card.link.tmuxLink == nil {
161172
let hasSession = card.link.sessionLink != nil
162173
let isStart = card.column == .backlog || !hasSession
@@ -1060,6 +1071,72 @@ struct CardDetailView: View {
10601071
isLoadingPRBody = false
10611072
}
10621073

1074+
@ViewBuilder
1075+
private func prSummaryPill(primary: PRLink) -> some View {
1076+
let totalApprovals = card.link.prLinks.compactMap(\.approvalCount).reduce(0, +)
1077+
let totalThreads = card.link.prLinks.compactMap(\.unresolvedThreads).reduce(0, +)
1078+
let targetURL = totalThreads > 0
1079+
? (primary.firstUnresolvedThreadURL ?? primary.url)
1080+
: primary.url
1081+
1082+
if totalApprovals > 0 || totalThreads > 0 {
1083+
Button {
1084+
if let urlStr = targetURL, let url = URL(string: urlStr) {
1085+
NSWorkspace.shared.open(url)
1086+
}
1087+
} label: {
1088+
HStack(spacing: 6) {
1089+
if totalApprovals > 0 {
1090+
HStack(spacing: 2) {
1091+
Image(systemName: "checkmark")
1092+
.font(.system(size: 10, weight: .bold))
1093+
Text(verbatim: "\(totalApprovals)")
1094+
.font(.system(size: 12, weight: .medium))
1095+
}
1096+
.foregroundStyle(.green)
1097+
}
1098+
if totalThreads > 0 {
1099+
HStack(spacing: 2) {
1100+
Image(systemName: "bubble.left")
1101+
.font(.system(size: 10))
1102+
Text(verbatim: "\(totalThreads)")
1103+
.font(.system(size: 12, weight: .medium))
1104+
}
1105+
.foregroundStyle(.orange)
1106+
}
1107+
}
1108+
.frame(height: 36)
1109+
.padding(.horizontal, 10)
1110+
.contentShape(Capsule())
1111+
}
1112+
.buttonStyle(.plain)
1113+
.glassEffect(.regular, in: .capsule)
1114+
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)
1115+
.modifier(HoverBrightness())
1116+
.help(totalThreads > 0 ? "Open unresolved comment" : "Open pull request")
1117+
}
1118+
}
1119+
1120+
@ViewBuilder
1121+
private func mergeButton(pr: PRLink) -> some View {
1122+
Button {
1123+
if let urlStr = pr.url, let url = URL(string: urlStr) {
1124+
NSWorkspace.shared.open(url)
1125+
}
1126+
} label: {
1127+
Label("Merge", systemImage: "arrow.triangle.merge")
1128+
.font(.system(size: 13))
1129+
.foregroundStyle(Color.green.opacity(0.8))
1130+
.padding(.horizontal, 12)
1131+
.frame(height: 36)
1132+
.background(Color.green.opacity(0.08), in: Capsule())
1133+
.background(.ultraThinMaterial, in: Capsule())
1134+
}
1135+
.buttonStyle(HoverFeedbackStyle())
1136+
.shadow(color: .black.opacity(0.25), radius: 4, y: 2)
1137+
.help("Merge pull request")
1138+
}
1139+
10631140
private var actionsMenuButton: some View {
10641141
NSMenuButton {
10651142
Image(systemName: "ellipsis")

Sources/KanbanCode/CardView.swift

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -73,22 +73,13 @@ struct CardView: View {
7373

7474
// PR badge(s) — worst status across all PRs
7575
if let primary = card.link.prLink {
76-
PRBadge(status: card.link.worstPRStatus, prNumber: primary.number)
76+
let totalThreads = card.link.prLinks.compactMap(\.unresolvedThreads).reduce(0, +)
77+
PRBadge(status: card.link.worstPRStatus, prNumber: primary.number, unresolvedThreads: totalThreads)
7778
if card.link.prLinks.count > 1 {
7879
Text(verbatim: "+\(card.link.prLinks.count - 1)")
7980
.font(.system(size: 9, weight: .medium))
8081
.foregroundStyle(.secondary)
8182
}
82-
let totalThreads = card.link.prLinks.compactMap(\.unresolvedThreads).reduce(0, +)
83-
if totalThreads > 0 {
84-
HStack(spacing: 1) {
85-
Image(systemName: "bubble.left.and.exclamationmark.bubble.right")
86-
.font(.system(size: 8))
87-
Text(verbatim: "\(totalThreads)")
88-
.font(.system(size: 9, weight: .medium))
89-
}
90-
.foregroundStyle(.orange)
91-
}
9283
}
9384

9485
// Issue indicator

Sources/KanbanCode/PRBadge.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,25 @@ import KanbanCodeCore
66
struct PRBadge: View {
77
let status: PRStatus?
88
let prNumber: Int
9+
var unresolvedThreads: Int = 0
910

1011
var body: some View {
11-
HStack(spacing: 2) {
12+
HStack(spacing: 3) {
1213
if status == .approved {
1314
Image(systemName: "checkmark")
1415
.font(.system(size: 8, weight: .bold))
1516
}
1617
Text(verbatim: "#\(prNumber)")
1718
.font(.system(size: 10, weight: .medium, design: .rounded))
19+
if unresolvedThreads > 0 {
20+
HStack(spacing: 1) {
21+
Image(systemName: "bubble.left")
22+
.font(.system(size: 7))
23+
Text(verbatim: "\(unresolvedThreads)")
24+
.font(.system(size: 9, weight: .medium))
25+
}
26+
.foregroundStyle(.orange)
27+
}
1828
}
1929
.padding(.horizontal, 5)
2030
.padding(.vertical, 2)

Sources/KanbanCodeCore/Adapters/Git/GhCliAdapter.swift

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ public final class GhCliAdapter: PRTrackerPort, @unchecked Sendable {
7676
\(alias): pullRequest(number: \(pr.number)) {
7777
body
7878
reviewDecision
79-
reviewThreads(first: 100) { nodes { isResolved } }
79+
reviewThreads(first: 100) { nodes { isResolved comments(first: 1) { nodes { url } } } }
8080
reviews(states: APPROVED) { totalCount }
8181
commits(last: 1) { nodes { commit { statusCheckRollup {
8282
state
@@ -148,10 +148,19 @@ public final class GhCliAdapter: PRTrackerPort, @unchecked Sendable {
148148
pr.approvalCount = totalCount
149149
}
150150

151-
// Unresolved threads
151+
// Unresolved threads + first unresolved comment URL
152152
if let threads = prData["reviewThreads"] as? [String: Any],
153153
let nodes = threads["nodes"] as? [[String: Any]] {
154-
pr.unresolvedThreads = nodes.filter { ($0["isResolved"] as? Bool) == false }.count
154+
let unresolved = nodes.filter { ($0["isResolved"] as? Bool) == false }
155+
pr.unresolvedThreads = unresolved.count
156+
// Grab URL of the first unresolved thread's first comment
157+
if let firstThread = unresolved.first,
158+
let comments = firstThread["comments"] as? [String: Any],
159+
let commentNodes = comments["nodes"] as? [[String: Any]],
160+
let firstComment = commentNodes.first,
161+
let url = firstComment["url"] as? String {
162+
pr.firstUnresolvedThreadURL = url
163+
}
155164
}
156165

157166
// CI status + individual check runs

Sources/KanbanCodeCore/Domain/Entities/Link.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ public struct PRLink: Codable, Sendable, Equatable {
6161
public var body: String?
6262
public var approvalCount: Int?
6363
public var checkRuns: [CheckRun]?
64+
public var firstUnresolvedThreadURL: String?
6465

6566
public init(
6667
number: Int,
@@ -70,7 +71,8 @@ public struct PRLink: Codable, Sendable, Equatable {
7071
title: String? = nil,
7172
body: String? = nil,
7273
approvalCount: Int? = nil,
73-
checkRuns: [CheckRun]? = nil
74+
checkRuns: [CheckRun]? = nil,
75+
firstUnresolvedThreadURL: String? = nil
7476
) {
7577
self.number = number
7678
self.url = url
@@ -80,6 +82,7 @@ public struct PRLink: Codable, Sendable, Equatable {
8082
self.body = body
8183
self.approvalCount = approvalCount
8284
self.checkRuns = checkRuns
85+
self.firstUnresolvedThreadURL = firstUnresolvedThreadURL
8386
}
8487
}
8588

Sources/KanbanCodeCore/Domain/Entities/PullRequest.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public struct PullRequest: Identifiable, Sendable {
1414
public var body: String?
1515
public var approvalCount: Int
1616
public var checkRuns: [CheckRun]
17+
public var firstUnresolvedThreadURL: String?
1718

1819
public init(
1920
number: Int,
@@ -26,7 +27,8 @@ public struct PullRequest: Identifiable, Sendable {
2627
unresolvedThreads: Int = 0,
2728
body: String? = nil,
2829
approvalCount: Int = 0,
29-
checkRuns: [CheckRun] = []
30+
checkRuns: [CheckRun] = [],
31+
firstUnresolvedThreadURL: String? = nil
3032
) {
3133
self.number = number
3234
self.title = title
@@ -39,6 +41,7 @@ public struct PullRequest: Identifiable, Sendable {
3941
self.body = body
4042
self.approvalCount = approvalCount
4143
self.checkRuns = checkRuns
44+
self.firstUnresolvedThreadURL = firstUnresolvedThreadURL
4245
}
4346

4447
/// Derive unified PR status with priority ordering.

Sources/KanbanCodeCore/UseCases/BackgroundOrchestrator.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@ public final class BackgroundOrchestrator: @unchecked Sendable {
113113
number: pr.number, url: pr.url,
114114
status: pr.status, title: pr.title,
115115
approvalCount: pr.approvalCount > 0 ? pr.approvalCount : nil,
116-
checkRuns: pr.checkRuns.isEmpty ? nil : pr.checkRuns
116+
checkRuns: pr.checkRuns.isEmpty ? nil : pr.checkRuns,
117+
firstUnresolvedThreadURL: pr.firstUnresolvedThreadURL
117118
))
118119
}
119120
}

Sources/KanbanCodeCore/UseCases/BoardState.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,9 @@ public final class BoardState: @unchecked Sendable {
501501
mergedLinks[entry.index].prLinks[entry.prIndex].status = pr.status
502502
mergedLinks[entry.index].prLinks[entry.prIndex].title = pr.title
503503
mergedLinks[entry.index].prLinks[entry.prIndex].url = pr.url
504+
if pr.approvalCount > 0 {
505+
mergedLinks[entry.index].prLinks[entry.prIndex].approvalCount = pr.approvalCount
506+
}
504507
KanbanCodeLog.info("refresh", "Refreshed PR #\(entry.number)\(pr.status)")
505508
}
506509
}

0 commit comments

Comments
 (0)