krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

ffc0ef0988b5e7d01951c23af85a1cf026b3da9f

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-03-19T03:56:34Z

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(-)

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<Destination: View>: 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..<endIndex])
+
+            let batchTickets = try await withThrowingTaskGroup(of: [HomeAssignedTicket].self) { group in
+                for tracker in batch {
+                    group.addTask {
+                        try await self.fetchAssignedTickets(for: tracker)
+                    }
+                }
+
+                var ticketsForBatch: [HomeAssignedTicket] = []
+                for try await tickets in group {
+                    ticketsForBatch.append(contentsOf: tickets)
+                }
+                return ticketsForBatch
+            }
+
+            assignedTickets.append(contentsOf: batchTickets)
+            startIndex = endIndex
+        }
+
+        return assignedTickets
+    }
+
+    private func fetchAssignedTickets(for tracker: TrackerSummary) async throws -> [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..<manifest.endIndex, in: manifest)
+        guard let match = regex.firstMatch(in: manifest, options: [], range: nsRange),
+              let ownerRange = Range(match.range(at: 1), in: manifest),
+              let nameRange = Range(match.range(at: 2), in: manifest) else {
+            return nil
+        }
+
+        let owner = String(manifest[ownerRange])
+        var name = String(manifest[nameRange])
+        if let suffixRange = name.range(of: ".git", options: [.backwards, .anchored]) {
+            name.removeSubrange(suffixRange)
+        }
+        name = name.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+        guard !name.isEmpty else { return nil }
+        return (owner, name)
+    }
+
+    private nonisolated static func normalizedCanonicalName(_ value: String) -> 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<Void, Never>?
 
     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<String> {
+        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..<manifest.endIndex, in: manifest)
+        return regex.matches(in: manifest, options: [], range: nsRange).reduce(into: Set<String>()) { 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,