Commit 4c67bc570f
Unsigned
Layout: unified · split
Hutch/App/RootView.swift +13 −2
| @@ -6,13 +6,15 @@ struct RootView: View { | ||
| 6 | 6 | @Environment(AppState.self) private var appState |
| 7 | 7 | |
| 8 | 8 | enum Tab: Hashable { |
| 9 | case home | |
| 9 | 10 | case repositories |
| 10 | 11 | case builds |
| 11 | 12 | case tickets |
| 12 | 13 | case settings |
| 13 | 14 | } |
| 14 | 15 | |
| 15 | @State private var selectedTab: Tab = .repositories | |
| 16 | @State private var selectedTab: Tab = .home | |
| 17 | @State private var homePath = NavigationPath() | |
| 16 | 18 | @State private var repoPath = NavigationPath() |
| 17 | 19 | @State private var buildsPath = NavigationPath() |
| 18 | 20 | @State private var ticketsPath = NavigationPath() |
| @@ -47,6 +49,14 @@ struct RootView: View { | ||
| 47 | 49 | |
| 48 | 50 | private var tabContent: some View { |
| 49 | 51 | TabView(selection: $selectedTab) { |
| 52 | NavigationStack(path: $homePath) { | |
| 53 | HomeView() | |
| 54 | } | |
| 55 | .tag(Tab.home) | |
| 56 | .tabItem { | |
| 57 | Label("Home", systemImage: "house") | |
| 58 | } | |
| 59 | ||
| 50 | 60 | NavigationStack(path: $repoPath) { |
| 51 | 61 | RepositoryListView() |
| 52 | 62 | } |
| @@ -106,10 +116,11 @@ struct RootView: View { | ||
| 106 | 116 | case .launching: |
| 107 | 117 | break |
| 108 | 118 | case .unauthenticated: |
| 119 | homePath = NavigationPath() | |
| 109 | 120 | repoPath = NavigationPath() |
| 110 | 121 | buildsPath = NavigationPath() |
| 111 | 122 | ticketsPath = NavigationPath() |
| 112 | selectedTab = .repositories | |
| 123 | selectedTab = .home | |
| 113 | 124 | isResolvingDeepLink = false |
| 114 | 125 | case .authenticated: |
| 115 | 126 | consumePendingDeepLinkIfPossible(appState.pendingDeepLink) |
Hutch/Views/Home/HomeView.swift added +297
| @@ -0,0 +1,297 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct HomeView: View { | |
| 4 | @Environment(AppState.self) private var appState | |
| 5 | @State private var viewModel: HomeViewModel? | |
| 6 | private let previewLimit = 4 | |
| 7 | ||
| 8 | var body: some View { | |
| 9 | Group { | |
| 10 | if let viewModel { | |
| 11 | content(viewModel) | |
| 12 | } else { | |
| 13 | SRHTLoadingStateView(message: "Loading Home…") | |
| 14 | } | |
| 15 | } | |
| 16 | .navigationTitle("Home") | |
| 17 | .task { | |
| 18 | if viewModel == nil, let currentUser = appState.currentUser { | |
| 19 | let vm = HomeViewModel(currentUser: currentUser, client: appState.client) | |
| 20 | viewModel = vm | |
| 21 | await vm.loadDashboard() | |
| 22 | } | |
| 23 | } | |
| 24 | } | |
| 25 | ||
| 26 | @ViewBuilder | |
| 27 | private func content(_ viewModel: HomeViewModel) -> some View { | |
| 28 | List { | |
| 29 | assignedTicketsSection(viewModel) | |
| 30 | recentBuildsSection(viewModel) | |
| 31 | } | |
| 32 | .listStyle(.insetGrouped) | |
| 33 | .overlay { | |
| 34 | if viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds && | |
| 35 | viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty { | |
| 36 | SRHTLoadingStateView(message: "Loading Home…") | |
| 37 | } else if !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds && | |
| 38 | viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && | |
| 39 | viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil { | |
| 40 | ContentUnavailableView( | |
| 41 | "All Clear", | |
| 42 | systemImage: "checkmark.circle", | |
| 43 | description: Text("There are no assigned tickets or recent builds right now.") | |
| 44 | ) | |
| 45 | } | |
| 46 | } | |
| 47 | .refreshable { | |
| 48 | await viewModel.loadDashboard() | |
| 49 | } | |
| 50 | } | |
| 51 | ||
| 52 | @ViewBuilder | |
| 53 | private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View { | |
| 54 | Section { | |
| 55 | if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { | |
| 56 | HomeSectionLoadingRow(label: "Loading assigned tickets") | |
| 57 | } else if let error = viewModel.assignedTicketsError, viewModel.assignedTickets.isEmpty { | |
| 58 | HomeSectionMessageRow( | |
| 59 | text: "Couldn’t load assigned tickets.", | |
| 60 | systemImage: "exclamationmark.triangle", | |
| 61 | emphasized: true, | |
| 62 | accessibilityHint: error | |
| 63 | ) | |
| 64 | } else if viewModel.assignedTickets.isEmpty { | |
| 65 | HomeSectionMessageRow( | |
| 66 | text: "No open tickets assigned to you.", | |
| 67 | systemImage: "person.crop.circle.badge.checkmark" | |
| 68 | ) | |
| 69 | } else { | |
| 70 | ForEach(viewModel.assignedTickets.prefix(previewLimit)) { ticket in | |
| 71 | NavigationLink { | |
| 72 | TicketDetailView( | |
| 73 | ownerUsername: ticket.ownerUsername, | |
| 74 | trackerName: ticket.trackerName, | |
| 75 | trackerId: ticket.trackerId, | |
| 76 | trackerRid: ticket.trackerRid, | |
| 77 | ticketId: ticket.ticket.id | |
| 78 | ) | |
| 79 | } label: { | |
| 80 | HomeAssignedTicketRow(ticket: ticket) | |
| 81 | } | |
| 82 | } | |
| 83 | } | |
| 84 | } header: { | |
| 85 | HomeSectionHeader("Assigned Tickets") { | |
| 86 | HomeAssignedTicketsListView(viewModel: viewModel) | |
| 87 | } | |
| 88 | } | |
| 89 | } | |
| 90 | ||
| 91 | @ViewBuilder | |
| 92 | private func recentBuildsSection(_ viewModel: HomeViewModel) -> some View { | |
| 93 | Section { | |
| 94 | if viewModel.isLoadingRecentBuilds && viewModel.recentBuilds.isEmpty { | |
| 95 | HomeSectionLoadingRow(label: "Loading recent builds") | |
| 96 | } else if let error = viewModel.recentBuildsError, viewModel.recentBuilds.isEmpty { | |
| 97 | HomeSectionMessageRow( | |
| 98 | text: "Couldn’t load recent builds.", | |
| 99 | systemImage: "exclamationmark.triangle", | |
| 100 | emphasized: true, | |
| 101 | accessibilityHint: error | |
| 102 | ) | |
| 103 | } else if viewModel.recentBuilds.isEmpty { | |
| 104 | HomeSectionMessageRow( | |
| 105 | text: "No recent builds.", | |
| 106 | systemImage: "clock" | |
| 107 | ) | |
| 108 | } else { | |
| 109 | ForEach(viewModel.recentBuilds.prefix(previewLimit)) { build in | |
| 110 | NavigationLink { | |
| 111 | BuildDetailView(jobId: build.job.id) | |
| 112 | } label: { | |
| 113 | HomeBuildRow(build: build) | |
| 114 | } | |
| 115 | } | |
| 116 | } | |
| 117 | } header: { | |
| 118 | HomeSectionHeader("Recent Builds") { | |
| 119 | BuildListView() | |
| 120 | } | |
| 121 | } | |
| 122 | } | |
| 123 | ||
| 124 | } | |
| 125 | ||
| 126 | private struct HomeBuildRow: View { | |
| 127 | let build: HomeBuildItem | |
| 128 | ||
| 129 | var body: some View { | |
| 130 | HStack(spacing: 12) { | |
| 131 | JobStatusIcon(status: build.job.status) | |
| 132 | .frame(width: 20) | |
| 133 | ||
| 134 | VStack(alignment: .leading, spacing: 4) { | |
| 135 | Text(primaryTitle) | |
| 136 | .font(.subheadline.weight(.medium)) | |
| 137 | .lineLimit(1) | |
| 138 | ||
| 139 | HStack(spacing: 8) { | |
| 140 | Text("Job #\(build.job.id)") | |
| 141 | .font(.caption) | |
| 142 | .foregroundStyle(.secondary) | |
| 143 | ||
| 144 | Text("•") | |
| 145 | .font(.caption) | |
| 146 | .foregroundStyle(.tertiary) | |
| 147 | ||
| 148 | Text(build.job.status.rawValue.capitalized) | |
| 149 | .font(.caption) | |
| 150 | .foregroundStyle(.secondary) | |
| 151 | ||
| 152 | Text("•") | |
| 153 | .font(.caption) | |
| 154 | .foregroundStyle(.tertiary) | |
| 155 | ||
| 156 | Text(build.job.created.relativeDescription) | |
| 157 | .font(.caption) | |
| 158 | .foregroundStyle(.tertiary) | |
| 159 | ||
| 160 | Spacer() | |
| 161 | } | |
| 162 | } | |
| 163 | } | |
| 164 | .padding(.vertical, 2) | |
| 165 | } | |
| 166 | ||
| 167 | private var primaryTitle: String { | |
| 168 | if let repositoryDisplayName = build.repositoryDisplayName { | |
| 169 | return repositoryDisplayName | |
| 170 | } | |
| 171 | return build.job.displayLabel | |
| 172 | } | |
| 173 | } | |
| 174 | ||
| 175 | private struct HomeAssignedTicketRow: View { | |
| 176 | let ticket: HomeAssignedTicket | |
| 177 | ||
| 178 | var body: some View { | |
| 179 | HStack(alignment: .top, spacing: 12) { | |
| 180 | TicketStatusIcon(status: ticket.ticket.status) | |
| 181 | .frame(width: 20) | |
| 182 | ||
| 183 | VStack(alignment: .leading, spacing: 4) { | |
| 184 | Text(ticket.ticket.title) | |
| 185 | .font(.subheadline.weight(.medium)) | |
| 186 | .lineLimit(2) | |
| 187 | ||
| 188 | Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName) • #\(ticket.ticket.id) • \(ticket.ticket.created.relativeDescription)") | |
| 189 | .font(.caption) | |
| 190 | .foregroundStyle(.secondary) | |
| 191 | .lineLimit(1) | |
| 192 | .truncationMode(.tail) | |
| 193 | } | |
| 194 | ||
| 195 | Spacer(minLength: 8) | |
| 196 | ||
| 197 | Text(ticket.ticket.status.displayName) | |
| 198 | .font(.caption2.weight(.medium)) | |
| 199 | .foregroundStyle(.secondary) | |
| 200 | .lineLimit(1) | |
| 201 | .fixedSize() | |
| 202 | } | |
| 203 | .padding(.vertical, 2) | |
| 204 | } | |
| 205 | } | |
| 206 | ||
| 207 | private struct HomeSectionLoadingRow: View { | |
| 208 | let label: String | |
| 209 | ||
| 210 | var body: some View { | |
| 211 | HStack(spacing: 10) { | |
| 212 | ProgressView() | |
| 213 | .controlSize(.small) | |
| 214 | Text(label) | |
| 215 | .foregroundStyle(.secondary) | |
| 216 | } | |
| 217 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 218 | } | |
| 219 | } | |
| 220 | ||
| 221 | private struct HomeSectionHeader<Destination: View>: View { | |
| 222 | let title: String | |
| 223 | let destination: Destination | |
| 224 | ||
| 225 | init(_ title: String, @ViewBuilder destination: () -> Destination) { | |
| 226 | self.title = title | |
| 227 | self.destination = destination() | |
| 228 | } | |
| 229 | ||
| 230 | var body: some View { | |
| 231 | HStack { | |
| 232 | Text(title) | |
| 233 | Spacer() | |
| 234 | NavigationLink { | |
| 235 | destination | |
| 236 | } label: { | |
| 237 | Text("See All") | |
| 238 | .font(.caption.weight(.medium)) | |
| 239 | } | |
| 240 | .buttonStyle(.plain) | |
| 241 | } | |
| 242 | .textCase(nil) | |
| 243 | } | |
| 244 | } | |
| 245 | ||
| 246 | private struct HomeAssignedTicketsListView: View { | |
| 247 | let viewModel: HomeViewModel | |
| 248 | ||
| 249 | var body: some View { | |
| 250 | List { | |
| 251 | ForEach(viewModel.assignedTickets) { ticket in | |
| 252 | NavigationLink { | |
| 253 | TicketDetailView( | |
| 254 | ownerUsername: ticket.ownerUsername, | |
| 255 | trackerName: ticket.trackerName, | |
| 256 | trackerId: ticket.trackerId, | |
| 257 | trackerRid: ticket.trackerRid, | |
| 258 | ticketId: ticket.ticket.id | |
| 259 | ) | |
| 260 | } label: { | |
| 261 | HomeAssignedTicketRow(ticket: ticket) | |
| 262 | } | |
| 263 | } | |
| 264 | ||
| 265 | if !viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { | |
| 266 | HomeSectionMessageRow( | |
| 267 | text: "No open tickets assigned to you.", | |
| 268 | systemImage: "person.crop.circle.badge.checkmark" | |
| 269 | ) | |
| 270 | } | |
| 271 | } | |
| 272 | .navigationTitle("Assigned Tickets") | |
| 273 | .navigationBarTitleDisplayMode(.inline) | |
| 274 | .refreshable { | |
| 275 | await viewModel.loadDashboard() | |
| 276 | } | |
| 277 | .overlay { | |
| 278 | if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { | |
| 279 | SRHTLoadingStateView(message: "Loading assigned tickets…") | |
| 280 | } | |
| 281 | } | |
| 282 | } | |
| 283 | } | |
| 284 | ||
| 285 | private struct HomeSectionMessageRow: View { | |
| 286 | let text: String | |
| 287 | let systemImage: String | |
| 288 | var emphasized = false | |
| 289 | var accessibilityHint: String? = nil | |
| 290 | ||
| 291 | var body: some View { | |
| 292 | Label(text, systemImage: systemImage) | |
| 293 | .font(.subheadline) | |
| 294 | .foregroundStyle(emphasized ? .secondary : .tertiary) | |
| 295 | .accessibilityHint(accessibilityHint ?? "") | |
| 296 | } | |
| 297 | } | |
Hutch/Views/Home/HomeViewModel.swift added +443
| @@ -0,0 +1,443 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | private struct HomeJobsResponse: Decodable, Sendable { | |
| 4 | let jobs: HomeJobsPage | |
| 5 | } | |
| 6 | ||
| 7 | private struct HomeJobsPage: Decodable, Sendable { | |
| 8 | let results: [HomeJobPayload] | |
| 9 | } | |
| 10 | ||
| 11 | private struct HomeTrackersResponse: Decodable, Sendable { | |
| 12 | let trackers: HomeTrackersPage | |
| 13 | } | |
| 14 | ||
| 15 | private struct HomeTrackersPage: Decodable, Sendable { | |
| 16 | let results: [TrackerSummary] | |
| 17 | let cursor: String? | |
| 18 | } | |
| 19 | ||
| 20 | private struct HomeTrackerTicketsResponse: Decodable, Sendable { | |
| 21 | let user: HomeTrackerTicketsUser | |
| 22 | } | |
| 23 | ||
| 24 | private struct HomeTrackerTicketsUser: Decodable, Sendable { | |
| 25 | let tracker: HomeTrackerTicketsTracker | |
| 26 | } | |
| 27 | ||
| 28 | private struct HomeTrackerTicketsTracker: Decodable, Sendable { | |
| 29 | let tickets: HomeTrackerTicketsPage | |
| 30 | } | |
| 31 | ||
| 32 | private struct HomeTrackerTicketsPage: Decodable, Sendable { | |
| 33 | let results: [HomeTicketPayload] | |
| 34 | } | |
| 35 | ||
| 36 | private struct HomeTicketPayload: Decodable, Sendable { | |
| 37 | let id: Int | |
| 38 | let title: String | |
| 39 | let status: TicketStatus | |
| 40 | let resolution: TicketResolution? | |
| 41 | let created: Date | |
| 42 | let submitter: Entity | |
| 43 | let labels: [TicketLabel] | |
| 44 | let assignees: [Entity] | |
| 45 | ||
| 46 | enum CodingKeys: String, CodingKey { | |
| 47 | case id | |
| 48 | case title = "subject" | |
| 49 | case status | |
| 50 | case resolution | |
| 51 | case created | |
| 52 | case submitter | |
| 53 | case labels | |
| 54 | case assignees | |
| 55 | } | |
| 56 | ||
| 57 | var ticketSummary: TicketSummary { | |
| 58 | TicketSummary( | |
| 59 | id: id, | |
| 60 | title: title, | |
| 61 | status: status, | |
| 62 | resolution: resolution, | |
| 63 | created: created, | |
| 64 | submitter: submitter, | |
| 65 | labels: labels, | |
| 66 | assignees: assignees | |
| 67 | ) | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | struct HomeAssignedTicket: Identifiable, Hashable, Sendable { | |
| 72 | let trackerId: Int | |
| 73 | let trackerRid: String | |
| 74 | let trackerName: String | |
| 75 | let ownerCanonicalName: String | |
| 76 | let ticket: TicketSummary | |
| 77 | ||
| 78 | var id: String { | |
| 79 | "\(trackerRid)#\(ticket.id)" | |
| 80 | } | |
| 81 | ||
| 82 | var ownerUsername: String { | |
| 83 | if ownerCanonicalName.hasPrefix("~") { | |
| 84 | return String(ownerCanonicalName.dropFirst()) | |
| 85 | } | |
| 86 | return ownerCanonicalName | |
| 87 | } | |
| 88 | } | |
| 89 | ||
| 90 | struct HomeBuildItem: Identifiable, Hashable, Sendable { | |
| 91 | let job: JobSummary | |
| 92 | let repositoryName: String? | |
| 93 | let repositoryOwner: String? | |
| 94 | ||
| 95 | var id: Int { job.id } | |
| 96 | ||
| 97 | var repositoryDisplayName: String? { | |
| 98 | guard let repositoryName else { return nil } | |
| 99 | if let repositoryOwner { | |
| 100 | return "\(repositoryOwner)/\(repositoryName)" | |
| 101 | } | |
| 102 | return repositoryName | |
| 103 | } | |
| 104 | } | |
| 105 | ||
| 106 | @Observable | |
| 107 | @MainActor | |
| 108 | final class HomeViewModel { | |
| 109 | private(set) var failedBuilds: [HomeBuildItem] = [] | |
| 110 | private(set) var assignedTickets: [HomeAssignedTicket] = [] | |
| 111 | private(set) var recentBuilds: [HomeBuildItem] = [] | |
| 112 | private(set) var isLoadingFailedBuilds = false | |
| 113 | private(set) var isLoadingAssignedTickets = false | |
| 114 | private(set) var isLoadingRecentBuilds = false | |
| 115 | private(set) var failedBuildsError: String? | |
| 116 | private(set) var assignedTicketsError: String? | |
| 117 | private(set) var recentBuildsError: String? | |
| 118 | ||
| 119 | private let currentUser: User | |
| 120 | private let client: SRHTClient | |
| 121 | private let ticketFetchConcurrencyLimit = 6 | |
| 122 | ||
| 123 | private static let jobsQuery = """ | |
| 124 | query jobs { | |
| 125 | jobs { | |
| 126 | results { | |
| 127 | id | |
| 128 | created | |
| 129 | updated | |
| 130 | status | |
| 131 | note | |
| 132 | tags | |
| 133 | visibility | |
| 134 | image | |
| 135 | tasks { name status } | |
| 136 | manifest | |
| 137 | } | |
| 138 | } | |
| 139 | } | |
| 140 | """ | |
| 141 | ||
| 142 | private static let trackersQuery = """ | |
| 143 | query trackers($cursor: Cursor) { | |
| 144 | trackers(cursor: $cursor) { | |
| 145 | results { | |
| 146 | id | |
| 147 | rid | |
| 148 | name | |
| 149 | description | |
| 150 | visibility | |
| 151 | updated | |
| 152 | owner { canonicalName } | |
| 153 | } | |
| 154 | cursor | |
| 155 | } | |
| 156 | } | |
| 157 | """ | |
| 158 | ||
| 159 | private static let trackerTicketsQuery = """ | |
| 160 | query tickets($owner: String!, $tracker: String!) { | |
| 161 | user(username: $owner) { | |
| 162 | tracker(name: $tracker) { | |
| 163 | tickets { | |
| 164 | results { | |
| 165 | id | |
| 166 | subject | |
| 167 | status | |
| 168 | resolution | |
| 169 | created | |
| 170 | submitter { canonicalName } | |
| 171 | labels { id name backgroundColor foregroundColor } | |
| 172 | assignees { canonicalName } | |
| 173 | } | |
| 174 | } | |
| 175 | } | |
| 176 | } | |
| 177 | } | |
| 178 | """ | |
| 179 | ||
| 180 | init(currentUser: User, client: SRHTClient) { | |
| 181 | self.currentUser = currentUser | |
| 182 | self.client = client | |
| 183 | } | |
| 184 | ||
| 185 | func loadDashboard() async { | |
| 186 | isLoadingFailedBuilds = true | |
| 187 | isLoadingAssignedTickets = true | |
| 188 | isLoadingRecentBuilds = true | |
| 189 | failedBuildsError = nil | |
| 190 | assignedTicketsError = nil | |
| 191 | recentBuildsError = nil | |
| 192 | ||
| 193 | async let jobsTask = loadRecentJobs() | |
| 194 | async let assignedTicketsTask = loadAssignedTickets() | |
| 195 | ||
| 196 | let recentJobsResult = await jobsTask | |
| 197 | ||
| 198 | switch recentJobsResult { | |
| 199 | case .success(let recentJobs): | |
| 200 | let buildItems = Self.buildItems(from: recentJobs) | |
| 201 | self.recentBuilds = buildItems | |
| 202 | self.failedBuilds = Self.failedBuilds(from: buildItems) | |
| 203 | self.failedBuildsError = nil | |
| 204 | self.recentBuildsError = nil | |
| 205 | case .failure(let error): | |
| 206 | self.recentBuilds = [] | |
| 207 | self.failedBuilds = [] | |
| 208 | self.failedBuildsError = error.localizedDescription | |
| 209 | self.recentBuildsError = error.localizedDescription | |
| 210 | } | |
| 211 | isLoadingFailedBuilds = false | |
| 212 | isLoadingRecentBuilds = false | |
| 213 | ||
| 214 | let assignedTicketsResult = await assignedTicketsTask | |
| 215 | ||
| 216 | switch assignedTicketsResult { | |
| 217 | case .success(let assignedTickets): | |
| 218 | self.assignedTickets = assignedTickets | |
| 219 | self.assignedTicketsError = nil | |
| 220 | case .failure(let error): | |
| 221 | self.assignedTickets = [] | |
| 222 | self.assignedTicketsError = error.localizedDescription | |
| 223 | } | |
| 224 | isLoadingAssignedTickets = false | |
| 225 | } | |
| 226 | ||
| 227 | private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> { | |
| 228 | do { | |
| 229 | let response = try await client.execute( | |
| 230 | service: .builds, | |
| 231 | query: Self.jobsQuery, | |
| 232 | responseType: HomeJobsResponse.self | |
| 233 | ) | |
| 234 | return .success(response.jobs.results) | |
| 235 | } catch { | |
| 236 | return .failure(error) | |
| 237 | } | |
| 238 | } | |
| 239 | ||
| 240 | private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> { | |
| 241 | do { | |
| 242 | let trackers = try await fetchAllTrackers() | |
| 243 | let tickets = try await fetchAssignedTickets(for: trackers) | |
| 244 | .sorted { $0.ticket.created > $1.ticket.created } | |
| 245 | return .success(tickets) | |
| 246 | } catch { | |
| 247 | return .failure(error) | |
| 248 | } | |
| 249 | } | |
| 250 | ||
| 251 | private func fetchAllTrackers() async throws -> [TrackerSummary] { | |
| 252 | var allTrackers: [TrackerSummary] = [] | |
| 253 | var cursor: String? | |
| 254 | ||
| 255 | while true { | |
| 256 | var variables: [String: any Sendable] = [:] | |
| 257 | if let cursor { | |
| 258 | variables["cursor"] = cursor | |
| 259 | } | |
| 260 | ||
| 261 | let response = try await client.execute( | |
| 262 | service: .todo, | |
| 263 | query: Self.trackersQuery, | |
| 264 | variables: variables.isEmpty ? nil : variables, | |
| 265 | responseType: HomeTrackersResponse.self | |
| 266 | ) | |
| 267 | ||
| 268 | allTrackers.append(contentsOf: response.trackers.results) | |
| 269 | guard let nextCursor = response.trackers.cursor else { | |
| 270 | break | |
| 271 | } | |
| 272 | cursor = nextCursor | |
| 273 | } | |
| 274 | ||
| 275 | return allTrackers | |
| 276 | } | |
| 277 | ||
| 278 | private func fetchAssignedTickets(for trackers: [TrackerSummary]) async throws -> [HomeAssignedTicket] { | |
| 279 | guard !trackers.isEmpty else { return [] } | |
| 280 | ||
| 281 | var assignedTickets: [HomeAssignedTicket] = [] | |
| 282 | var startIndex = trackers.startIndex | |
| 283 | ||
| 284 | while startIndex < trackers.endIndex { | |
| 285 | let endIndex = trackers.index(startIndex, offsetBy: ticketFetchConcurrencyLimit, limitedBy: trackers.endIndex) ?? trackers.endIndex | |
| 286 | let batch = Array(trackers[startIndex..<endIndex]) | |
| 287 | ||
| 288 | let batchTickets = try await withThrowingTaskGroup(of: [HomeAssignedTicket].self) { group in | |
| 289 | for tracker in batch { | |
| 290 | group.addTask { | |
| 291 | try await self.fetchAssignedTickets(for: tracker) | |
| 292 | } | |
| 293 | } | |
| 294 | ||
| 295 | var ticketsForBatch: [HomeAssignedTicket] = [] | |
| 296 | for try await tickets in group { | |
| 297 | ticketsForBatch.append(contentsOf: tickets) | |
| 298 | } | |
| 299 | return ticketsForBatch | |
| 300 | } | |
| 301 | ||
| 302 | assignedTickets.append(contentsOf: batchTickets) | |
| 303 | startIndex = endIndex | |
| 304 | } | |
| 305 | ||
| 306 | return assignedTickets | |
| 307 | } | |
| 308 | ||
| 309 | private func fetchAssignedTickets(for tracker: TrackerSummary) async throws -> [HomeAssignedTicket] { | |
| 310 | let response = try await client.execute( | |
| 311 | service: .todo, | |
| 312 | query: Self.trackerTicketsQuery, | |
| 313 | variables: [ | |
| 314 | "owner": tracker.owner.canonicalName.hasPrefix("~") | |
| 315 | ? String(tracker.owner.canonicalName.dropFirst()) | |
| 316 | : tracker.owner.canonicalName, | |
| 317 | "tracker": tracker.name | |
| 318 | ], | |
| 319 | responseType: HomeTrackerTicketsResponse.self | |
| 320 | ) | |
| 321 | ||
| 322 | return response.user.tracker.tickets.results.compactMap { payload in | |
| 323 | guard payload.status.isOpen else { | |
| 324 | return nil | |
| 325 | } | |
| 326 | guard payload.assignees.contains(where: { Self.matchesCurrentUserAssignee($0, currentUser: currentUser) }) else { | |
| 327 | return nil | |
| 328 | } | |
| 329 | ||
| 330 | return HomeAssignedTicket( | |
| 331 | trackerId: tracker.id, | |
| 332 | trackerRid: tracker.rid, | |
| 333 | trackerName: tracker.name, | |
| 334 | ownerCanonicalName: tracker.owner.canonicalName, | |
| 335 | ticket: payload.ticketSummary | |
| 336 | ) | |
| 337 | } | |
| 338 | } | |
| 339 | ||
| 340 | nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { | |
| 341 | jobs.map { job in | |
| 342 | let repository = primaryRepositoryReference(in: job.manifest) | |
| 343 | return HomeBuildItem( | |
| 344 | job: job.jobSummary, | |
| 345 | repositoryName: repository?.name, | |
| 346 | repositoryOwner: repository?.ownerCanonicalName | |
| 347 | ) | |
| 348 | } | |
| 349 | } | |
| 350 | ||
| 351 | nonisolated static func failedBuilds(from builds: [HomeBuildItem]) -> [HomeBuildItem] { | |
| 352 | builds.filter { build in | |
| 353 | switch build.job.status { | |
| 354 | case .failed, .timeout: | |
| 355 | true | |
| 356 | default: | |
| 357 | false | |
| 358 | } | |
| 359 | } | |
| 360 | } | |
| 361 | ||
| 362 | nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { | |
| 363 | failedBuilds(from: buildItems(from: jobs)) | |
| 364 | } | |
| 365 | ||
| 366 | nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool { | |
| 367 | let assigneeCanonical = normalizedCanonicalName(entity.canonicalName) | |
| 368 | let currentCanonical = normalizedCanonicalName(currentUser.canonicalName) | |
| 369 | if assigneeCanonical == currentCanonical { | |
| 370 | return true | |
| 371 | } | |
| 372 | ||
| 373 | let assigneeUsername = normalizedUsername(entity.canonicalName) | |
| 374 | let currentUsername = normalizedUsername(currentUser.username) | |
| 375 | return assigneeUsername == currentUsername | |
| 376 | } | |
| 377 | ||
| 378 | nonisolated static func primaryRepositoryReference(in manifest: String?) -> (ownerCanonicalName: String, name: String)? { | |
| 379 | guard let manifest else { return nil } | |
| 380 | let pattern = #"(?:https://|ssh://(?:git|hg)@|(?:git|hg)@)(?:git|hg)\.sr\.ht[:/]([~][^/\s]+)/([^\s"'#]+)"# | |
| 381 | guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { | |
| 382 | return nil | |
| 383 | } | |
| 384 | let nsRange = NSRange(manifest.startIndex..<manifest.endIndex, in: manifest) | |
| 385 | guard let match = regex.firstMatch(in: manifest, options: [], range: nsRange), | |
| 386 | let ownerRange = Range(match.range(at: 1), in: manifest), | |
| 387 | let nameRange = Range(match.range(at: 2), in: manifest) else { | |
| 388 | return nil | |
| 389 | } | |
| 390 | ||
| 391 | let owner = String(manifest[ownerRange]) | |
| 392 | var name = String(manifest[nameRange]) | |
| 393 | if let suffixRange = name.range(of: ".git", options: [.backwards, .anchored]) { | |
| 394 | name.removeSubrange(suffixRange) | |
| 395 | } | |
| 396 | name = name.trimmingCharacters(in: CharacterSet(charactersIn: "/")) | |
| 397 | guard !name.isEmpty else { return nil } | |
| 398 | return (owner, name) | |
| 399 | } | |
| 400 | ||
| 401 | private nonisolated static func normalizedCanonicalName(_ value: String) -> String { | |
| 402 | let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 403 | guard !trimmed.isEmpty else { return trimmed } | |
| 404 | if trimmed.hasPrefix("~") { | |
| 405 | return trimmed | |
| 406 | } | |
| 407 | return "~\(trimmed)" | |
| 408 | } | |
| 409 | ||
| 410 | private nonisolated static func normalizedUsername(_ value: String) -> String { | |
| 411 | let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 412 | if trimmed.hasPrefix("~") { | |
| 413 | return String(trimmed.dropFirst()) | |
| 414 | } | |
| 415 | return trimmed | |
| 416 | } | |
| 417 | } | |
| 418 | struct HomeJobPayload: Decodable, Sendable { | |
| 419 | let id: Int | |
| 420 | let created: Date | |
| 421 | let updated: Date | |
| 422 | let status: JobStatus | |
| 423 | let note: String? | |
| 424 | let tags: [String] | |
| 425 | let visibility: Visibility? | |
| 426 | let image: String? | |
| 427 | let tasks: [JobTaskSummary] | |
| 428 | let manifest: String? | |
| 429 | ||
| 430 | nonisolated var jobSummary: JobSummary { | |
| 431 | JobSummary( | |
| 432 | id: id, | |
| 433 | created: created, | |
| 434 | updated: updated, | |
| 435 | status: status, | |
| 436 | note: note, | |
| 437 | tags: tags, | |
| 438 | visibility: visibility, | |
| 439 | image: image, | |
| 440 | tasks: tasks | |
| 441 | ) | |
| 442 | } | |
| 443 | } | |
Hutch/Views/Repositories/RepositoryListView.swift +4 −1
| @@ -63,7 +63,10 @@ struct RepositoryListView: View { | ||
| 63 | 63 | List { |
| 64 | 64 | ForEach(viewModel.repositories) { repo in |
| 65 | 65 | NavigationLink(value: repo) { |
| 66 | RepositoryRowView(repository: repo) | |
| 66 | RepositoryRowView( | |
| 67 | repository: repo, | |
| 68 | buildStatus: viewModel.latestBuildStatus(for: repo) | |
| 69 | ) | |
| 67 | 70 | } |
| 68 | 71 | .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } |
| 69 | 72 | .task { |
Hutch/Views/Repositories/RepositoryListViewModel.swift +185
| @@ -27,6 +27,7 @@ enum RepositoryCreationService: String, CaseIterable, Identifiable, Sendable { | ||
| 27 | 27 | final class RepositoryListViewModel { |
| 28 | 28 | |
| 29 | 29 | private(set) var repositories: [RepositorySummary] = [] |
| 30 | private(set) var latestBuildStatuses: [String: RepositoryBuildStatus] = [:] | |
| 30 | 31 | private(set) var isLoading = false |
| 31 | 32 | private(set) var isLoadingMore = false |
| 32 | 33 | private(set) var isRefreshing = false |
| @@ -41,9 +42,11 @@ final class RepositoryListViewModel { | ||
| 41 | 42 | private(set) var hasLoadedSearchIndex = false |
| 42 | 43 | private var searchIndex: [RepositorySummary] = [] |
| 43 | 44 | private let client: SRHTClient |
| 45 | private var buildStatusTask: Task<Void, Never>? | |
| 44 | 46 | |
| 45 | 47 | private static let gitCacheKey = "git.repositories" |
| 46 | 48 | private static let hgCacheKey = "hg.repositories" |
| 49 | private static let buildsCacheKey = "builds.repository-status" | |
| 47 | 50 | private static let minimumRemoteSearchLength = 3 |
| 48 | 51 | |
| 49 | 52 | init(client: SRHTClient) { |
| @@ -117,6 +120,20 @@ final class RepositoryListViewModel { | ||
| 117 | 120 | } |
| 118 | 121 | """ |
| 119 | 122 | |
| 123 | private static let buildsQuery = """ | |
| 124 | query jobs($cursor: Cursor) { | |
| 125 | jobs(cursor: $cursor) { | |
| 126 | results { | |
| 127 | id | |
| 128 | created | |
| 129 | status | |
| 130 | manifest | |
| 131 | } | |
| 132 | cursor | |
| 133 | } | |
| 134 | } | |
| 135 | """ | |
| 136 | ||
| 120 | 137 | // MARK: - Public API |
| 121 | 138 | |
| 122 | 139 | /// Fetch the first page of repositories. Shows cached data instantly if available, |
| @@ -168,6 +185,7 @@ final class RepositoryListViewModel { | ||
| 168 | 185 | } |
| 169 | 186 | |
| 170 | 187 | repositories = filteredResults.sorted(by: repositorySortOrder) |
| 188 | scheduleBuildStatusRefresh() | |
| 171 | 189 | } catch { |
| 172 | 190 | // Only show error if we have no cached data to fall back on |
| 173 | 191 | if repositories.isEmpty { |
| @@ -245,6 +263,7 @@ final class RepositoryListViewModel { | ||
| 245 | 263 | } |
| 246 | 264 | repositories.insert(repository, at: 0) |
| 247 | 265 | insertIntoSearchIndex(repository) |
| 266 | scheduleBuildStatusRefresh() | |
| 248 | 267 | return repository |
| 249 | 268 | } catch { |
| 250 | 269 | self.error = repositoryCreationErrorMessage(for: error) |
| @@ -306,6 +325,22 @@ final class RepositoryListViewModel { | ||
| 306 | 325 | let createRepository: HGRepositoryPayload |
| 307 | 326 | } |
| 308 | 327 | |
| 328 | private struct BuildJobsResponse: Decodable, Sendable { | |
| 329 | let jobs: BuildJobsPage | |
| 330 | } | |
| 331 | ||
| 332 | private struct BuildJobsPage: Decodable, Sendable { | |
| 333 | let results: [BuildStatusPayload] | |
| 334 | let cursor: String? | |
| 335 | } | |
| 336 | ||
| 337 | private struct BuildStatusPayload: Decodable, Sendable { | |
| 338 | let id: Int | |
| 339 | let created: Date | |
| 340 | let status: JobStatus | |
| 341 | let manifest: String? | |
| 342 | } | |
| 343 | ||
| 309 | 344 | private struct HGPage: Decodable, Sendable { |
| 310 | 345 | let results: [HGRepositoryPayload] |
| 311 | 346 | let cursor: String? |
| @@ -380,6 +415,10 @@ final class RepositoryListViewModel { | ||
| 380 | 415 | searchIndex |
| 381 | 416 | } |
| 382 | 417 | |
| 418 | func latestBuildStatus(for repository: RepositorySummary) -> RepositoryBuildStatus { | |
| 419 | latestBuildStatuses[Self.buildStatusCacheKey(for: repository)] ?? RepositoryBuildStatus.none | |
| 420 | } | |
| 421 | ||
| 383 | 422 | private func fetchPage( |
| 384 | 423 | service: SRHTService, |
| 385 | 424 | cursor: String?, |
| @@ -514,9 +553,94 @@ final class RepositoryListViewModel { | ||
| 514 | 553 | let sortedRepositories = cachedRepositories.sorted(by: repositorySortOrder) |
| 515 | 554 | repositories = sortedRepositories |
| 516 | 555 | updateSearchIndex(with: sortedRepositories) |
| 556 | scheduleBuildStatusRefresh() | |
| 517 | 557 | } |
| 518 | 558 | } |
| 519 | 559 | |
| 560 | private func scheduleBuildStatusRefresh() { | |
| 561 | let repositoriesSnapshot = repositories | |
| 562 | buildStatusTask?.cancel() | |
| 563 | buildStatusTask = Task { [weak self] in | |
| 564 | guard let self else { return } | |
| 565 | await self.loadLatestBuildStatuses(for: repositoriesSnapshot) | |
| 566 | } | |
| 567 | } | |
| 568 | ||
| 569 | private func loadLatestBuildStatuses(for repositories: [RepositorySummary]) async { | |
| 570 | let targetKeys = Set(repositories.map(Self.buildStatusCacheKey(for:))) | |
| 571 | guard !targetKeys.isEmpty else { | |
| 572 | await MainActor.run { | |
| 573 | latestBuildStatuses = [:] | |
| 574 | } | |
| 575 | return | |
| 576 | } | |
| 577 | ||
| 578 | var resolvedStatuses: [String: (Date, RepositoryBuildStatus)] = [:] | |
| 579 | var cursor: String? | |
| 580 | var shouldUseCache = true | |
| 581 | ||
| 582 | do { | |
| 583 | while !Task.isCancelled { | |
| 584 | let page = try await fetchBuildStatusPage(cursor: cursor, useCache: shouldUseCache) | |
| 585 | shouldUseCache = false | |
| 586 | ||
| 587 | for job in page.results { | |
| 588 | let jobStatus = Self.repositoryBuildStatus(for: job.status) | |
| 589 | guard let manifest = job.manifest else { continue } | |
| 590 | ||
| 591 | for key in Self.buildStatusKeys(in: manifest) where targetKeys.contains(key) { | |
| 592 | let existing = resolvedStatuses[key] | |
| 593 | if existing == nil || existing!.0 < job.created { | |
| 594 | resolvedStatuses[key] = (job.created, jobStatus) | |
| 595 | } | |
| 596 | } | |
| 597 | } | |
| 598 | ||
| 599 | if resolvedStatuses.count == targetKeys.count || page.cursor == nil { | |
| 600 | break | |
| 601 | } | |
| 602 | cursor = page.cursor | |
| 603 | } | |
| 604 | ||
| 605 | let finalStatuses = targetKeys.reduce(into: [String: RepositoryBuildStatus]()) { result, key in | |
| 606 | result[key] = resolvedStatuses[key]?.1 ?? RepositoryBuildStatus.none | |
| 607 | } | |
| 608 | ||
| 609 | await MainActor.run { | |
| 610 | guard repositories == self.repositories else { return } | |
| 611 | latestBuildStatuses = finalStatuses | |
| 612 | } | |
| 613 | } catch { | |
| 614 | // Build status is auxiliary data for the list. Leave the default gray state on failure. | |
| 615 | } | |
| 616 | } | |
| 617 | ||
| 618 | private func fetchBuildStatusPage(cursor: String?, useCache: Bool) async throws -> BuildJobsPage { | |
| 619 | var variables: [String: any Sendable] = [:] | |
| 620 | if let cursor { | |
| 621 | variables["cursor"] = cursor | |
| 622 | } | |
| 623 | ||
| 624 | if useCache && cursor == nil { | |
| 625 | let result = try await client.executeAndCache( | |
| 626 | service: .builds, | |
| 627 | query: Self.buildsQuery, | |
| 628 | variables: variables.isEmpty ? nil : variables, | |
| 629 | responseType: BuildJobsResponse.self, | |
| 630 | cacheKey: Self.buildsCacheKey | |
| 631 | ) | |
| 632 | return result.jobs | |
| 633 | } | |
| 634 | ||
| 635 | let result = try await client.execute( | |
| 636 | service: .builds, | |
| 637 | query: Self.buildsQuery, | |
| 638 | variables: variables.isEmpty ? nil : variables, | |
| 639 | responseType: BuildJobsResponse.self | |
| 640 | ) | |
| 641 | return result.jobs | |
| 642 | } | |
| 643 | ||
| 520 | 644 | private func fetchRepositories(for service: SRHTService, useCache: Bool) async throws -> [RepositorySummary] { |
| 521 | 645 | var allRepositories: [RepositorySummary] = [] |
| 522 | 646 | var currentCursor: String? = nil |
| @@ -580,6 +704,67 @@ final class RepositoryListViewModel { | ||
| 580 | 704 | repo.description?.lowercased().contains(lowercasedQuery) ?? false |
| 581 | 705 | } |
| 582 | 706 | } |
| 707 | ||
| 708 | nonisolated static func buildStatusCacheKey(for repository: RepositorySummary) -> String { | |
| 709 | buildStatusCacheKey( | |
| 710 | service: repository.service, | |
| 711 | ownerCanonicalName: repository.owner.canonicalName, | |
| 712 | repositoryName: repository.name | |
| 713 | ) | |
| 714 | } | |
| 715 | ||
| 716 | nonisolated static func buildStatusCacheKey( | |
| 717 | service: SRHTService, | |
| 718 | ownerCanonicalName: String, | |
| 719 | repositoryName: String | |
| 720 | ) -> String { | |
| 721 | "\(service.rawValue)|\(ownerCanonicalName.lowercased())|\(repositoryName.lowercased())" | |
| 722 | } | |
| 723 | ||
| 724 | nonisolated static func repositoryBuildStatus(for jobStatus: JobStatus) -> RepositoryBuildStatus { | |
| 725 | switch jobStatus { | |
| 726 | case .success: | |
| 727 | .success | |
| 728 | case .pending, .queued, .running: | |
| 729 | .running | |
| 730 | case .failed, .cancelled, .timeout: | |
| 731 | .failed | |
| 732 | } | |
| 733 | } | |
| 734 | ||
| 735 | nonisolated static func buildStatusKeys(in manifest: String) -> Set<String> { | |
| 736 | let pattern = #"(?:https://|ssh://(?:git|hg)@|(?:git|hg)@)(git|hg)\.sr\.ht[:/]([~][^/\s]+)/([^\s"'#]+)"# | |
| 737 | guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { | |
| 738 | return [] | |
| 739 | } | |
| 740 | ||
| 741 | let nsRange = NSRange(manifest.startIndex..<manifest.endIndex, in: manifest) | |
| 742 | return regex.matches(in: manifest, options: [], range: nsRange).reduce(into: Set<String>()) { result, match in | |
| 743 | guard | |
| 744 | let serviceRange = Range(match.range(at: 1), in: manifest), | |
| 745 | let ownerRange = Range(match.range(at: 2), in: manifest), | |
| 746 | let nameRange = Range(match.range(at: 3), in: manifest) | |
| 747 | else { | |
| 748 | return | |
| 749 | } | |
| 750 | ||
| 751 | let service: SRHTService = manifest[serviceRange].lowercased() == "hg" ? .hg : .git | |
| 752 | let owner = String(manifest[ownerRange]).lowercased() | |
| 753 | var name = String(manifest[nameRange]).lowercased() | |
| 754 | ||
| 755 | if let suffixRange = name.range(of: ".git", options: [.backwards, .anchored]) { | |
| 756 | name.removeSubrange(suffixRange) | |
| 757 | } | |
| 758 | name = name.trimmingCharacters(in: CharacterSet(charactersIn: "/")) | |
| 759 | if !name.isEmpty { | |
| 760 | result.insert(buildStatusCacheKey( | |
| 761 | service: service, | |
| 762 | ownerCanonicalName: owner, | |
| 763 | repositoryName: name | |
| 764 | )) | |
| 765 | } | |
| 766 | } | |
| 767 | } | |
| 583 | 768 | } |
| 584 | 769 | |
| 585 | 770 | private extension Array { |
Hutch/Views/Repositories/RepositoryRowView.swift +45
| @@ -2,6 +2,7 @@ import SwiftUI | ||
| 2 | 2 | |
| 3 | 3 | struct RepositoryRowView: View { |
| 4 | 4 | let repository: RepositorySummary |
| 5 | let buildStatus: RepositoryBuildStatus | |
| 5 | 6 | |
| 6 | 7 | var body: some View { |
| 7 | 8 | VStack(alignment: .leading, spacing: 4) { |
| @@ -20,6 +21,9 @@ struct RepositoryRowView: View { | ||
| 20 | 21 | .foregroundStyle(.cyan) |
| 21 | 22 | } |
| 22 | 23 | |
| 24 | if buildStatus != .none { | |
| 25 | RepositoryBuildStatusIndicator(status: buildStatus) | |
| 26 | } | |
| 23 | 27 | VisibilityBadge(visibility: repository.visibility) |
| 24 | 28 | } |
| 25 | 29 | |
| @@ -53,6 +57,47 @@ struct RepositoryRowView: View { | ||
| 53 | 57 | } |
| 54 | 58 | } |
| 55 | 59 | |
| 60 | private struct RepositoryBuildStatusIndicator: View { | |
| 61 | let status: RepositoryBuildStatus | |
| 62 | ||
| 63 | var body: some View { | |
| 64 | Circle() | |
| 65 | .fill(color) | |
| 66 | .frame(width: 8, height: 8) | |
| 67 | .overlay { | |
| 68 | Circle() | |
| 69 | .strokeBorder(.primary.opacity(0.08)) | |
| 70 | } | |
| 71 | .accessibilityLabel(accessibilityLabel) | |
| 72 | } | |
| 73 | ||
| 74 | private var color: Color { | |
| 75 | switch status { | |
| 76 | case .success: | |
| 77 | .green | |
| 78 | case .failed: | |
| 79 | .red | |
| 80 | case .running: | |
| 81 | .orange | |
| 82 | case .none: | |
| 83 | .clear | |
| 84 | } | |
| 85 | } | |
| 86 | ||
| 87 | private var accessibilityLabel: String { | |
| 88 | switch status { | |
| 89 | case .success: | |
| 90 | "Latest build succeeded" | |
| 91 | case .failed: | |
| 92 | "Latest build failed" | |
| 93 | case .running: | |
| 94 | "Latest build is running" | |
| 95 | case .none: | |
| 96 | "No recent builds" | |
| 97 | } | |
| 98 | } | |
| 99 | } | |
| 100 | ||
| 56 | 101 | // MARK: - VisibilityBadge |
| 57 | 102 | |
| 58 | 103 | struct VisibilityBadge: View { |
Hutch/Views/Repositories/RepositorySummarySupport.swift +7
| @@ -1,5 +1,12 @@ | ||
| 1 | 1 | import SwiftUI |
| 2 | 2 | |
| 3 | enum RepositoryBuildStatus: Sendable { | |
| 4 | case success | |
| 5 | case failed | |
| 6 | case running | |
| 7 | case none | |
| 8 | } | |
| 9 | ||
| 3 | 10 | struct RepositoryCloneURLs { |
| 4 | 11 | let readOnly: String |
| 5 | 12 | let readWrite: String |
HutchTests/HomeViewModelTests.swift added +67
| @@ -0,0 +1,67 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import Hutch | |
| 4 | ||
| 5 | struct HomeViewModelTests { | |
| 6 | ||
| 7 | @Test | |
| 8 | func failedBuildsKeepsOnlyFailedAndTimedOutJobs() { | |
| 9 | let jobs = [ | |
| 10 | makeJob(id: 1, status: .success, created: Date(timeIntervalSince1970: 10)), | |
| 11 | makeJob(id: 2, status: .failed, created: Date(timeIntervalSince1970: 20)), | |
| 12 | makeJob(id: 3, status: .timeout, created: Date(timeIntervalSince1970: 30)), | |
| 13 | makeJob(id: 4, status: .running, created: Date(timeIntervalSince1970: 40)) | |
| 14 | ] | |
| 15 | ||
| 16 | let failedBuilds = HomeViewModel.failedBuilds(from: jobs) | |
| 17 | ||
| 18 | #expect(failedBuilds.map(\.job.id) == [2, 3]) | |
| 19 | } | |
| 20 | ||
| 21 | @Test | |
| 22 | func matchesCurrentUserAssigneeNormalizesCanonicalNameAndUsername() { | |
| 23 | let currentUser = User( | |
| 24 | id: 42, | |
| 25 | username: "owner", | |
| 26 | canonicalName: "~owner", | |
| 27 | email: "owner@example.com", | |
| 28 | avatar: nil | |
| 29 | ) | |
| 30 | ||
| 31 | #expect(HomeViewModel.matchesCurrentUserAssignee(Entity(canonicalName: "~owner"), currentUser: currentUser)) | |
| 32 | #expect(HomeViewModel.matchesCurrentUserAssignee(Entity(canonicalName: "owner"), currentUser: currentUser)) | |
| 33 | #expect(HomeViewModel.matchesCurrentUserAssignee(Entity(canonicalName: currentUser.username), currentUser: currentUser)) | |
| 34 | #expect(HomeViewModel.matchesCurrentUserAssignee(Entity(canonicalName: "~someone-else"), currentUser: currentUser) == false) | |
| 35 | } | |
| 36 | ||
| 37 | @Test | |
| 38 | func primaryRepositoryReferenceParsesManifestSourceURL() { | |
| 39 | let manifest = """ | |
| 40 | image: alpine/latest | |
| 41 | sources: | |
| 42 | - https://git.sr.ht/~owner/hutch | |
| 43 | tasks: | |
| 44 | - true | |
| 45 | """ | |
| 46 | ||
| 47 | let repository = HomeViewModel.primaryRepositoryReference(in: manifest) | |
| 48 | ||
| 49 | #expect(repository?.ownerCanonicalName == "~owner") | |
| 50 | #expect(repository?.name == "hutch") | |
| 51 | } | |
| 52 | ||
| 53 | private func makeJob(id: Int, status: JobStatus, created: Date) -> HomeJobPayload { | |
| 54 | HomeJobPayload( | |
| 55 | id: id, | |
| 56 | created: created, | |
| 57 | updated: created, | |
| 58 | status: status, | |
| 59 | note: nil, | |
| 60 | tags: [], | |
| 61 | visibility: nil, | |
| 62 | image: nil, | |
| 63 | tasks: [], | |
| 64 | manifest: nil | |
| 65 | ) | |
| 66 | } | |
| 67 | } | |
HutchTests/RepositoryListViewModelTests.swift +26
| @@ -28,6 +28,32 @@ struct RepositoryListViewModelTests { | ||
| 28 | 28 | #expect(descriptionMatches.map(\.id) == [2]) |
| 29 | 29 | } |
| 30 | 30 | |
| 31 | @Test | |
| 32 | func buildStatusKeysParsesSourceHutRepositoryURLsFromManifest() { | |
| 33 | let manifest = """ | |
| 34 | image: alpine/latest | |
| 35 | sources: | |
| 36 | - https://git.sr.ht/~owner/hutch | |
| 37 | - ssh://hg@hg.sr.ht/~owner/wiki | |
| 38 | tasks: | |
| 39 | - echo "build" | |
| 40 | """ | |
| 41 | ||
| 42 | let keys = RepositoryListViewModel.buildStatusKeys(in: manifest) | |
| 43 | ||
| 44 | #expect(keys.contains("git|~owner|hutch")) | |
| 45 | #expect(keys.contains("hg|~owner|wiki")) | |
| 46 | } | |
| 47 | ||
| 48 | @Test | |
| 49 | func repositoryBuildStatusMapsJobStatesToRowStates() { | |
| 50 | #expect(RepositoryListViewModel.repositoryBuildStatus(for: .success) == .success) | |
| 51 | #expect(RepositoryListViewModel.repositoryBuildStatus(for: .running) == .running) | |
| 52 | #expect(RepositoryListViewModel.repositoryBuildStatus(for: .queued) == .running) | |
| 53 | #expect(RepositoryListViewModel.repositoryBuildStatus(for: .failed) == .failed) | |
| 54 | #expect(RepositoryListViewModel.repositoryBuildStatus(for: .timeout) == .failed) | |
| 55 | } | |
| 56 | ||
| 31 | 57 | @MainActor |
| 32 | 58 | private func makeRepository( |
| 33 | 59 | id: Int, |