Commit 4c67bc570f

4c67bc570ffaa25dd84f11dc4f77527792a5602b

parent: ce9f6b7c8d

Unsigned

cmc <hello@cleberg.net> · 2026-03-19 03:56 UTC

add Home dashboard and repository build status indicators

Layout: unified · split

Hutch/App/RootView.swift +13 −2
@@ -6,13 +6,15 @@ struct RootView: View {
6 @Environment(AppState.self) private var appState 6 @Environment(AppState.self) private var appState
7 7
8 enum Tab: Hashable { 8 enum Tab: Hashable {
9 case home
9 case repositories 10 case repositories
10 case builds 11 case builds
11 case tickets 12 case tickets
12 case settings 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 @State private var repoPath = NavigationPath() 18 @State private var repoPath = NavigationPath()
17 @State private var buildsPath = NavigationPath() 19 @State private var buildsPath = NavigationPath()
18 @State private var ticketsPath = NavigationPath() 20 @State private var ticketsPath = NavigationPath()
@@ -47,6 +49,14 @@ struct RootView: View {
47 49
48 private var tabContent: some View { 50 private var tabContent: some View {
49 TabView(selection: $selectedTab) { 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 NavigationStack(path: $repoPath) { 60 NavigationStack(path: $repoPath) {
51 RepositoryListView() 61 RepositoryListView()
52 } 62 }
@@ -106,10 +116,11 @@ struct RootView: View {
106 case .launching: 116 case .launching:
107 break 117 break
108 case .unauthenticated: 118 case .unauthenticated:
119 homePath = NavigationPath()
109 repoPath = NavigationPath() 120 repoPath = NavigationPath()
110 buildsPath = NavigationPath() 121 buildsPath = NavigationPath()
111 ticketsPath = NavigationPath() 122 ticketsPath = NavigationPath()
112 selectedTab = .repositories 123 selectedTab = .home
113 isResolvingDeepLink = false 124 isResolvingDeepLink = false
114 case .authenticated: 125 case .authenticated:
115 consumePendingDeepLinkIfPossible(appState.pendingDeepLink) 126 consumePendingDeepLinkIfPossible(appState.pendingDeepLink)
Hutch/Views/Home/HomeView.swift added +297
@@ -0,0 +1,297 @@
1import SwiftUI
2
3struct 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
126private 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
175private 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
207private 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
221private 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
246private 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
285private 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 @@
1import Foundation
2
3private struct HomeJobsResponse: Decodable, Sendable {
4 let jobs: HomeJobsPage
5}
6
7private struct HomeJobsPage: Decodable, Sendable {
8 let results: [HomeJobPayload]
9}
10
11private struct HomeTrackersResponse: Decodable, Sendable {
12 let trackers: HomeTrackersPage
13}
14
15private struct HomeTrackersPage: Decodable, Sendable {
16 let results: [TrackerSummary]
17 let cursor: String?
18}
19
20private struct HomeTrackerTicketsResponse: Decodable, Sendable {
21 let user: HomeTrackerTicketsUser
22}
23
24private struct HomeTrackerTicketsUser: Decodable, Sendable {
25 let tracker: HomeTrackerTicketsTracker
26}
27
28private struct HomeTrackerTicketsTracker: Decodable, Sendable {
29 let tickets: HomeTrackerTicketsPage
30}
31
32private struct HomeTrackerTicketsPage: Decodable, Sendable {
33 let results: [HomeTicketPayload]
34}
35
36private 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
71struct 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
90struct 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
108final 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}
418struct 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 List { 63 List {
64 ForEach(viewModel.repositories) { repo in 64 ForEach(viewModel.repositories) { repo in
65 NavigationLink(value: repo) { 65 NavigationLink(value: repo) {
66 RepositoryRowView(repository: repo) 66 RepositoryRowView(
67 repository: repo,
68 buildStatus: viewModel.latestBuildStatus(for: repo)
69 )
67 } 70 }
68 .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } 71 .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
69 .task { 72 .task {
Hutch/Views/Repositories/RepositoryListViewModel.swift +185
@@ -27,6 +27,7 @@ enum RepositoryCreationService: String, CaseIterable, Identifiable, Sendable {
27final class RepositoryListViewModel { 27final class RepositoryListViewModel {
28 28
29 private(set) var repositories: [RepositorySummary] = [] 29 private(set) var repositories: [RepositorySummary] = []
30 private(set) var latestBuildStatuses: [String: RepositoryBuildStatus] = [:]
30 private(set) var isLoading = false 31 private(set) var isLoading = false
31 private(set) var isLoadingMore = false 32 private(set) var isLoadingMore = false
32 private(set) var isRefreshing = false 33 private(set) var isRefreshing = false
@@ -41,9 +42,11 @@ final class RepositoryListViewModel {
41 private(set) var hasLoadedSearchIndex = false 42 private(set) var hasLoadedSearchIndex = false
42 private var searchIndex: [RepositorySummary] = [] 43 private var searchIndex: [RepositorySummary] = []
43 private let client: SRHTClient 44 private let client: SRHTClient
45 private var buildStatusTask: Task<Void, Never>?
44 46
45 private static let gitCacheKey = "git.repositories" 47 private static let gitCacheKey = "git.repositories"
46 private static let hgCacheKey = "hg.repositories" 48 private static let hgCacheKey = "hg.repositories"
49 private static let buildsCacheKey = "builds.repository-status"
47 private static let minimumRemoteSearchLength = 3 50 private static let minimumRemoteSearchLength = 3
48 51
49 init(client: SRHTClient) { 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 // MARK: - Public API 137 // MARK: - Public API
121 138
122 /// Fetch the first page of repositories. Shows cached data instantly if available, 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 repositories = filteredResults.sorted(by: repositorySortOrder) 187 repositories = filteredResults.sorted(by: repositorySortOrder)
188 scheduleBuildStatusRefresh()
171 } catch { 189 } catch {
172 // Only show error if we have no cached data to fall back on 190 // Only show error if we have no cached data to fall back on
173 if repositories.isEmpty { 191 if repositories.isEmpty {
@@ -245,6 +263,7 @@ final class RepositoryListViewModel {
245 } 263 }
246 repositories.insert(repository, at: 0) 264 repositories.insert(repository, at: 0)
247 insertIntoSearchIndex(repository) 265 insertIntoSearchIndex(repository)
266 scheduleBuildStatusRefresh()
248 return repository 267 return repository
249 } catch { 268 } catch {
250 self.error = repositoryCreationErrorMessage(for: error) 269 self.error = repositoryCreationErrorMessage(for: error)
@@ -306,6 +325,22 @@ final class RepositoryListViewModel {
306 let createRepository: HGRepositoryPayload 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 private struct HGPage: Decodable, Sendable { 344 private struct HGPage: Decodable, Sendable {
310 let results: [HGRepositoryPayload] 345 let results: [HGRepositoryPayload]
311 let cursor: String? 346 let cursor: String?
@@ -380,6 +415,10 @@ final class RepositoryListViewModel {
380 searchIndex 415 searchIndex
381 } 416 }
382 417
418 func latestBuildStatus(for repository: RepositorySummary) -> RepositoryBuildStatus {
419 latestBuildStatuses[Self.buildStatusCacheKey(for: repository)] ?? RepositoryBuildStatus.none
420 }
421
383 private func fetchPage( 422 private func fetchPage(
384 service: SRHTService, 423 service: SRHTService,
385 cursor: String?, 424 cursor: String?,
@@ -514,9 +553,94 @@ final class RepositoryListViewModel {
514 let sortedRepositories = cachedRepositories.sorted(by: repositorySortOrder) 553 let sortedRepositories = cachedRepositories.sorted(by: repositorySortOrder)
515 repositories = sortedRepositories 554 repositories = sortedRepositories
516 updateSearchIndex(with: sortedRepositories) 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 private func fetchRepositories(for service: SRHTService, useCache: Bool) async throws -> [RepositorySummary] { 644 private func fetchRepositories(for service: SRHTService, useCache: Bool) async throws -> [RepositorySummary] {
521 var allRepositories: [RepositorySummary] = [] 645 var allRepositories: [RepositorySummary] = []
522 var currentCursor: String? = nil 646 var currentCursor: String? = nil
@@ -580,6 +704,67 @@ final class RepositoryListViewModel {
580 repo.description?.lowercased().contains(lowercasedQuery) ?? false 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
585private extension Array { 770private extension Array {
Hutch/Views/Repositories/RepositoryRowView.swift +45
@@ -2,6 +2,7 @@ import SwiftUI
2 2
3struct RepositoryRowView: View { 3struct RepositoryRowView: View {
4 let repository: RepositorySummary 4 let repository: RepositorySummary
5 let buildStatus: RepositoryBuildStatus
5 6
6 var body: some View { 7 var body: some View {
7 VStack(alignment: .leading, spacing: 4) { 8 VStack(alignment: .leading, spacing: 4) {
@@ -20,6 +21,9 @@ struct RepositoryRowView: View {
20 .foregroundStyle(.cyan) 21 .foregroundStyle(.cyan)
21 } 22 }
22 23
24 if buildStatus != .none {
25 RepositoryBuildStatusIndicator(status: buildStatus)
26 }
23 VisibilityBadge(visibility: repository.visibility) 27 VisibilityBadge(visibility: repository.visibility)
24 } 28 }
25 29
@@ -53,6 +57,47 @@ struct RepositoryRowView: View {
53 } 57 }
54} 58}
55 59
60private 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// MARK: - VisibilityBadge 101// MARK: - VisibilityBadge
57 102
58struct VisibilityBadge: View { 103struct VisibilityBadge: View {
Hutch/Views/Repositories/RepositorySummarySupport.swift +7
@@ -1,5 +1,12 @@
1import SwiftUI 1import SwiftUI
2 2
3enum RepositoryBuildStatus: Sendable {
4 case success
5 case failed
6 case running
7 case none
8}
9
3struct RepositoryCloneURLs { 10struct RepositoryCloneURLs {
4 let readOnly: String 11 let readOnly: String
5 let readWrite: String 12 let readWrite: String
HutchTests/HomeViewModelTests.swift added +67
@@ -0,0 +1,67 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5struct 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 #expect(descriptionMatches.map(\.id) == [2]) 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 @MainActor 57 @MainActor
32 private func makeRepository( 58 private func makeRepository(
33 id: Int, 59 id: Int,