krz/hutch

an ios client for sourcehut

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

82e54ad018ae5fa5cdf90c0d763e09e99bc0e933

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-03-23T01:42:53Z

feat: swipe actions on tickets, pastes, builds, and home dashboard
 Hutch/Views/Builds/BuildListView.swift        |   1 +
 Hutch/Views/Builds/BuildListViewModel.swift   |  37 ++++
 Hutch/Views/Home/HomeView.swift               |  87 ++++++++
 Hutch/Views/Home/HomeViewModel.swift          | 150 +++++++++++--
 Hutch/Views/Pastes/PasteListView.swift        |  77 ++++++-
 Hutch/Views/Pastes/PasteListViewModel.swift   |  41 ++++
 Hutch/Views/Tickets/TicketListView.swift      | 208 ++++++++++++++++++
 Hutch/Views/Tickets/TicketListViewModel.swift | 305 ++++++++++++++++++++++++++
 HutchTests/TicketListViewModelTests.swift     | 116 ++++++++++
 9 files changed, 1003 insertions(+), 19 deletions(-)

diff --git a/Hutch/Views/Builds/BuildListView.swift b/Hutch/Views/Builds/BuildListView.swift
index e248ca8..6bfbd6b 100644
--- a/Hutch/Views/Builds/BuildListView.swift
+++ b/Hutch/Views/Builds/BuildListView.swift
@@ -1,6 +1,7 @@
 import SwiftUI
 
 struct BuildListView: View {
+    @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
     @Environment(AppState.self) private var appState
     @State private var viewModel: BuildListViewModel?
     @State private var showSubmitSheet = false
diff --git a/Hutch/Views/Builds/BuildListViewModel.swift b/Hutch/Views/Builds/BuildListViewModel.swift
index 7b0b0b9..0757ad8 100644
--- a/Hutch/Views/Builds/BuildListViewModel.swift
+++ b/Hutch/Views/Builds/BuildListViewModel.swift
@@ -194,6 +194,35 @@ final class BuildListViewModel {
         }
     }
 
+    func cancelJob(_ job: JobSummary) async {
+        guard job.status.isCancellable else { return }
+
+        do {
+            _ = try await client.execute(
+                service: .builds,
+                query: Self.cancelMutation,
+                variables: ["id": job.id],
+                responseType: CancelResponse.self
+            )
+            if let index = jobs.firstIndex(where: { $0.id == job.id }) {
+                let updated = JobSummary(
+                    id: job.id,
+                    created: job.created,
+                    updated: job.updated,
+                    status: .cancelled,
+                    note: job.note,
+                    tags: job.tags,
+                    visibility: job.visibility,
+                    image: job.image,
+                    tasks: job.tasks
+                )
+                jobs[index] = updated
+            }
+        } catch {
+            self.error = error.userFacingMessage
+        }
+    }
+
     // MARK: - Private
 
     private func fetchPage(cursor: String?, useCache: Bool) async throws -> JobsPage {
@@ -235,4 +264,12 @@ final class BuildListViewModel {
             hasMore = page.cursor != nil
         }
     }
+
+    private struct CancelResponse: Decodable, Sendable {
+        struct CancelResult: Decodable, Sendable {
+            let id: Int
+        }
+
+        let cancel: CancelResult
+    }
 }
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
index 55ec41e..d3d370a 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -1,6 +1,7 @@
 import SwiftUI
 
 struct HomeView: View {
+    @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
     @Environment(AppState.self) private var appState
     @State private var viewModel: HomeViewModel?
     private let previewLimit = 4
@@ -109,6 +110,23 @@ struct HomeView: View {
                     } label: {
                         HomeAssignedTicketRow(ticket: ticket)
                     }
+                    .swipeActions(edge: .leading, allowsFullSwipe: true) {
+                        if swipeActionsEnabled {
+                            ticketLeadingSwipeAction(ticket, viewModel: viewModel)
+                        }
+                    }
+                    .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+                        if swipeActionsEnabled {
+                            Button {
+                                Task {
+                                    await viewModel.unassignFromMe(ticket)
+                                }
+                            } label: {
+                                Label("Unassign Me", systemImage: "person.badge.minus")
+                            }
+                            .tint(.orange)
+                        }
+                    }
                 }
             }
         } header: {
@@ -142,6 +160,19 @@ struct HomeView: View {
                     } label: {
                         HomeBuildRow(build: build)
                     }
+                    .swipeActions(edge: .leading, allowsFullSwipe: true) {
+                        if swipeActionsEnabled, build.job.status.isCancellable {
+                            Button {
+                                Task {
+                                    await viewModel.cancelBuild(build)
+                                }
+                            }
+                            label: {
+                                Label("Cancel", systemImage: "xmark.circle")
+                            }
+                            .tint(.red)
+                        }
+                    }
                 }
             }
         } header: {
@@ -151,6 +182,32 @@ struct HomeView: View {
         }
     }
 
+    @ViewBuilder
+    private func ticketLeadingSwipeAction(
+        _ ticket: HomeAssignedTicket,
+        viewModel: HomeViewModel
+    ) -> some View {
+        if ticket.ticket.status.isOpen {
+            Button {
+                Task {
+                    await viewModel.resolveTicket(ticket)
+                }
+            } label: {
+                Label("Resolve", systemImage: "checkmark.circle")
+            }
+            .tint(.green)
+        } else {
+            Button {
+                Task {
+                    await viewModel.reopenTicket(ticket)
+                }
+            } label: {
+                Label("Reopen", systemImage: "arrow.uturn.backward")
+            }
+            .tint(.blue)
+        }
+    }
+
 }
 
 private struct HomeInboxToolbarIcon: View {
@@ -367,6 +424,7 @@ private struct HomeSectionActionHeader: View {
 
 private struct HomeAssignedTicketsListView: View {
     let viewModel: HomeViewModel
+    @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
 
     var body: some View {
         List {
@@ -382,6 +440,35 @@ private struct HomeAssignedTicketsListView: View {
                 } label: {
                     HomeAssignedTicketRow(ticket: ticket)
                 }
+                .swipeActions(edge: .leading, allowsFullSwipe: true) {
+                    if swipeActionsEnabled {
+                        if ticket.ticket.status.isOpen {
+                            Button {
+                                Task { await viewModel.resolveTicket(ticket) }
+                            } label: {
+                                Label("Resolve", systemImage: "checkmark.circle")
+                            }
+                            .tint(.green)
+                        } else {
+                            Button {
+                                Task { await viewModel.reopenTicket(ticket) }
+                            } label: {
+                                Label("Reopen", systemImage: "arrow.uturn.backward")
+                            }
+                            .tint(.blue)
+                        }
+                    }
+                }
+                .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+                    if swipeActionsEnabled {
+                        Button {
+                            Task { await viewModel.unassignFromMe(ticket) }
+                        } label: {
+                            Label("Unassign Me", systemImage: "person.badge.minus")
+                        }
+                        .tint(.orange)
+                    }
+                }
             }
 
             if !viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift
index 4f3a514..5897aca 100644
--- a/Hutch/Views/Home/HomeViewModel.swift
+++ b/Hutch/Views/Home/HomeViewModel.swift
@@ -137,15 +137,12 @@ struct HomeBuildItem: Identifiable, Hashable, Sendable {
 @MainActor
 final class HomeViewModel {
     private(set) var projects: [Project] = []
-    private(set) var failedBuilds: [HomeBuildItem] = []
-    private(set) var assignedTickets: [HomeAssignedTicket] = []
-    private(set) var recentBuilds: [HomeBuildItem] = []
+    var assignedTickets: [HomeAssignedTicket] = []
+    var recentBuilds: [HomeBuildItem] = []
     private(set) var hasUnreadInboxThreads = false
     private(set) var isLoadingProjects = false
-    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?
 
@@ -244,6 +241,26 @@ final class HomeViewModel {
     }
     """
 
+    private static let updateTicketStatusMutation = """
+    mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) {
+        updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
+            eventType: __typename
+        }
+    }
+    """
+
+    private static let unassignUserMutation = """
+    mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
+        unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
+    }
+    """
+
+    private static let cancelBuildMutation = """
+    mutation cancel($id: Int!) {
+        cancel(jobId: $id) { id }
+    }
+    """
+
     init(currentUser: User, client: SRHTClient) {
         self.currentUser = currentUser
         self.client = client
@@ -252,10 +269,8 @@ final class HomeViewModel {
 
     func loadDashboard() async {
         isLoadingProjects = true
-        isLoadingFailedBuilds = true
         isLoadingAssignedTickets = true
         isLoadingRecentBuilds = true
-        failedBuildsError = nil
         assignedTicketsError = nil
         recentBuildsError = nil
 
@@ -279,16 +294,11 @@ final class HomeViewModel {
         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.userFacingMessage
             self.recentBuildsError = error.userFacingMessage
         }
-        isLoadingFailedBuilds = false
         isLoadingRecentBuilds = false
 
         let assignedTicketsResult = await assignedTicketsTask
@@ -306,6 +316,70 @@ final class HomeViewModel {
         hasUnreadInboxThreads = (await inboxUnreadTask) ?? false
     }
 
+    func resolveTicket(_ ticket: HomeAssignedTicket) async {
+        let input: [String: any Sendable] = [
+            "status": TicketStatus.resolved.rawValue,
+            "resolution": TicketResolution.fixed.rawValue
+        ]
+        await performTicketStatusUpdate(ticket: ticket, input: input)
+    }
+
+    func reopenTicket(_ ticket: HomeAssignedTicket) async {
+        let input: [String: any Sendable] = [
+            "status": TicketStatus.reported.rawValue
+        ]
+        await performTicketStatusUpdate(ticket: ticket, input: input)
+    }
+
+    func unassignFromMe(_ ticket: HomeAssignedTicket) async {
+        do {
+            _ = try await client.execute(
+                service: .todo,
+                query: Self.unassignUserMutation,
+                variables: [
+                    "trackerId": ticket.trackerId,
+                    "ticketId": ticket.ticket.id,
+                    "userId": currentUser.id
+                ],
+                responseType: UnassignResponse.self
+            )
+            assignedTickets.removeAll { $0.id == ticket.id }
+        } catch {
+        }
+    }
+
+    func cancelBuild(_ build: HomeBuildItem) async {
+        guard build.job.status.isCancellable else { return }
+
+        do {
+            _ = try await client.execute(
+                service: .builds,
+                query: Self.cancelBuildMutation,
+                variables: ["id": build.job.id],
+                responseType: CancelBuildResponse.self
+            )
+            if let index = recentBuilds.firstIndex(where: { $0.id == build.id }) {
+                let updatedJob = JobSummary(
+                    id: build.job.id,
+                    created: build.job.created,
+                    updated: build.job.updated,
+                    status: .cancelled,
+                    note: build.job.note,
+                    tags: build.job.tags,
+                    visibility: build.job.visibility,
+                    image: build.job.image,
+                    tasks: build.job.tasks
+                )
+                recentBuilds[index] = HomeBuildItem(
+                    job: updatedJob,
+                    repositoryName: build.repositoryName,
+                    repositoryOwner: build.repositoryOwner
+                )
+            }
+        } catch {
+        }
+    }
+
     private func loadProjects() async -> Result<[Project], Error> {
         do {
             return .success(try await projectService.fetchProjects())
@@ -521,6 +595,26 @@ final class HomeViewModel {
         }
     }
 
+    private func performTicketStatusUpdate(
+        ticket: HomeAssignedTicket,
+        input: [String: any Sendable]
+    ) async {
+        do {
+            _ = try await client.execute(
+                service: .todo,
+                query: Self.updateTicketStatusMutation,
+                variables: [
+                    "trackerId": ticket.trackerId,
+                    "ticketId": ticket.ticket.id,
+                    "input": input
+                ],
+                responseType: StatusEventResponse.self
+            )
+            assignedTickets.removeAll { $0.id == ticket.id }
+        } catch {
+        }
+    }
+
     nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
         jobs.map { job in
             let repository = primaryRepositoryReference(in: job.manifest)
@@ -532,8 +626,8 @@ final class HomeViewModel {
         }
     }
 
-    nonisolated static func failedBuilds(from builds: [HomeBuildItem]) -> [HomeBuildItem] {
-        builds.filter { build in
+    nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
+        buildItems(from: jobs).filter { build in
             switch build.job.status {
             case .failed, .timeout:
                 true
@@ -543,10 +637,6 @@ final class HomeViewModel {
         }
     }
 
-    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)
@@ -598,6 +688,30 @@ final class HomeViewModel {
         }
         return trimmed
     }
+
+    private struct StatusEventResponse: Decodable, Sendable {
+        struct EventRef: Decodable, Sendable {
+            let eventType: String
+        }
+
+        let updateTicketStatus: EventRef
+    }
+
+    private struct UnassignResponse: Decodable, Sendable {
+        struct EventRef: Decodable, Sendable {
+            let id: Int
+        }
+
+        let unassignUser: EventRef
+    }
+
+    private struct CancelBuildResponse: Decodable, Sendable {
+        struct CancelResult: Decodable, Sendable {
+            let id: Int
+        }
+
+        let cancel: CancelResult
+    }
 }
 struct HomeJobPayload: Decodable, Sendable {
     let id: Int
diff --git a/Hutch/Views/Pastes/PasteListView.swift b/Hutch/Views/Pastes/PasteListView.swift
index 84f2926..22f6ff9 100644
--- a/Hutch/Views/Pastes/PasteListView.swift
+++ b/Hutch/Views/Pastes/PasteListView.swift
@@ -1,10 +1,12 @@
 import SwiftUI
 
 struct PasteListView: View {
+    @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
     @Environment(AppState.self) private var appState
     @State private var viewModel: PasteListViewModel?
     @State private var showCreatePasteSheet = false
     @State private var createdPaste: Paste?
+    @State private var pasteToDelete: Paste?
 
     var body: some View {
         Group {
@@ -68,10 +70,32 @@ struct PasteListView: View {
         @Bindable var vm = viewModel
 
         List {
-            ForEach(viewModel.pastes) { paste in
+            ForEach(viewModel.filteredPastes) { paste in
                 NavigationLink(value: paste) {
                     PasteRowView(paste: paste)
                 }
+                .swipeActions(edge: .leading, allowsFullSwipe: true) {
+                    if swipeActionsEnabled {
+                        Button {
+                            Task { await viewModel.cycleVisibility(for: paste) }
+                        } label: {
+                            Label(
+                                nextVisibilityLabel(for: paste.visibility),
+                                systemImage: nextVisibilityIcon(for: paste.visibility)
+                            )
+                        }
+                        .tint(nextVisibilityColor(for: paste.visibility))
+                    }
+                }
+                .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+                    if swipeActionsEnabled {
+                        Button(role: .destructive) {
+                            pasteToDelete = paste
+                        } label: {
+                            Label("Delete", systemImage: "trash")
+                        }
+                    }
+                }
                 .task {
                     await viewModel.loadMoreIfNeeded(currentItem: paste)
                 }
@@ -115,6 +139,24 @@ struct PasteListView: View {
             await viewModel.loadPastes()
         }
         .srhtErrorBanner(error: $vm.error)
+        .alert("Delete Paste?", isPresented: Binding(
+            get: { pasteToDelete != nil },
+            set: { if !$0 { pasteToDelete = nil } }
+        )) {
+            Button("Cancel", role: .cancel) {
+                pasteToDelete = nil
+            }
+            Button("Delete", role: .destructive) {
+                if let paste = pasteToDelete {
+                    pasteToDelete = nil
+                    Task {
+                        await viewModel.deletePaste(paste)
+                    }
+                }
+            }
+        } message: {
+            Text("This paste will be permanently deleted from SourceHut.")
+        }
         .refreshable {
             await viewModel.loadPastes()
         }
@@ -130,6 +172,39 @@ struct PasteListView: View {
             )
         }
     }
+
+    private func nextVisibilityLabel(for visibility: Visibility) -> String {
+        switch visibility {
+        case .public:
+            return "Make Unlisted"
+        case .unlisted:
+            return "Make Private"
+        case .private:
+            return "Make Public"
+        }
+    }
+
+    private func nextVisibilityIcon(for visibility: Visibility) -> String {
+        switch visibility {
+        case .public:
+            return "eye.slash"
+        case .unlisted:
+            return "lock"
+        case .private:
+            return "globe"
+        }
+    }
+
+    private func nextVisibilityColor(for visibility: Visibility) -> Color {
+        switch visibility {
+        case .public:
+            return .orange
+        case .unlisted:
+            return .red
+        case .private:
+            return .green
+        }
+    }
 }
 
 private struct PasteRowView: View {
diff --git a/Hutch/Views/Pastes/PasteListViewModel.swift b/Hutch/Views/Pastes/PasteListViewModel.swift
index d41cf96..b521e18 100644
--- a/Hutch/Views/Pastes/PasteListViewModel.swift
+++ b/Hutch/Views/Pastes/PasteListViewModel.swift
@@ -107,6 +107,47 @@ final class PasteListViewModel {
         }
     }
 
+    func deletePaste(_ paste: Paste) async {
+        do {
+            _ = try await service.deletePaste(id: paste.id)
+            removePaste(id: paste.id)
+        } catch {
+            self.error = error.userFacingMessage
+        }
+    }
+
+    func cycleVisibility(for paste: Paste) async {
+        let next: Visibility
+        switch paste.visibility {
+        case .public:
+            next = .unlisted
+        case .unlisted:
+            next = .private
+        case .private:
+            next = .public
+        }
+
+        let original = pastes
+        if let index = pastes.firstIndex(where: { $0.id == paste.id }) {
+            pastes[index] = Paste(
+                id: paste.id,
+                created: paste.created,
+                visibility: next,
+                files: paste.files,
+                user: paste.user
+            )
+        }
+
+        do {
+            if let updated = try await service.updateVisibility(id: paste.id, visibility: next) {
+                upsertPaste(updated)
+            }
+        } catch {
+            pastes = original
+            self.error = error.userFacingMessage
+        }
+    }
+
     func upsertPaste(_ paste: Paste) {
         if let index = pastes.firstIndex(where: { $0.id == paste.id }) {
             pastes[index] = paste
diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift
index 93a7032..69ab419 100644
--- a/Hutch/Views/Tickets/TicketListView.swift
+++ b/Hutch/Views/Tickets/TicketListView.swift
@@ -6,10 +6,12 @@ struct TicketListView: View {
     let trackerId: Int
     let trackerRid: String
 
+    @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
     @Environment(AppState.self) private var appState
     @State private var viewModel: TicketListViewModel?
     @State private var showCreateTicketSheet = false
     @State private var createdTicket: TicketSummary?
+    @State private var labelEditorTicket: LabelEditorTicket?
 
     var body: some View {
         Group {
@@ -45,6 +47,15 @@ struct TicketListView: View {
                 }
             }
         }
+        .sheet(item: $labelEditorTicket) { item in
+            if let viewModel {
+                TicketLabelsSheet(
+                    ticketId: item.id,
+                    viewModel: viewModel
+                )
+                .presentationDetents([.medium])
+            }
+        }
         .navigationDestination(isPresented: Binding(
             get: { createdTicket != nil },
             set: { isPresented in
@@ -63,6 +74,7 @@ struct TicketListView: View {
                     ownerUsername: ownerUsername,
                     trackerName: trackerName,
                     trackerId: trackerId,
+                    trackerRid: trackerRid,
                     client: appState.client
                 )
                 viewModel = vm
@@ -93,6 +105,16 @@ struct TicketListView: View {
                 NavigationLink(value: ticket) {
                     TicketRowView(ticket: ticket)
                 }
+                .swipeActions(edge: .leading, allowsFullSwipe: true) {
+                    if swipeActionsEnabled {
+                        ticketLeadingSwipeAction(ticket, viewModel: viewModel)
+                    }
+                }
+                .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+                    if swipeActionsEnabled {
+                        ticketTrailingSwipeActions(ticket, viewModel: viewModel)
+                    }
+                }
                 .task {
                     await viewModel.loadMoreIfNeeded(currentItem: ticket)
                 }
@@ -144,6 +166,106 @@ struct TicketListView: View {
             TicketDetailView(ownerUsername: ownerUsername, trackerName: trackerName, trackerId: trackerId, trackerRid: trackerRid, ticketId: ticket.id)
         }
     }
+
+    @ViewBuilder
+    private func ticketLeadingSwipeAction(
+        _ ticket: TicketSummary,
+        viewModel: TicketListViewModel
+    ) -> some View {
+        if ticket.status.isOpen {
+            Button {
+                Task {
+                    await resolveTicket(ticket, viewModel: viewModel)
+                }
+            } label: {
+                Label("Resolve", systemImage: "checkmark.circle")
+            }
+            .tint(.green)
+        } else {
+            Button {
+                Task {
+                    await reopenTicket(ticket, viewModel: viewModel)
+                }
+            } label: {
+                Label("Reopen", systemImage: "arrow.uturn.backward")
+            }
+            .tint(.blue)
+        }
+    }
+
+    private func resolveTicket(_ ticket: TicketSummary, viewModel: TicketListViewModel) async {
+        await viewModel.resolveTicket(ticket)
+    }
+
+    private func reopenTicket(_ ticket: TicketSummary, viewModel: TicketListViewModel) async {
+        await viewModel.reopenTicket(ticket)
+    }
+
+    @ViewBuilder
+    private func ticketTrailingSwipeActions(
+        _ ticket: TicketSummary,
+        viewModel: TicketListViewModel
+    ) -> some View {
+        Button {
+            labelEditorTicket = LabelEditorTicket(id: ticket.id)
+            Task { await viewModel.loadTrackerLabels() }
+        } label: {
+            Label("Edit Labels", systemImage: "tag")
+        }
+        .tint(.purple)
+
+        if let currentUser = appState.currentUser {
+            let isAssigned = ticket.assignees.contains { assignee in
+                matchesAssignee(assignee, user: currentUser)
+            }
+
+            if isAssigned {
+                Button {
+                    Task {
+                        await viewModel.unassignFromMe(ticket: ticket, user: currentUser)
+                    }
+                } label: {
+                    Label("Unassign Me", systemImage: "person.badge.minus")
+                }
+                .tint(.orange)
+            } else {
+                Button {
+                    Task {
+                        await viewModel.assignToMe(ticket: ticket, user: currentUser)
+                    }
+                } label: {
+                    Label("Assign Me", systemImage: "person.badge.plus")
+                }
+                .tint(.cyan)
+            }
+        }
+    }
+
+    private func matchesAssignee(_ entity: Entity, user: User) -> Bool {
+        let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
+        let userCanonical = normalizedCanonicalName(user.canonicalName)
+        if assigneeCanonical == userCanonical {
+            return true
+        }
+        return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username)
+    }
+
+    private func normalizedCanonicalName(_ value: String) -> String {
+        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+        if trimmed.hasPrefix("~") {
+            return trimmed
+        }
+        return "~\(trimmed)"
+    }
+
+    private func normalizedUsername(_ value: String) -> String {
+        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+        return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
+    }
+}
+
+private struct LabelEditorTicket: Identifiable {
+    let id: Int
 }
 
 private struct CreateTicketSheet: View {
@@ -191,6 +313,92 @@ private struct CreateTicketSheet: View {
     }
 }
 
+private struct TicketLabelsSheet: View {
+    let ticketId: Int
+    let viewModel: TicketListViewModel
+
+    @Environment(\.dismiss) private var dismiss
+
+    var body: some View {
+        NavigationStack {
+            Group {
+                if let ticket = viewModel.ticket(withId: ticketId) {
+                    if viewModel.trackerLabels.isEmpty {
+                        if viewModel.isPerformingAction {
+                            ProgressView()
+                        } else {
+                            ContentUnavailableView(
+                                "No Labels",
+                                systemImage: "tag",
+                                description: Text("This tracker has no labels defined.")
+                            )
+                        }
+                    } else {
+                        List {
+                            ForEach(viewModel.trackerLabels) { label in
+                                TicketListLabelToggleRow(
+                                    label: label,
+                                    isApplied: ticket.labels.contains(where: { $0.id == label.id }),
+                                    isLoading: viewModel.isPerformingAction
+                                ) { shouldApply in
+                                    Task {
+                                        if shouldApply {
+                                            await viewModel.labelTicket(ticket, label: label)
+                                        } else {
+                                            await viewModel.unlabelTicket(ticket, label: label)
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
+                } else {
+                    ContentUnavailableView(
+                        "Ticket Unavailable",
+                        systemImage: "ticket",
+                        description: Text("This ticket is no longer in the current list.")
+                    )
+                }
+            }
+            .navigationTitle("Labels")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button("Done") { dismiss() }
+                }
+            }
+            .task {
+                if viewModel.trackerLabels.isEmpty {
+                    await viewModel.loadTrackerLabels()
+                }
+            }
+        }
+    }
+}
+
+private struct TicketListLabelToggleRow: View {
+    let label: TicketLabel
+    let isApplied: Bool
+    let isLoading: Bool
+    let onToggle: (Bool) -> Void
+
+    var body: some View {
+        Button {
+            onToggle(!isApplied)
+        } label: {
+            HStack {
+                LabelPill(label: label)
+                Spacer()
+                if isApplied {
+                    Image(systemName: "checkmark")
+                        .foregroundStyle(.blue)
+                }
+            }
+        }
+        .disabled(isLoading)
+    }
+}
+
 // MARK: - Ticket Row
 
 private struct TicketRowView: View {
diff --git a/Hutch/Views/Tickets/TicketListViewModel.swift b/Hutch/Views/Tickets/TicketListViewModel.swift
index 303fd66..4a9f8df 100644
--- a/Hutch/Views/Tickets/TicketListViewModel.swift
+++ b/Hutch/Views/Tickets/TicketListViewModel.swift
@@ -19,6 +19,48 @@ private struct TicketsPage: Decodable, Sendable {
     let cursor: String?
 }
 
+private struct AssignmentMutationResponse: Decodable, Sendable {
+    struct EventRef: Decodable, Sendable {
+        let id: Int
+    }
+
+    let assignUser: EventRef?
+    let unassignUser: EventRef?
+}
+
+private struct LabelMutationResponse: Decodable, Sendable {
+    struct EventRef: Decodable, Sendable {
+        let id: Int
+    }
+
+    let labelTicket: EventRef?
+    let unlabelTicket: EventRef?
+}
+
+private struct TrackerLabelsResponse: Decodable, Sendable {
+    let user: UserTrackerLabelsWrapper
+}
+
+private struct UserTrackerLabelsWrapper: Decodable, Sendable {
+    let tracker: TrackerLabelsWrapper
+}
+
+private struct TrackerLabelsWrapper: Decodable, Sendable {
+    let labels: LabelsPage
+}
+
+private struct LabelsPage: Decodable, Sendable {
+    let results: [TicketLabel]
+}
+
+private struct UpdateStatusResponse: Decodable, Sendable {
+    let updateTicketStatus: MutationEventRef
+}
+
+private struct MutationEventRef: Decodable, Sendable {
+    let eventType: String
+}
+
 // MARK: - Filter
 
 enum TicketFilter: String, CaseIterable, Sendable {
@@ -260,8 +302,233 @@ final class TicketListViewModel {
         }
     }
 
+    func resolveTicket(_ ticket: TicketSummary) async {
+        let input: [String: any Sendable] = [
+            "status": TicketStatus.resolved.rawValue,
+            "resolution": TicketResolution.fixed.rawValue
+        ]
+        await performStatusUpdate(ticket: ticket, input: input)
+    }
+
+    func reopenTicket(_ ticket: TicketSummary) async {
+        let input: [String: any Sendable] = [
+            "status": TicketStatus.reported.rawValue
+        ]
+        await performStatusUpdate(ticket: ticket, input: input)
+    }
+
+    func assignToMe(ticket: TicketSummary, user: User) async {
+        guard !isPerformingAction else { return }
+        isPerformingAction = true
+        error = nil
+
+        let original = tickets
+        if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
+            let entity = Entity(canonicalName: user.canonicalName)
+            let updated = TicketSummary(
+                id: ticket.id,
+                title: ticket.title,
+                status: ticket.status,
+                resolution: ticket.resolution,
+                created: ticket.created,
+                submitter: ticket.submitter,
+                labels: ticket.labels,
+                assignees: ticket.assignees + [entity]
+            )
+            tickets[index] = updated
+        }
+
+        do {
+            _ = try await client.execute(
+                service: .todo,
+                query: Self.assignUserMutation,
+                variables: [
+                    "trackerId": trackerId,
+                    "ticketId": ticket.id,
+                    "userId": user.id
+                ],
+                responseType: AssignmentMutationResponse.self
+            )
+        } catch {
+            tickets = original
+            self.error = error.userFacingMessage
+        }
+
+        isPerformingAction = false
+    }
+
+    func unassignFromMe(ticket: TicketSummary, user: User) async {
+        guard !isPerformingAction else { return }
+        isPerformingAction = true
+        error = nil
+
+        let original = tickets
+        if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
+            let filtered = ticket.assignees.filter { assignee in
+                !Self.matchesAssignee(assignee, user: user)
+            }
+            let updated = TicketSummary(
+                id: ticket.id,
+                title: ticket.title,
+                status: ticket.status,
+                resolution: ticket.resolution,
+                created: ticket.created,
+                submitter: ticket.submitter,
+                labels: ticket.labels,
+                assignees: filtered
+            )
+            tickets[index] = updated
+        }
+
+        do {
+            _ = try await client.execute(
+                service: .todo,
+                query: Self.unassignUserMutation,
+                variables: [
+                    "trackerId": trackerId,
+                    "ticketId": ticket.id,
+                    "userId": user.id
+                ],
+                responseType: AssignmentMutationResponse.self
+            )
+        } catch {
+            tickets = original
+            self.error = error.userFacingMessage
+        }
+
+        isPerformingAction = false
+    }
+
+    func loadTrackerLabels() async {
+        do {
+            let result = try await client.execute(
+                service: .todo,
+                query: Self.trackerLabelsQuery,
+                variables: [
+                    "owner": ownerUsername,
+                    "tracker": trackerName
+                ],
+                responseType: TrackerLabelsResponse.self
+            )
+            trackerLabels = result.user.tracker.labels.results
+        } catch {
+            self.error = error.userFacingMessage
+        }
+    }
+
+    func labelTicket(_ ticket: TicketSummary, label: TicketLabel) async {
+        guard !isPerformingAction else { return }
+        isPerformingAction = true
+        error = nil
+
+        let original = tickets
+        if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
+            let updated = TicketSummary(
+                id: ticket.id,
+                title: ticket.title,
+                status: ticket.status,
+                resolution: ticket.resolution,
+                created: ticket.created,
+                submitter: ticket.submitter,
+                labels: ticket.labels + [label],
+                assignees: ticket.assignees
+            )
+            tickets[index] = updated
+        }
+
+        do {
+            _ = try await client.execute(
+                service: .todo,
+                query: Self.labelTicketMutation,
+                variables: [
+                    "trackerId": trackerId,
+                    "ticketId": ticket.id,
+                    "labelId": label.id
+                ],
+                responseType: LabelMutationResponse.self
+            )
+        } catch {
+            tickets = original
+            self.error = error.userFacingMessage
+        }
+
+        isPerformingAction = false
+    }
+
+    func unlabelTicket(_ ticket: TicketSummary, label: TicketLabel) async {
+        guard !isPerformingAction else { return }
+        isPerformingAction = true
+        error = nil
+
+        let original = tickets
+        if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
+            let filtered = ticket.labels.filter { $0.id != label.id }
+            let updated = TicketSummary(
+                id: ticket.id,
+                title: ticket.title,
+                status: ticket.status,
+                resolution: ticket.resolution,
+                created: ticket.created,
+                submitter: ticket.submitter,
+                labels: filtered,
+                assignees: ticket.assignees
+            )
+            tickets[index] = updated
+        }
+
+        do {
+            _ = try await client.execute(
+                service: .todo,
+                query: Self.unlabelTicketMutation,
+                variables: [
+                    "trackerId": trackerId,
+                    "ticketId": ticket.id,
+                    "labelId": label.id
+                ],
+                responseType: LabelMutationResponse.self
+            )
+        } catch {
+            tickets = original
+            self.error = error.userFacingMessage
+        }
+
+        isPerformingAction = false
+    }
+
+    func ticket(withId ticketId: Int) -> TicketSummary? {
+        tickets.first(where: { $0.id == ticketId })
+    }
+
     // MARK: - Private
 
+    private func performStatusUpdate(ticket: TicketSummary, input: [String: any Sendable]) async {
+        guard !isPerformingAction else { return }
+        isPerformingAction = true
+        error = nil
+
+        do {
+            let variables: [String: any Sendable] = [
+                "trackerId": trackerId,
+                "ticketId": ticket.id,
+                "input": input
+            ]
+            let result = try await client.execute(
+                service: .todo,
+                query: Self.updateStatusMutation,
+                variables: variables,
+                responseType: UpdateStatusResponse.self
+            )
+            _ = result.updateTicketStatus
+            if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
+                tickets[index] = updatedTicket(from: ticket, input: input)
+            }
+        } catch {
+            self.error = error.userFacingMessage
+        }
+
+        isPerformingAction = false
+    }
+
     private func fetchPage(cursor: String?) async throws -> TicketsPage {
         var variables: [String: any Sendable] = [
             "owner": ownerUsername,
@@ -282,4 +549,42 @@ final class TicketListViewModel {
     private struct SubmitTicketResponse: Decodable, Sendable {
         let submitTicket: TicketSummary
     }
+
+    private static func matchesAssignee(_ entity: Entity, user: User) -> Bool {
+        let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
+        let userCanonical = normalizedCanonicalName(user.canonicalName)
+        if assigneeCanonical == userCanonical {
+            return true
+        }
+        return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username)
+    }
+
+    private static func normalizedCanonicalName(_ value: String) -> String {
+        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+        if trimmed.hasPrefix("~") {
+            return trimmed
+        }
+        return "~\(trimmed)"
+    }
+
+    private static func normalizedUsername(_ value: String) -> String {
+        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+        return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
+    }
+
+    private func updatedTicket(from ticket: TicketSummary, input: [String: any Sendable]) -> TicketSummary {
+        let updatedStatus = (input["status"] as? String).flatMap(TicketStatus.init(rawValue:)) ?? ticket.status
+        let updatedResolution = (input["resolution"] as? String).flatMap(TicketResolution.init(rawValue:))
+
+        return TicketSummary(
+            id: ticket.id,
+            title: ticket.title,
+            status: updatedStatus,
+            resolution: updatedStatus == .resolved ? updatedResolution : nil,
+            created: ticket.created,
+            submitter: ticket.submitter,
+            labels: ticket.labels,
+            assignees: ticket.assignees
+        )
+    }
 }
diff --git a/HutchTests/TicketListViewModelTests.swift b/HutchTests/TicketListViewModelTests.swift
new file mode 100644
index 0000000..23be24c
--- /dev/null
+++ b/HutchTests/TicketListViewModelTests.swift
@@ -0,0 +1,116 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct TicketListViewModelTests {
+
+    @Test
+    func filteredTicketsReturnsStatusFilteredTicketsWhenSearchTextIsEmpty() {
+        let tickets = [
+            makeTicket(id: 1, title: "Crash on launch", status: .reported, submitter: "~owner", labels: []),
+            makeTicket(id: 2, title: "Already fixed", status: .resolved, submitter: "~owner", labels: [])
+        ]
+
+        let filtered = filterTickets(tickets, filter: .open, query: "")
+
+        #expect(filtered.map(\.id) == [1])
+    }
+
+    @Test
+    func filteredTicketsMatchesTitleAndTicketId() {
+        let tickets = [
+            makeTicket(id: 42, title: "Crash on launch", status: .reported, submitter: "~owner", labels: []),
+            makeTicket(id: 99, title: "Settings polish", status: .reported, submitter: "~owner", labels: [])
+        ]
+
+        let titleMatches = filterTickets(tickets, filter: .all, query: "settings")
+        let idMatches = filterTickets(tickets, filter: .all, query: "42")
+
+        #expect(titleMatches.map(\.id) == [99])
+        #expect(idMatches.map(\.id) == [42])
+    }
+
+    @Test
+    func filteredTicketsMatchesSubmitterAndLabels() {
+        let tickets = [
+            makeTicket(id: 1, title: "Crash on launch", status: .reported, submitter: "~owner", labels: [makeLabel(id: 1, name: "bug")]),
+            makeTicket(id: 2, title: "Needs triage", status: .reported, submitter: "~triage", labels: [makeLabel(id: 2, name: "needs-info")])
+        ]
+
+        let submitterMatches = filterTickets(tickets, filter: .all, query: "~triage")
+        let labelMatches = filterTickets(tickets, filter: .all, query: "bug")
+
+        #expect(submitterMatches.map(\.id) == [2])
+        #expect(labelMatches.map(\.id) == [1])
+    }
+
+    @Test
+    @MainActor
+    func resolveTicketInputHasCorrectStatusAndDefaultResolution() {
+        let input: [String: any Sendable] = [
+            "status": TicketStatus.resolved.rawValue,
+            "resolution": TicketResolution.fixed.rawValue
+        ]
+        #expect(input["status"] as? String == "resolved")
+        #expect(input["resolution"] as? String == "fixed")
+    }
+
+    @Test
+    @MainActor
+    func reopenTicketInputOmitsResolution() {
+        let input: [String: any Sendable] = [
+            "status": TicketStatus.reported.rawValue
+        ]
+        #expect(input["status"] as? String == "reported")
+        #expect(input["resolution"] == nil)
+    }
+
+    private func filterTickets(_ tickets: [TicketSummary], filter: TicketFilter, query: String) -> [TicketSummary] {
+        let statusFiltered: [TicketSummary]
+        switch filter {
+        case .open:
+            statusFiltered = tickets.filter { $0.status.isOpen }
+        case .resolved:
+            statusFiltered = tickets.filter { !$0.status.isOpen }
+        case .all:
+            statusFiltered = tickets
+        }
+
+        let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+        guard !q.isEmpty else { return statusFiltered }
+        return statusFiltered.filter {
+            String($0.id).contains(q) ||
+            $0.title.lowercased().contains(q) ||
+            $0.submitter.canonicalName.lowercased().contains(q) ||
+            $0.labels.contains { $0.name.lowercased().contains(q) }
+        }
+    }
+
+    private func makeTicket(
+        id: Int,
+        title: String,
+        status: TicketStatus,
+        submitter: String,
+        labels: [TicketLabel]
+    ) -> TicketSummary {
+        TicketSummary(
+            id: id,
+            title: title,
+            status: status,
+            resolution: status == .resolved ? .fixed : nil,
+            created: Date(),
+            submitter: Entity(canonicalName: submitter),
+            labels: labels,
+            assignees: []
+        )
+    }
+
+    private func makeLabel(id: Int, name: String) -> TicketLabel {
+        TicketLabel(
+            id: id,
+            name: name,
+            backgroundColor: "#000000",
+            foregroundColor: "#ffffff"
+        )
+    }
+}