From ffc0ef0988b5e7d01951c23af85a1cf026b3da9f Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Wed, 18 Mar 2026 22:56:34 -0500 Subject: add Home dashboard and repository build status indicators --- Hutch/App/RootView.swift | 15 +- Hutch/Views/Home/HomeView.swift | 297 ++++++++++++++ Hutch/Views/Home/HomeViewModel.swift | 443 +++++++++++++++++++++ Hutch/Views/Repositories/RepositoryListView.swift | 5 +- .../Repositories/RepositoryListViewModel.swift | 185 +++++++++ Hutch/Views/Repositories/RepositoryRowView.swift | 45 +++ .../Repositories/RepositorySummarySupport.swift | 7 + HutchTests/HomeViewModelTests.swift | 67 ++++ HutchTests/RepositoryListViewModelTests.swift | 26 ++ 9 files changed, 1087 insertions(+), 3 deletions(-) create mode 100644 Hutch/Views/Home/HomeView.swift create mode 100644 Hutch/Views/Home/HomeViewModel.swift create mode 100644 HutchTests/HomeViewModelTests.swift diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift index 7af162d..79411ed 100644 --- a/Hutch/App/RootView.swift +++ b/Hutch/App/RootView.swift @@ -6,13 +6,15 @@ struct RootView: View { @Environment(AppState.self) private var appState enum Tab: Hashable { + case home case repositories case builds case tickets case settings } - @State private var selectedTab: Tab = .repositories + @State private var selectedTab: Tab = .home + @State private var homePath = NavigationPath() @State private var repoPath = NavigationPath() @State private var buildsPath = NavigationPath() @State private var ticketsPath = NavigationPath() @@ -47,6 +49,14 @@ struct RootView: View { private var tabContent: some View { TabView(selection: $selectedTab) { + NavigationStack(path: $homePath) { + HomeView() + } + .tag(Tab.home) + .tabItem { + Label("Home", systemImage: "house") + } + NavigationStack(path: $repoPath) { RepositoryListView() } @@ -106,10 +116,11 @@ struct RootView: View { case .launching: break case .unauthenticated: + homePath = NavigationPath() repoPath = NavigationPath() buildsPath = NavigationPath() ticketsPath = NavigationPath() - selectedTab = .repositories + selectedTab = .home isResolvingDeepLink = false case .authenticated: consumePendingDeepLinkIfPossible(appState.pendingDeepLink) diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift new file mode 100644 index 0000000..a72853f --- /dev/null +++ b/Hutch/Views/Home/HomeView.swift @@ -0,0 +1,297 @@ +import SwiftUI + +struct HomeView: View { + @Environment(AppState.self) private var appState + @State private var viewModel: HomeViewModel? + private let previewLimit = 4 + + var body: some View { + Group { + if let viewModel { + content(viewModel) + } else { + SRHTLoadingStateView(message: "Loading Home…") + } + } + .navigationTitle("Home") + .task { + if viewModel == nil, let currentUser = appState.currentUser { + let vm = HomeViewModel(currentUser: currentUser, client: appState.client) + viewModel = vm + await vm.loadDashboard() + } + } + } + + @ViewBuilder + private func content(_ viewModel: HomeViewModel) -> some View { + List { + assignedTicketsSection(viewModel) + recentBuildsSection(viewModel) + } + .listStyle(.insetGrouped) + .overlay { + if viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds && + viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty { + SRHTLoadingStateView(message: "Loading Home…") + } else if !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds && + viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && + viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil { + ContentUnavailableView( + "All Clear", + systemImage: "checkmark.circle", + description: Text("There are no assigned tickets or recent builds right now.") + ) + } + } + .refreshable { + await viewModel.loadDashboard() + } + } + + @ViewBuilder + private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View { + Section { + if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { + HomeSectionLoadingRow(label: "Loading assigned tickets") + } else if let error = viewModel.assignedTicketsError, viewModel.assignedTickets.isEmpty { + HomeSectionMessageRow( + text: "Couldn’t load assigned tickets.", + systemImage: "exclamationmark.triangle", + emphasized: true, + accessibilityHint: error + ) + } else if viewModel.assignedTickets.isEmpty { + HomeSectionMessageRow( + text: "No open tickets assigned to you.", + systemImage: "person.crop.circle.badge.checkmark" + ) + } else { + ForEach(viewModel.assignedTickets.prefix(previewLimit)) { ticket in + NavigationLink { + TicketDetailView( + ownerUsername: ticket.ownerUsername, + trackerName: ticket.trackerName, + trackerId: ticket.trackerId, + trackerRid: ticket.trackerRid, + ticketId: ticket.ticket.id + ) + } label: { + HomeAssignedTicketRow(ticket: ticket) + } + } + } + } header: { + HomeSectionHeader("Assigned Tickets") { + HomeAssignedTicketsListView(viewModel: viewModel) + } + } + } + + @ViewBuilder + private func recentBuildsSection(_ viewModel: HomeViewModel) -> some View { + Section { + if viewModel.isLoadingRecentBuilds && viewModel.recentBuilds.isEmpty { + HomeSectionLoadingRow(label: "Loading recent builds") + } else if let error = viewModel.recentBuildsError, viewModel.recentBuilds.isEmpty { + HomeSectionMessageRow( + text: "Couldn’t load recent builds.", + systemImage: "exclamationmark.triangle", + emphasized: true, + accessibilityHint: error + ) + } else if viewModel.recentBuilds.isEmpty { + HomeSectionMessageRow( + text: "No recent builds.", + systemImage: "clock" + ) + } else { + ForEach(viewModel.recentBuilds.prefix(previewLimit)) { build in + NavigationLink { + BuildDetailView(jobId: build.job.id) + } label: { + HomeBuildRow(build: build) + } + } + } + } header: { + HomeSectionHeader("Recent Builds") { + BuildListView() + } + } + } + +} + +private struct HomeBuildRow: View { + let build: HomeBuildItem + + var body: some View { + HStack(spacing: 12) { + JobStatusIcon(status: build.job.status) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 4) { + Text(primaryTitle) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + + HStack(spacing: 8) { + Text("Job #\(build.job.id)") + .font(.caption) + .foregroundStyle(.secondary) + + Text("•") + .font(.caption) + .foregroundStyle(.tertiary) + + Text(build.job.status.rawValue.capitalized) + .font(.caption) + .foregroundStyle(.secondary) + + Text("•") + .font(.caption) + .foregroundStyle(.tertiary) + + Text(build.job.created.relativeDescription) + .font(.caption) + .foregroundStyle(.tertiary) + + Spacer() + } + } + } + .padding(.vertical, 2) + } + + private var primaryTitle: String { + if let repositoryDisplayName = build.repositoryDisplayName { + return repositoryDisplayName + } + return build.job.displayLabel + } +} + +private struct HomeAssignedTicketRow: View { + let ticket: HomeAssignedTicket + + var body: some View { + HStack(alignment: .top, spacing: 12) { + TicketStatusIcon(status: ticket.ticket.status) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 4) { + Text(ticket.ticket.title) + .font(.subheadline.weight(.medium)) + .lineLimit(2) + + Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName) • #\(ticket.ticket.id) • \(ticket.ticket.created.relativeDescription)") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + + Spacer(minLength: 8) + + Text(ticket.ticket.status.displayName) + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + .fixedSize() + } + .padding(.vertical, 2) + } +} + +private struct HomeSectionLoadingRow: View { + let label: String + + var body: some View { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text(label) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct HomeSectionHeader: View { + let title: String + let destination: Destination + + init(_ title: String, @ViewBuilder destination: () -> Destination) { + self.title = title + self.destination = destination() + } + + var body: some View { + HStack { + Text(title) + Spacer() + NavigationLink { + destination + } label: { + Text("See All") + .font(.caption.weight(.medium)) + } + .buttonStyle(.plain) + } + .textCase(nil) + } +} + +private struct HomeAssignedTicketsListView: View { + let viewModel: HomeViewModel + + var body: some View { + List { + ForEach(viewModel.assignedTickets) { ticket in + NavigationLink { + TicketDetailView( + ownerUsername: ticket.ownerUsername, + trackerName: ticket.trackerName, + trackerId: ticket.trackerId, + trackerRid: ticket.trackerRid, + ticketId: ticket.ticket.id + ) + } label: { + HomeAssignedTicketRow(ticket: ticket) + } + } + + if !viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { + HomeSectionMessageRow( + text: "No open tickets assigned to you.", + systemImage: "person.crop.circle.badge.checkmark" + ) + } + } + .navigationTitle("Assigned Tickets") + .navigationBarTitleDisplayMode(.inline) + .refreshable { + await viewModel.loadDashboard() + } + .overlay { + if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { + SRHTLoadingStateView(message: "Loading assigned tickets…") + } + } + } +} + +private struct HomeSectionMessageRow: View { + let text: String + let systemImage: String + var emphasized = false + var accessibilityHint: String? = nil + + var body: some View { + Label(text, systemImage: systemImage) + .font(.subheadline) + .foregroundStyle(emphasized ? .secondary : .tertiary) + .accessibilityHint(accessibilityHint ?? "") + } +} diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift new file mode 100644 index 0000000..ce27bb0 --- /dev/null +++ b/Hutch/Views/Home/HomeViewModel.swift @@ -0,0 +1,443 @@ +import Foundation + +private struct HomeJobsResponse: Decodable, Sendable { + let jobs: HomeJobsPage +} + +private struct HomeJobsPage: Decodable, Sendable { + let results: [HomeJobPayload] +} + +private struct HomeTrackersResponse: Decodable, Sendable { + let trackers: HomeTrackersPage +} + +private struct HomeTrackersPage: Decodable, Sendable { + let results: [TrackerSummary] + let cursor: String? +} + +private struct HomeTrackerTicketsResponse: Decodable, Sendable { + let user: HomeTrackerTicketsUser +} + +private struct HomeTrackerTicketsUser: Decodable, Sendable { + let tracker: HomeTrackerTicketsTracker +} + +private struct HomeTrackerTicketsTracker: Decodable, Sendable { + let tickets: HomeTrackerTicketsPage +} + +private struct HomeTrackerTicketsPage: Decodable, Sendable { + let results: [HomeTicketPayload] +} + +private struct HomeTicketPayload: Decodable, Sendable { + let id: Int + let title: String + let status: TicketStatus + let resolution: TicketResolution? + let created: Date + let submitter: Entity + let labels: [TicketLabel] + let assignees: [Entity] + + enum CodingKeys: String, CodingKey { + case id + case title = "subject" + case status + case resolution + case created + case submitter + case labels + case assignees + } + + var ticketSummary: TicketSummary { + TicketSummary( + id: id, + title: title, + status: status, + resolution: resolution, + created: created, + submitter: submitter, + labels: labels, + assignees: assignees + ) + } +} + +struct HomeAssignedTicket: Identifiable, Hashable, Sendable { + let trackerId: Int + let trackerRid: String + let trackerName: String + let ownerCanonicalName: String + let ticket: TicketSummary + + var id: String { + "\(trackerRid)#\(ticket.id)" + } + + var ownerUsername: String { + if ownerCanonicalName.hasPrefix("~") { + return String(ownerCanonicalName.dropFirst()) + } + return ownerCanonicalName + } +} + +struct HomeBuildItem: Identifiable, Hashable, Sendable { + let job: JobSummary + let repositoryName: String? + let repositoryOwner: String? + + var id: Int { job.id } + + var repositoryDisplayName: String? { + guard let repositoryName else { return nil } + if let repositoryOwner { + return "\(repositoryOwner)/\(repositoryName)" + } + return repositoryName + } +} + +@Observable +@MainActor +final class HomeViewModel { + private(set) var failedBuilds: [HomeBuildItem] = [] + private(set) var assignedTickets: [HomeAssignedTicket] = [] + private(set) var recentBuilds: [HomeBuildItem] = [] + private(set) var isLoadingFailedBuilds = false + private(set) var isLoadingAssignedTickets = false + private(set) var isLoadingRecentBuilds = false + private(set) var failedBuildsError: String? + private(set) var assignedTicketsError: String? + private(set) var recentBuildsError: String? + + private let currentUser: User + private let client: SRHTClient + private let ticketFetchConcurrencyLimit = 6 + + private static let jobsQuery = """ + query jobs { + jobs { + results { + id + created + updated + status + note + tags + visibility + image + tasks { name status } + manifest + } + } + } + """ + + private static let trackersQuery = """ + query trackers($cursor: Cursor) { + trackers(cursor: $cursor) { + results { + id + rid + name + description + visibility + updated + owner { canonicalName } + } + cursor + } + } + """ + + private static let trackerTicketsQuery = """ + query tickets($owner: String!, $tracker: String!) { + user(username: $owner) { + tracker(name: $tracker) { + tickets { + results { + id + subject + status + resolution + created + submitter { canonicalName } + labels { id name backgroundColor foregroundColor } + assignees { canonicalName } + } + } + } + } + } + """ + + init(currentUser: User, client: SRHTClient) { + self.currentUser = currentUser + self.client = client + } + + func loadDashboard() async { + isLoadingFailedBuilds = true + isLoadingAssignedTickets = true + isLoadingRecentBuilds = true + failedBuildsError = nil + assignedTicketsError = nil + recentBuildsError = nil + + async let jobsTask = loadRecentJobs() + async let assignedTicketsTask = loadAssignedTickets() + + let recentJobsResult = await jobsTask + + switch recentJobsResult { + case .success(let recentJobs): + let buildItems = Self.buildItems(from: recentJobs) + self.recentBuilds = buildItems + self.failedBuilds = Self.failedBuilds(from: buildItems) + self.failedBuildsError = nil + self.recentBuildsError = nil + case .failure(let error): + self.recentBuilds = [] + self.failedBuilds = [] + self.failedBuildsError = error.localizedDescription + self.recentBuildsError = error.localizedDescription + } + isLoadingFailedBuilds = false + isLoadingRecentBuilds = false + + let assignedTicketsResult = await assignedTicketsTask + + switch assignedTicketsResult { + case .success(let assignedTickets): + self.assignedTickets = assignedTickets + self.assignedTicketsError = nil + case .failure(let error): + self.assignedTickets = [] + self.assignedTicketsError = error.localizedDescription + } + isLoadingAssignedTickets = false + } + + private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> { + do { + let response = try await client.execute( + service: .builds, + query: Self.jobsQuery, + responseType: HomeJobsResponse.self + ) + return .success(response.jobs.results) + } catch { + return .failure(error) + } + } + + private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> { + do { + let trackers = try await fetchAllTrackers() + let tickets = try await fetchAssignedTickets(for: trackers) + .sorted { $0.ticket.created > $1.ticket.created } + return .success(tickets) + } catch { + return .failure(error) + } + } + + private func fetchAllTrackers() async throws -> [TrackerSummary] { + var allTrackers: [TrackerSummary] = [] + var cursor: String? + + while true { + var variables: [String: any Sendable] = [:] + if let cursor { + variables["cursor"] = cursor + } + + let response = try await client.execute( + service: .todo, + query: Self.trackersQuery, + variables: variables.isEmpty ? nil : variables, + responseType: HomeTrackersResponse.self + ) + + allTrackers.append(contentsOf: response.trackers.results) + guard let nextCursor = response.trackers.cursor else { + break + } + cursor = nextCursor + } + + return allTrackers + } + + private func fetchAssignedTickets(for trackers: [TrackerSummary]) async throws -> [HomeAssignedTicket] { + guard !trackers.isEmpty else { return [] } + + var assignedTickets: [HomeAssignedTicket] = [] + var startIndex = trackers.startIndex + + while startIndex < trackers.endIndex { + let endIndex = trackers.index(startIndex, offsetBy: ticketFetchConcurrencyLimit, limitedBy: trackers.endIndex) ?? trackers.endIndex + let batch = Array(trackers[startIndex.. [HomeAssignedTicket] { + let response = try await client.execute( + service: .todo, + query: Self.trackerTicketsQuery, + variables: [ + "owner": tracker.owner.canonicalName.hasPrefix("~") + ? String(tracker.owner.canonicalName.dropFirst()) + : tracker.owner.canonicalName, + "tracker": tracker.name + ], + responseType: HomeTrackerTicketsResponse.self + ) + + return response.user.tracker.tickets.results.compactMap { payload in + guard payload.status.isOpen else { + return nil + } + guard payload.assignees.contains(where: { Self.matchesCurrentUserAssignee($0, currentUser: currentUser) }) else { + return nil + } + + return HomeAssignedTicket( + trackerId: tracker.id, + trackerRid: tracker.rid, + trackerName: tracker.name, + ownerCanonicalName: tracker.owner.canonicalName, + ticket: payload.ticketSummary + ) + } + } + + nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { + jobs.map { job in + let repository = primaryRepositoryReference(in: job.manifest) + return HomeBuildItem( + job: job.jobSummary, + repositoryName: repository?.name, + repositoryOwner: repository?.ownerCanonicalName + ) + } + } + + nonisolated static func failedBuilds(from builds: [HomeBuildItem]) -> [HomeBuildItem] { + builds.filter { build in + switch build.job.status { + case .failed, .timeout: + true + default: + false + } + } + } + + nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { + failedBuilds(from: buildItems(from: jobs)) + } + + nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool { + let assigneeCanonical = normalizedCanonicalName(entity.canonicalName) + let currentCanonical = normalizedCanonicalName(currentUser.canonicalName) + if assigneeCanonical == currentCanonical { + return true + } + + let assigneeUsername = normalizedUsername(entity.canonicalName) + let currentUsername = normalizedUsername(currentUser.username) + return assigneeUsername == currentUsername + } + + nonisolated static func primaryRepositoryReference(in manifest: String?) -> (ownerCanonicalName: String, name: String)? { + guard let manifest else { return nil } + let pattern = #"(?:https://|ssh://(?:git|hg)@|(?:git|hg)@)(?:git|hg)\.sr\.ht[:/]([~][^/\s]+)/([^\s"'#]+)"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { + return nil + } + let nsRange = NSRange(manifest.startIndex.. String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return trimmed } + if trimmed.hasPrefix("~") { + return trimmed + } + return "~\(trimmed)" + } + + private nonisolated static func normalizedUsername(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("~") { + return String(trimmed.dropFirst()) + } + return trimmed + } +} +struct HomeJobPayload: Decodable, Sendable { + let id: Int + let created: Date + let updated: Date + let status: JobStatus + let note: String? + let tags: [String] + let visibility: Visibility? + let image: String? + let tasks: [JobTaskSummary] + let manifest: String? + + nonisolated var jobSummary: JobSummary { + JobSummary( + id: id, + created: created, + updated: updated, + status: status, + note: note, + tags: tags, + visibility: visibility, + image: image, + tasks: tasks + ) + } +} diff --git a/Hutch/Views/Repositories/RepositoryListView.swift b/Hutch/Views/Repositories/RepositoryListView.swift index 6fcfafa..7cac8a8 100644 --- a/Hutch/Views/Repositories/RepositoryListView.swift +++ b/Hutch/Views/Repositories/RepositoryListView.swift @@ -63,7 +63,10 @@ struct RepositoryListView: View { List { ForEach(viewModel.repositories) { repo in NavigationLink(value: repo) { - RepositoryRowView(repository: repo) + RepositoryRowView( + repository: repo, + buildStatus: viewModel.latestBuildStatus(for: repo) + ) } .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } .task { diff --git a/Hutch/Views/Repositories/RepositoryListViewModel.swift b/Hutch/Views/Repositories/RepositoryListViewModel.swift index 6b63203..5bdc4a5 100644 --- a/Hutch/Views/Repositories/RepositoryListViewModel.swift +++ b/Hutch/Views/Repositories/RepositoryListViewModel.swift @@ -27,6 +27,7 @@ enum RepositoryCreationService: String, CaseIterable, Identifiable, Sendable { final class RepositoryListViewModel { private(set) var repositories: [RepositorySummary] = [] + private(set) var latestBuildStatuses: [String: RepositoryBuildStatus] = [:] private(set) var isLoading = false private(set) var isLoadingMore = false private(set) var isRefreshing = false @@ -41,9 +42,11 @@ final class RepositoryListViewModel { private(set) var hasLoadedSearchIndex = false private var searchIndex: [RepositorySummary] = [] private let client: SRHTClient + private var buildStatusTask: Task? private static let gitCacheKey = "git.repositories" private static let hgCacheKey = "hg.repositories" + private static let buildsCacheKey = "builds.repository-status" private static let minimumRemoteSearchLength = 3 init(client: SRHTClient) { @@ -117,6 +120,20 @@ final class RepositoryListViewModel { } """ + private static let buildsQuery = """ + query jobs($cursor: Cursor) { + jobs(cursor: $cursor) { + results { + id + created + status + manifest + } + cursor + } + } + """ + // MARK: - Public API /// Fetch the first page of repositories. Shows cached data instantly if available, @@ -168,6 +185,7 @@ final class RepositoryListViewModel { } repositories = filteredResults.sorted(by: repositorySortOrder) + scheduleBuildStatusRefresh() } catch { // Only show error if we have no cached data to fall back on if repositories.isEmpty { @@ -245,6 +263,7 @@ final class RepositoryListViewModel { } repositories.insert(repository, at: 0) insertIntoSearchIndex(repository) + scheduleBuildStatusRefresh() return repository } catch { self.error = repositoryCreationErrorMessage(for: error) @@ -306,6 +325,22 @@ final class RepositoryListViewModel { let createRepository: HGRepositoryPayload } + private struct BuildJobsResponse: Decodable, Sendable { + let jobs: BuildJobsPage + } + + private struct BuildJobsPage: Decodable, Sendable { + let results: [BuildStatusPayload] + let cursor: String? + } + + private struct BuildStatusPayload: Decodable, Sendable { + let id: Int + let created: Date + let status: JobStatus + let manifest: String? + } + private struct HGPage: Decodable, Sendable { let results: [HGRepositoryPayload] let cursor: String? @@ -380,6 +415,10 @@ final class RepositoryListViewModel { searchIndex } + func latestBuildStatus(for repository: RepositorySummary) -> RepositoryBuildStatus { + latestBuildStatuses[Self.buildStatusCacheKey(for: repository)] ?? RepositoryBuildStatus.none + } + private func fetchPage( service: SRHTService, cursor: String?, @@ -514,9 +553,94 @@ final class RepositoryListViewModel { let sortedRepositories = cachedRepositories.sorted(by: repositorySortOrder) repositories = sortedRepositories updateSearchIndex(with: sortedRepositories) + scheduleBuildStatusRefresh() } } + private func scheduleBuildStatusRefresh() { + let repositoriesSnapshot = repositories + buildStatusTask?.cancel() + buildStatusTask = Task { [weak self] in + guard let self else { return } + await self.loadLatestBuildStatuses(for: repositoriesSnapshot) + } + } + + private func loadLatestBuildStatuses(for repositories: [RepositorySummary]) async { + let targetKeys = Set(repositories.map(Self.buildStatusCacheKey(for:))) + guard !targetKeys.isEmpty else { + await MainActor.run { + latestBuildStatuses = [:] + } + return + } + + var resolvedStatuses: [String: (Date, RepositoryBuildStatus)] = [:] + var cursor: String? + var shouldUseCache = true + + do { + while !Task.isCancelled { + let page = try await fetchBuildStatusPage(cursor: cursor, useCache: shouldUseCache) + shouldUseCache = false + + for job in page.results { + let jobStatus = Self.repositoryBuildStatus(for: job.status) + guard let manifest = job.manifest else { continue } + + for key in Self.buildStatusKeys(in: manifest) where targetKeys.contains(key) { + let existing = resolvedStatuses[key] + if existing == nil || existing!.0 < job.created { + resolvedStatuses[key] = (job.created, jobStatus) + } + } + } + + if resolvedStatuses.count == targetKeys.count || page.cursor == nil { + break + } + cursor = page.cursor + } + + let finalStatuses = targetKeys.reduce(into: [String: RepositoryBuildStatus]()) { result, key in + result[key] = resolvedStatuses[key]?.1 ?? RepositoryBuildStatus.none + } + + await MainActor.run { + guard repositories == self.repositories else { return } + latestBuildStatuses = finalStatuses + } + } catch { + // Build status is auxiliary data for the list. Leave the default gray state on failure. + } + } + + private func fetchBuildStatusPage(cursor: String?, useCache: Bool) async throws -> BuildJobsPage { + var variables: [String: any Sendable] = [:] + if let cursor { + variables["cursor"] = cursor + } + + if useCache && cursor == nil { + let result = try await client.executeAndCache( + service: .builds, + query: Self.buildsQuery, + variables: variables.isEmpty ? nil : variables, + responseType: BuildJobsResponse.self, + cacheKey: Self.buildsCacheKey + ) + return result.jobs + } + + let result = try await client.execute( + service: .builds, + query: Self.buildsQuery, + variables: variables.isEmpty ? nil : variables, + responseType: BuildJobsResponse.self + ) + return result.jobs + } + private func fetchRepositories(for service: SRHTService, useCache: Bool) async throws -> [RepositorySummary] { var allRepositories: [RepositorySummary] = [] var currentCursor: String? = nil @@ -580,6 +704,67 @@ final class RepositoryListViewModel { repo.description?.lowercased().contains(lowercasedQuery) ?? false } } + + nonisolated static func buildStatusCacheKey(for repository: RepositorySummary) -> String { + buildStatusCacheKey( + service: repository.service, + ownerCanonicalName: repository.owner.canonicalName, + repositoryName: repository.name + ) + } + + nonisolated static func buildStatusCacheKey( + service: SRHTService, + ownerCanonicalName: String, + repositoryName: String + ) -> String { + "\(service.rawValue)|\(ownerCanonicalName.lowercased())|\(repositoryName.lowercased())" + } + + nonisolated static func repositoryBuildStatus(for jobStatus: JobStatus) -> RepositoryBuildStatus { + switch jobStatus { + case .success: + .success + case .pending, .queued, .running: + .running + case .failed, .cancelled, .timeout: + .failed + } + } + + nonisolated static func buildStatusKeys(in manifest: String) -> Set { + let pattern = #"(?:https://|ssh://(?:git|hg)@|(?:git|hg)@)(git|hg)\.sr\.ht[:/]([~][^/\s]+)/([^\s"'#]+)"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { + return [] + } + + let nsRange = NSRange(manifest.startIndex..()) { result, match in + guard + let serviceRange = Range(match.range(at: 1), in: manifest), + let ownerRange = Range(match.range(at: 2), in: manifest), + let nameRange = Range(match.range(at: 3), in: manifest) + else { + return + } + + let service: SRHTService = manifest[serviceRange].lowercased() == "hg" ? .hg : .git + let owner = String(manifest[ownerRange]).lowercased() + var name = String(manifest[nameRange]).lowercased() + + if let suffixRange = name.range(of: ".git", options: [.backwards, .anchored]) { + name.removeSubrange(suffixRange) + } + name = name.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + if !name.isEmpty { + result.insert(buildStatusCacheKey( + service: service, + ownerCanonicalName: owner, + repositoryName: name + )) + } + } + } } private extension Array { diff --git a/Hutch/Views/Repositories/RepositoryRowView.swift b/Hutch/Views/Repositories/RepositoryRowView.swift index 9f8cd15..f8fd205 100644 --- a/Hutch/Views/Repositories/RepositoryRowView.swift +++ b/Hutch/Views/Repositories/RepositoryRowView.swift @@ -2,6 +2,7 @@ import SwiftUI struct RepositoryRowView: View { let repository: RepositorySummary + let buildStatus: RepositoryBuildStatus var body: some View { VStack(alignment: .leading, spacing: 4) { @@ -20,6 +21,9 @@ struct RepositoryRowView: View { .foregroundStyle(.cyan) } + if buildStatus != .none { + RepositoryBuildStatusIndicator(status: buildStatus) + } VisibilityBadge(visibility: repository.visibility) } @@ -53,6 +57,47 @@ struct RepositoryRowView: View { } } +private struct RepositoryBuildStatusIndicator: View { + let status: RepositoryBuildStatus + + var body: some View { + Circle() + .fill(color) + .frame(width: 8, height: 8) + .overlay { + Circle() + .strokeBorder(.primary.opacity(0.08)) + } + .accessibilityLabel(accessibilityLabel) + } + + private var color: Color { + switch status { + case .success: + .green + case .failed: + .red + case .running: + .orange + case .none: + .clear + } + } + + private var accessibilityLabel: String { + switch status { + case .success: + "Latest build succeeded" + case .failed: + "Latest build failed" + case .running: + "Latest build is running" + case .none: + "No recent builds" + } + } +} + // MARK: - VisibilityBadge struct VisibilityBadge: View { diff --git a/Hutch/Views/Repositories/RepositorySummarySupport.swift b/Hutch/Views/Repositories/RepositorySummarySupport.swift index a2cf699..7861b40 100644 --- a/Hutch/Views/Repositories/RepositorySummarySupport.swift +++ b/Hutch/Views/Repositories/RepositorySummarySupport.swift @@ -1,5 +1,12 @@ import SwiftUI +enum RepositoryBuildStatus: Sendable { + case success + case failed + case running + case none +} + struct RepositoryCloneURLs { let readOnly: String let readWrite: String diff --git a/HutchTests/HomeViewModelTests.swift b/HutchTests/HomeViewModelTests.swift new file mode 100644 index 0000000..5475bdd --- /dev/null +++ b/HutchTests/HomeViewModelTests.swift @@ -0,0 +1,67 @@ +import Foundation +import Testing +@testable import Hutch + +struct HomeViewModelTests { + + @Test + func failedBuildsKeepsOnlyFailedAndTimedOutJobs() { + let jobs = [ + makeJob(id: 1, status: .success, created: Date(timeIntervalSince1970: 10)), + makeJob(id: 2, status: .failed, created: Date(timeIntervalSince1970: 20)), + makeJob(id: 3, status: .timeout, created: Date(timeIntervalSince1970: 30)), + makeJob(id: 4, status: .running, created: Date(timeIntervalSince1970: 40)) + ] + + let failedBuilds = HomeViewModel.failedBuilds(from: jobs) + + #expect(failedBuilds.map(\.job.id) == [2, 3]) + } + + @Test + func matchesCurrentUserAssigneeNormalizesCanonicalNameAndUsername() { + let currentUser = User( + id: 42, + username: "owner", + canonicalName: "~owner", + email: "owner@example.com", + avatar: nil + ) + + #expect(HomeViewModel.matchesCurrentUserAssignee(Entity(canonicalName: "~owner"), currentUser: currentUser)) + #expect(HomeViewModel.matchesCurrentUserAssignee(Entity(canonicalName: "owner"), currentUser: currentUser)) + #expect(HomeViewModel.matchesCurrentUserAssignee(Entity(canonicalName: currentUser.username), currentUser: currentUser)) + #expect(HomeViewModel.matchesCurrentUserAssignee(Entity(canonicalName: "~someone-else"), currentUser: currentUser) == false) + } + + @Test + func primaryRepositoryReferenceParsesManifestSourceURL() { + let manifest = """ + image: alpine/latest + sources: + - https://git.sr.ht/~owner/hutch + tasks: + - true + """ + + let repository = HomeViewModel.primaryRepositoryReference(in: manifest) + + #expect(repository?.ownerCanonicalName == "~owner") + #expect(repository?.name == "hutch") + } + + private func makeJob(id: Int, status: JobStatus, created: Date) -> HomeJobPayload { + HomeJobPayload( + id: id, + created: created, + updated: created, + status: status, + note: nil, + tags: [], + visibility: nil, + image: nil, + tasks: [], + manifest: nil + ) + } +} diff --git a/HutchTests/RepositoryListViewModelTests.swift b/HutchTests/RepositoryListViewModelTests.swift index d6e76bf..7bd9126 100644 --- a/HutchTests/RepositoryListViewModelTests.swift +++ b/HutchTests/RepositoryListViewModelTests.swift @@ -28,6 +28,32 @@ struct RepositoryListViewModelTests { #expect(descriptionMatches.map(\.id) == [2]) } + @Test + func buildStatusKeysParsesSourceHutRepositoryURLsFromManifest() { + let manifest = """ + image: alpine/latest + sources: + - https://git.sr.ht/~owner/hutch + - ssh://hg@hg.sr.ht/~owner/wiki + tasks: + - echo "build" + """ + + let keys = RepositoryListViewModel.buildStatusKeys(in: manifest) + + #expect(keys.contains("git|~owner|hutch")) + #expect(keys.contains("hg|~owner|wiki")) + } + + @Test + func repositoryBuildStatusMapsJobStatesToRowStates() { + #expect(RepositoryListViewModel.repositoryBuildStatus(for: .success) == .success) + #expect(RepositoryListViewModel.repositoryBuildStatus(for: .running) == .running) + #expect(RepositoryListViewModel.repositoryBuildStatus(for: .queued) == .running) + #expect(RepositoryListViewModel.repositoryBuildStatus(for: .failed) == .failed) + #expect(RepositoryListViewModel.repositoryBuildStatus(for: .timeout) == .failed) + } + @MainActor private func makeRepository( id: Int, -- cgit v1.2.3