native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #112 from supabitapp/ci-checks-segmented-indicator

Add segmented CI check indicator

authored by

khoi and committed by
GitHub
0b7f8d6c 3923bcc7

+203 -110
+60 -72
supacode/Clients/Github/GithubPullRequestStatusCheck.swift
··· 1 1 import Foundation 2 2 3 - nonisolated enum PullRequestCheckState: Equatable { 4 - case passed 5 - case failed 6 - case pending 7 - case ignored 8 - } 9 - 10 - nonisolated struct PullRequestCheckSummary: Equatable { 11 - let passed: Int 12 - let failed: Int 13 - let pending: Int 14 - let ignored: Int 15 - 16 - var total: Int { 17 - passed + failed + pending 18 - } 19 - 20 - init(checks: [GithubPullRequestStatusCheck]) { 21 - var passed = 0 22 - var failed = 0 23 - var pending = 0 24 - var ignored = 0 25 - for check in checks { 26 - switch check.summarizedState { 27 - case .passed: 28 - passed += 1 29 - case .failed: 30 - failed += 1 31 - case .pending: 32 - pending += 1 33 - case .ignored: 34 - ignored += 1 35 - } 36 - } 37 - self.passed = passed 38 - self.failed = failed 39 - self.pending = pending 40 - self.ignored = ignored 41 - } 42 - } 43 - 44 3 nonisolated struct GithubPullRequestStatusCheck: Decodable, Equatable, Hashable { 45 4 let status: String? 46 5 let conclusion: String? 47 6 let state: String? 48 - 49 - var summarizedState: PullRequestCheckState { 50 - if let status, status.uppercased() != "COMPLETED" { 51 - return .pending 52 - } 53 - if let state { 54 - switch state.uppercased() { 55 - case "SUCCESS": 56 - return .passed 57 - case "FAILURE", "ERROR": 58 - return .failed 59 - case "PENDING", "EXPECTED": 60 - return .pending 61 - default: 62 - return .pending 63 - } 64 - } 65 - if let conclusion { 66 - switch conclusion.uppercased() { 67 - case "SUCCESS", "NEUTRAL": 68 - return .passed 69 - case "CANCELLED", "SKIPPED": 70 - return .ignored 71 - case "FAILURE", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE": 72 - return .failed 73 - default: 74 - return .pending 75 - } 76 - } 77 - return .pending 78 - } 79 7 } 80 8 81 9 nonisolated struct GithubPullRequestStatusCheckRollup: Decodable, Equatable, Hashable { ··· 106 34 nonisolated private struct GithubPullRequestStatusCheckContexts: Decodable, Equatable { 107 35 let nodes: [GithubPullRequestStatusCheck] 108 36 } 37 + 38 + nonisolated struct PullRequestCheckBreakdown: Equatable { 39 + let passed: Int 40 + let failed: Int 41 + let inProgress: Int 42 + let expected: Int 43 + let skipped: Int 44 + 45 + var total: Int { 46 + passed + failed + inProgress + expected + skipped 47 + } 48 + 49 + init(checks: [GithubPullRequestStatusCheck]) { 50 + var passed = 0 51 + var failed = 0 52 + var inProgress = 0 53 + var expected = 0 54 + var skipped = 0 55 + for check in checks { 56 + if let status = check.status, status.uppercased() != "COMPLETED" { 57 + inProgress += 1 58 + continue 59 + } 60 + if let state = check.state { 61 + switch state.uppercased() { 62 + case "SUCCESS": 63 + passed += 1 64 + case "FAILURE", "ERROR": 65 + failed += 1 66 + case "EXPECTED": 67 + expected += 1 68 + case "PENDING": 69 + inProgress += 1 70 + default: 71 + inProgress += 1 72 + } 73 + continue 74 + } 75 + if let conclusion = check.conclusion { 76 + switch conclusion.uppercased() { 77 + case "SUCCESS", "NEUTRAL": 78 + passed += 1 79 + case "CANCELLED", "SKIPPED": 80 + skipped += 1 81 + case "FAILURE", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE": 82 + failed += 1 83 + default: 84 + inProgress += 1 85 + } 86 + continue 87 + } 88 + inProgress += 1 89 + } 90 + self.passed = passed 91 + self.failed = failed 92 + self.inProgress = inProgress 93 + self.expected = expected 94 + self.skipped = skipped 95 + } 96 + }
+80
supacode/Features/Repositories/Views/PullRequestChecksRingView.swift
··· 1 + import SwiftUI 2 + 3 + struct PullRequestChecksRingView: View { 4 + let breakdown: PullRequestCheckBreakdown 5 + @ScaledMetric(relativeTo: .caption) private var diameter: CGFloat = 12 6 + @ScaledMetric(relativeTo: .caption) private var lineWidth: CGFloat = 2 7 + 8 + var body: some View { 9 + if breakdown.total == 0 { 10 + EmptyView() 11 + } else { 12 + let segments = segments(for: breakdown) 13 + ZStack { 14 + ForEach(segments) { segment in 15 + RingSegmentShape(startAngle: segment.startAngle, endAngle: segment.endAngle) 16 + .stroke(segment.color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt)) 17 + } 18 + } 19 + .frame(width: diameter, height: diameter) 20 + .accessibilityHidden(true) 21 + } 22 + } 23 + 24 + private func segments(for breakdown: PullRequestCheckBreakdown) -> [Segment] { 25 + let total = Double(breakdown.total) 26 + guard total > 0 else { 27 + return [] 28 + } 29 + var start = -90.0 30 + var segments: [Segment] = [] 31 + func addSegment(id: String, count: Int, color: Color) { 32 + guard count > 0 else { 33 + return 34 + } 35 + let sweep = (Double(count) / total) * 360 36 + let end = start + sweep 37 + segments.append( 38 + Segment( 39 + id: id, 40 + startAngle: .degrees(start), 41 + endAngle: .degrees(end), 42 + color: color 43 + ) 44 + ) 45 + start = end 46 + } 47 + addSegment(id: "failed", count: breakdown.failed, color: .red) 48 + addSegment(id: "inProgress", count: breakdown.inProgress, color: .yellow) 49 + addSegment(id: "skipped", count: breakdown.skipped, color: .gray) 50 + addSegment(id: "expected", count: breakdown.expected, color: .yellow) 51 + addSegment(id: "passed", count: breakdown.passed, color: .green) 52 + return segments 53 + } 54 + 55 + private struct Segment: Identifiable { 56 + let id: String 57 + let startAngle: Angle 58 + let endAngle: Angle 59 + let color: Color 60 + } 61 + 62 + private struct RingSegmentShape: Shape { 63 + let startAngle: Angle 64 + let endAngle: Angle 65 + 66 + func path(in rect: CGRect) -> Path { 67 + let center = CGPoint(x: rect.midX, y: rect.midY) 68 + let radius = min(rect.width, rect.height) / 2 69 + var path = Path() 70 + path.addArc( 71 + center: center, 72 + radius: radius, 73 + startAngle: startAngle, 74 + endAngle: endAngle, 75 + clockwise: false 76 + ) 77 + return path 78 + } 79 + } 80 + }
+24 -38
supacode/Features/Repositories/Views/PullRequestStatusButton.swift
··· 11 11 } 12 12 } label: { 13 13 HStack { 14 - if let indicatorColor = indicatorColor { 15 - Image(systemName: "circle.fill") 16 - .foregroundStyle(indicatorColor) 14 + if let checkBreakdown = model.checkBreakdown { 15 + PullRequestChecksRingView(breakdown: checkBreakdown) 17 16 } 18 17 Text(model.label) 19 18 } ··· 24 23 .help("Open pull request on GitHub") 25 24 } 26 25 27 - private var indicatorColor: Color? { 28 - switch model.indicatorState { 29 - case .passed, .ignored: 30 - return .green 31 - case .pending: 32 - return .yellow 33 - case .failed: 34 - return .red 35 - case .none: 36 - return nil 37 - } 38 - } 39 26 } 40 27 41 28 struct PullRequestStatusModel: Equatable { 42 29 let label: String 43 30 let url: URL? 44 - let indicatorState: PullRequestCheckState? 31 + let checkBreakdown: PullRequestCheckBreakdown? 45 32 46 - init(label: String, url: URL?, indicatorState: PullRequestCheckState?) { 33 + init(label: String, url: URL?, checkBreakdown: PullRequestCheckBreakdown?) { 47 34 self.label = label 48 35 self.url = url 49 - self.indicatorState = indicatorState 36 + self.checkBreakdown = checkBreakdown 50 37 } 51 38 52 39 init?(snapshot: WorktreeInfoSnapshot?) { ··· 62 49 if state == "MERGED" { 63 50 self.label = "PR #\(number) - Merged" 64 51 self.url = url 65 - self.indicatorState = nil 52 + self.checkBreakdown = nil 66 53 return 67 54 } 68 55 let isDraft = snapshot.pullRequestIsDraft ··· 71 58 if checks.isEmpty { 72 59 self.label = prefix + "Checks unavailable" 73 60 self.url = url 74 - self.indicatorState = nil 61 + self.checkBreakdown = nil 75 62 return 76 63 } 77 - let summary = PullRequestCheckSummary(checks: checks) 78 - if summary.failed > 0 { 79 - self.label = prefix + "\(summary.failed)/\(summary.total) checks failed" 80 - self.url = url 81 - self.indicatorState = .failed 82 - return 64 + let breakdown = PullRequestCheckBreakdown(checks: checks) 65 + let checksLabel = breakdown.total == 1 ? "check" : "checks" 66 + var parts: [String] = [] 67 + if breakdown.failed > 0 { 68 + parts.append("\(breakdown.failed) failed") 83 69 } 84 - if summary.pending > 0 { 85 - self.label = prefix + "\(summary.pending) checks pending" 86 - self.url = url 87 - self.indicatorState = .pending 88 - return 70 + if breakdown.inProgress > 0 { 71 + parts.append("\(breakdown.inProgress) in progress") 72 + } 73 + if breakdown.skipped > 0 { 74 + parts.append("\(breakdown.skipped) skipped") 75 + } 76 + if breakdown.expected > 0 { 77 + parts.append("\(breakdown.expected) expected") 89 78 } 90 - if summary.ignored > 0 { 91 - self.label = prefix + "\(summary.ignored) checks skipped" 92 - self.url = url 93 - self.indicatorState = .ignored 94 - return 79 + if breakdown.total > 0 { 80 + parts.append("\(breakdown.passed) successful") 95 81 } 96 - self.label = prefix + "All checks passed" 82 + self.label = prefix + parts.joined(separator: ", ") + " \(checksLabel)" 97 83 self.url = url 98 - self.indicatorState = .passed 84 + self.checkBreakdown = breakdown 99 85 } 100 86 101 87 static func shouldDisplay(state: String?, number: Int?) -> Bool {
+39
supacodeTests/PullRequestCheckBreakdownTests.swift
··· 1 + import Testing 2 + 3 + @testable import supacode 4 + 5 + @MainActor 6 + struct PullRequestCheckBreakdownTests { 7 + @Test func breakdownClassifiesChecksByStatusStateAndConclusion() { 8 + let checks = [ 9 + GithubPullRequestStatusCheck(status: "IN_PROGRESS", conclusion: "SUCCESS", state: "SUCCESS"), 10 + GithubPullRequestStatusCheck(status: "COMPLETED", conclusion: nil, state: "EXPECTED"), 11 + GithubPullRequestStatusCheck(status: "COMPLETED", conclusion: nil, state: "PENDING"), 12 + GithubPullRequestStatusCheck(status: nil, conclusion: "SKIPPED", state: nil), 13 + GithubPullRequestStatusCheck(status: nil, conclusion: "SUCCESS", state: nil), 14 + GithubPullRequestStatusCheck(status: nil, conclusion: "FAILURE", state: nil), 15 + ] 16 + 17 + let breakdown = PullRequestCheckBreakdown(checks: checks) 18 + 19 + #expect(breakdown.inProgress == 2) 20 + #expect(breakdown.expected == 1) 21 + #expect(breakdown.skipped == 1) 22 + #expect(breakdown.passed == 1) 23 + #expect(breakdown.failed == 1) 24 + #expect(breakdown.total == 6) 25 + } 26 + 27 + @Test func breakdownDefaultsUnknownStatesToInProgress() { 28 + let checks = [ 29 + GithubPullRequestStatusCheck(status: "COMPLETED", conclusion: nil, state: "UNKNOWN"), 30 + GithubPullRequestStatusCheck(status: nil, conclusion: "UNKNOWN", state: nil), 31 + GithubPullRequestStatusCheck(status: nil, conclusion: nil, state: nil), 32 + ] 33 + 34 + let breakdown = PullRequestCheckBreakdown(checks: checks) 35 + 36 + #expect(breakdown.inProgress == 3) 37 + #expect(breakdown.total == 3) 38 + } 39 + }