krz/hutch

an ios client for sourcehut

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

576bb6c2432a15f5138771c06019b55fd2244e15

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-13T03:42:33Z

feat: add ticket label management and bulk actions

Implements: https://todo.sr.ht/~ccleberg/hutch/45
Implements: https://todo.sr.ht/~ccleberg/hutch/46
 Hutch.xcodeproj/project.pbxproj                 |  16 +-
 Hutch/Models/TicketBulkAction.swift             |  88 ++++++
 Hutch/Views/Tickets/TicketListView.swift        | 354 +++++++++++++++++++++---
 Hutch/Views/Tickets/TicketListViewModel.swift   | 259 ++++++++++++++++-
 Hutch/Views/Tickets/TrackerManagementView.swift |  52 +++-
 HutchTests/TicketBulkActionTests.swift          |  35 +++
 HutchTests/TicketListViewModelTests.swift       |  38 +++
 7 files changed, 792 insertions(+), 50 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 3a7f7cd..c719ba2 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -515,7 +515,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 45;
+				CURRENT_PROJECT_VERSION = 46;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.19.2;
+				MARKETING_VERSION = 2.19.3;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 45;
+				CURRENT_PROJECT_VERSION = 46;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.19.2;
+				MARKETING_VERSION = 2.19.3;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 45;
+				CURRENT_PROJECT_VERSION = 46;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.19.2;
+				MARKETING_VERSION = 2.19.3;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 45;
+				CURRENT_PROJECT_VERSION = 46;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.19.2;
+				MARKETING_VERSION = 2.19.3;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
diff --git a/Hutch/Models/TicketBulkAction.swift b/Hutch/Models/TicketBulkAction.swift
new file mode 100644
index 0000000..dcf7676
--- /dev/null
+++ b/Hutch/Models/TicketBulkAction.swift
@@ -0,0 +1,88 @@
+import Foundation
+
+enum TicketBulkActionKind: String, Sendable {
+    case close
+    case assign
+
+    var displayName: String {
+        switch self {
+        case .close:
+            "Close"
+        case .assign:
+            "Assign"
+        }
+    }
+
+    var pastTenseDisplayName: String {
+        switch self {
+        case .close:
+            "Closed"
+        case .assign:
+            "Assigned"
+        }
+    }
+}
+
+struct TicketBulkActionResult: Identifiable, Sendable {
+    let id = UUID()
+    let action: TicketBulkActionKind
+    let totalCount: Int
+    let updatedCount: Int
+    let unchangedCount: Int
+    let failures: [TicketBulkActionFailure]
+
+    var failedCount: Int {
+        failures.count
+    }
+
+    var title: String {
+        if updatedCount == 0, failedCount > 0 {
+            return "\(action.displayName) Failed"
+        }
+        if failedCount > 0 {
+            return "\(action.displayName) Partially Applied"
+        }
+        return "\(action.displayName) Complete"
+    }
+
+    var message: String {
+        var components: [String] = []
+
+        if updatedCount > 0 {
+            components.append("\(action.pastTenseDisplayName) \(updatedCount) \(ticketWord(for: updatedCount)).")
+        }
+
+        if unchangedCount > 0 {
+            let unchangedDescription: String
+            switch action {
+            case .close:
+                unchangedDescription = "\(unchangedCount) already closed."
+            case .assign:
+                unchangedDescription = "\(unchangedCount) already assigned."
+            }
+            components.append(unchangedDescription)
+        }
+
+        if failedCount > 0 {
+            let ids = failures
+                .map { "#\($0.ticketID)" }
+                .joined(separator: ", ")
+            components.append("Failed: \(ids).")
+        }
+
+        if components.isEmpty {
+            components.append("No tickets were selected.")
+        }
+
+        return components.joined(separator: " ")
+    }
+
+    private func ticketWord(for count: Int) -> String {
+        count == 1 ? "ticket" : "tickets"
+    }
+}
+
+struct TicketBulkActionFailure: Sendable {
+    let ticketID: Int
+    let message: String
+}
diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift
index 3e0c656..bd2236c 100644
--- a/Hutch/Views/Tickets/TicketListView.swift
+++ b/Hutch/Views/Tickets/TicketListView.swift
@@ -19,6 +19,9 @@ struct TicketListView: View {
     @State private var showTrackerACLs = false
     @State private var showTrackerLabels = false
     @State private var showDeleteTrackerConfirmation = false
+    @State private var showBulkCloseSheet = false
+    @State private var showBulkAssignSheet = false
+    @State private var bulkActionResult: TicketBulkActionResult?
 
     private var isOwnedByCurrentUser: Bool {
         guard let currentUser = appState.currentUser else { return false }
@@ -46,27 +49,46 @@ struct TicketListView: View {
         .navigationTitle(tracker.name)
         .navigationBarTitleDisplayMode(.inline)
         .toolbar {
-            ToolbarItemGroup(placement: .topBarTrailing) {
-                SRHTShareButton(
-                    url: SRHTWebURL.tracker(
-                        ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
-                        trackerName: tracker.name
-                    ),
-                    target: .tracker
-                ) {
-                    Image(systemName: "square.and.arrow.up")
+            ToolbarItem(placement: .topBarLeading) {
+                if let viewModel, viewModel.isSelectionMode {
+                    Button("Cancel") {
+                        viewModel.setSelectionMode(false)
+                    }
                 }
+            }
 
+            ToolbarItemGroup(placement: .topBarTrailing) {
                 if viewModel != nil {
-                    Button {
-                        showCreateTicketSheet = true
-                    } label: {
-                        Image(systemName: "plus")
-                    }
-                    .accessibilityLabel("Create ticket")
+                    if let viewModel, viewModel.isSelectionMode {
+                        Button("All") {
+                            viewModel.selectVisibleTickets(viewModel.filteredTickets)
+                        }
+                        .disabled(viewModel.filteredTickets.isEmpty || viewModel.isPerformingAction)
+                    } else {
+                        SRHTShareButton(
+                            url: SRHTWebURL.tracker(
+                                ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
+                                trackerName: tracker.name
+                            ),
+                            target: .tracker
+                        ) {
+                            Image(systemName: "square.and.arrow.up")
+                        }
+
+                        Button {
+                            showCreateTicketSheet = true
+                        } label: {
+                            Image(systemName: "plus")
+                        }
+                        .accessibilityLabel("Create ticket")
+
+                        Button("Select") {
+                            viewModel?.setSelectionMode(true)
+                        }
 
-                    if isOwnedByCurrentUser {
-                        trackerActionsMenu
+                        if isOwnedByCurrentUser {
+                            trackerActionsMenu
+                        }
                     }
                 }
             }
@@ -130,12 +152,37 @@ struct TicketListView: View {
                     .presentationDetents([.large])
             }
         }
-        .sheet(isPresented: $showTrackerLabels) {
+        .sheet(isPresented: $showTrackerLabels, onDismiss: syncTrackerLabelsIntoTicketList) {
             if let trackerManagementViewModel {
                 TrackerLabelManagementSheet(viewModel: trackerManagementViewModel)
                     .presentationDetents([.large])
             }
         }
+        .sheet(isPresented: $showBulkCloseSheet) {
+            if let viewModel {
+                BulkResolveSheet(
+                    viewModel: viewModel,
+                    isPresented: $showBulkCloseSheet,
+                    onComplete: { result in
+                        bulkActionResult = result
+                    }
+                )
+                    .presentationDetents([.medium])
+            }
+        }
+        .sheet(isPresented: $showBulkAssignSheet) {
+            if let viewModel {
+                BulkAssignSheet(
+                    viewModel: viewModel,
+                    currentUser: appState.currentUser,
+                    isPresented: $showBulkAssignSheet,
+                    onComplete: { result in
+                        bulkActionResult = result
+                    }
+                )
+                .presentationDetents([.medium])
+            }
+        }
         .alert("Delete Tracker?", isPresented: $showDeleteTrackerConfirmation) {
             Button("Cancel", role: .cancel) {
                 // no-op: .cancel role handles alert dismissal
@@ -153,6 +200,13 @@ struct TicketListView: View {
         } message: {
             Text("“\(tracker.name)” will be permanently deleted.")
         }
+        .alert(item: $bulkActionResult) { result in
+            Alert(
+                title: Text(result.title),
+                message: Text(result.message),
+                dismissButton: .default(Text("OK"))
+            )
+        }
         .navigationDestination(isPresented: Binding(
             get: { createdTicket != nil },
             set: { isPresented in
@@ -199,6 +253,8 @@ struct TicketListView: View {
                     savedFilters: viewModel.savedFilters,
                     activeSavedFilterID: viewModel.activeSavedFilterID,
                     canSaveCurrentFilter: viewModel.hasCustomFilterSelection,
+                    isSelectionMode: viewModel.isSelectionMode,
+                    selectedTicketCount: viewModel.selectedTicketCount,
                     filter: $vm.filter
                 ) {
                     showLabelFilterSheet = true
@@ -216,26 +272,40 @@ struct TicketListView: View {
                 .listRowSeparator(.hidden)
 
                 ForEach(viewModel.filteredTickets) { ticket in
-                    NavigationLink {
-                        TicketDetailView(
-                            ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
-                            trackerName: tracker.name,
-                            trackerId: tracker.id,
-                            trackerRid: tracker.rid,
-                            ticketId: ticket.id
-                        )
-                    } label: {
-                        TicketRowView(ticket: ticket)
-                    }
-                    .swipeActions(edge: .leading, allowsFullSwipe: true) {
-                        if swipeActionsEnabled {
-                            ticketAssignSwipeAction(ticket, viewModel: viewModel)
-                        }
-                    }
-                    .swipeActions(edge: .trailing, allowsFullSwipe: false) {
-                        if swipeActionsEnabled {
-                            ticketStatusSwipeAction(ticket, viewModel: viewModel)
-                            ticketLabelSwipeAction(ticket, viewModel: viewModel)
+                    Group {
+                        if viewModel.isSelectionMode {
+                            Button {
+                                viewModel.toggleTicketSelection(ticket)
+                            } label: {
+                                SelectableTicketRow(
+                                    ticket: ticket,
+                                    isSelected: viewModel.selectedTicketIDs.contains(ticket.id)
+                                )
+                            }
+                            .buttonStyle(.plain)
+                        } else {
+                            NavigationLink {
+                                TicketDetailView(
+                                    ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
+                                    trackerName: tracker.name,
+                                    trackerId: tracker.id,
+                                    trackerRid: tracker.rid,
+                                    ticketId: ticket.id
+                                )
+                            } label: {
+                                TicketRowView(ticket: ticket)
+                            }
+                            .swipeActions(edge: .leading, allowsFullSwipe: true) {
+                                if swipeActionsEnabled {
+                                    ticketAssignSwipeAction(ticket, viewModel: viewModel)
+                                }
+                            }
+                            .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+                                if swipeActionsEnabled {
+                                    ticketStatusSwipeAction(ticket, viewModel: viewModel)
+                                    ticketLabelSwipeAction(ticket, viewModel: viewModel)
+                                }
+                            }
                         }
                     }
                     .task {
@@ -287,6 +357,20 @@ struct TicketListView: View {
         .refreshable {
             await viewModel.loadTickets()
         }
+        .safeAreaInset(edge: .bottom) {
+            if viewModel.isSelectionMode {
+                TicketBulkActionBar(
+                    selectedCount: viewModel.selectedTicketCount,
+                    isPerformingAction: viewModel.isPerformingAction,
+                    onClose: {
+                        showBulkCloseSheet = true
+                    },
+                    onAssign: {
+                        showBulkAssignSheet = true
+                    }
+                )
+            }
+        }
     }
 
     private func emptyStateDescription(for viewModel: TicketListViewModel) -> String {
@@ -296,6 +380,11 @@ struct TicketListView: View {
         return "No \(viewModel.filter.rawValue.lowercased()) tickets found."
     }
 
+    private func syncTrackerLabelsIntoTicketList() {
+        guard let trackerManagementViewModel, let viewModel else { return }
+        viewModel.syncTrackerLabels(trackerManagementViewModel.labels)
+    }
+
     private var trackerActionsMenu: some View {
         Menu {
             Button {
@@ -535,6 +624,8 @@ private struct TicketListFilterHeader: View {
     let savedFilters: [SavedTicketFilter]
     let activeSavedFilterID: SavedTicketFilter.ID?
     let canSaveCurrentFilter: Bool
+    let isSelectionMode: Bool
+    let selectedTicketCount: Int
     @Binding var filter: TicketFilter
     let onShowLabels: () -> Void
     let onSaveFilter: () -> Void
@@ -544,12 +635,19 @@ private struct TicketListFilterHeader: View {
 
     var body: some View {
         VStack(alignment: .leading, spacing: 10) {
+            if isSelectionMode {
+                Text(selectionSummary)
+                    .font(.subheadline.weight(.medium))
+                    .foregroundStyle(.secondary)
+            }
+
             Picker("Filter", selection: $filter) {
                 ForEach(TicketFilter.allCases, id: \.self) { filter in
                     Text(filter.rawValue).tag(filter)
                 }
             }
             .pickerStyle(.segmented)
+            .disabled(isSelectionMode)
 
             TicketQuickFilterBar(
                 selectedLabels: selectedLabels,
@@ -562,11 +660,16 @@ private struct TicketListFilterHeader: View {
                 onApplySavedFilter: onApplySavedFilter,
                 onDeleteSavedFilter: onDeleteSavedFilter
             )
+            .disabled(isSelectionMode)
         }
         .padding(.horizontal, 16)
         .padding(.top, 6)
         .padding(.bottom, 10)
     }
+
+    private var selectionSummary: String {
+        selectedTicketCount == 0 ? "Select tickets for bulk actions." : "\(selectedTicketCount) selected"
+    }
 }
 
 private struct TicketQuickFilterBar: View {
@@ -810,6 +913,181 @@ private struct TicketListLabelToggleRow: View {
     }
 }
 
+private struct TicketBulkActionBar: View {
+    let selectedCount: Int
+    let isPerformingAction: Bool
+    let onClose: () -> Void
+    let onAssign: () -> Void
+
+    var body: some View {
+        VStack(spacing: 12) {
+            HStack {
+                Text(selectedCount == 0 ? "Select tickets to continue" : "\(selectedCount) selected")
+                    .font(.subheadline.weight(.medium))
+                    .foregroundStyle(.secondary)
+                Spacer()
+            }
+
+            HStack(spacing: 12) {
+                Button("Close") {
+                    onClose()
+                }
+                .buttonStyle(.borderedProminent)
+                .tint(.red)
+                .disabled(selectedCount == 0 || isPerformingAction)
+
+                Button("Assign") {
+                    onAssign()
+                }
+                .buttonStyle(.bordered)
+                .disabled(selectedCount == 0 || isPerformingAction)
+
+                if isPerformingAction {
+                    ProgressView()
+                        .controlSize(.small)
+                }
+            }
+        }
+        .padding(.horizontal, 16)
+        .padding(.top, 12)
+        .padding(.bottom, 16)
+        .background(.ultraThinMaterial)
+    }
+}
+
+private struct BulkResolveSheet: View {
+    let viewModel: TicketListViewModel
+    @Binding var isPresented: Bool
+    let onComplete: (TicketBulkActionResult) -> Void
+    @State private var selectedResolution: TicketResolution = .fixed
+
+    private static let resolutionOptions: [TicketResolution] = [
+        .closed, .fixed, .implemented, .wontFix,
+        .byDesign, .invalid, .duplicate, .notOurBug
+    ]
+
+    var body: some View {
+        NavigationStack {
+            Form {
+                Section {
+                    Text("\(viewModel.selectedTicketCount) ticket\(viewModel.selectedTicketCount == 1 ? "" : "s") selected")
+                        .font(.subheadline)
+                        .foregroundStyle(.secondary)
+                }
+
+                Section("Resolution") {
+                    Picker("Resolution", selection: $selectedResolution) {
+                        ForEach(Self.resolutionOptions, id: \.self) { resolution in
+                            Text(resolution.displayName).tag(resolution)
+                        }
+                    }
+                    .pickerStyle(.inline)
+                    .labelsHidden()
+                }
+            }
+            .navigationTitle("Close Tickets")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button("Cancel") { isPresented = false }
+                }
+                ToolbarItem(placement: .confirmationAction) {
+                    Button("Close") {
+                        Task {
+                            if let result = await viewModel.closeSelectedTickets(resolution: selectedResolution) {
+                                onComplete(result)
+                                isPresented = false
+                            }
+                        }
+                    }
+                    .disabled(viewModel.selectedTicketCount == 0 || viewModel.isPerformingAction)
+                }
+            }
+        }
+    }
+}
+
+private struct BulkAssignSheet: View {
+    let viewModel: TicketListViewModel
+    let currentUser: User?
+    @Binding var isPresented: Bool
+    let onComplete: (TicketBulkActionResult) -> Void
+    @State private var username = ""
+
+    var body: some View {
+        NavigationStack {
+            Form {
+                Section {
+                    Text("\(viewModel.selectedTicketCount) ticket\(viewModel.selectedTicketCount == 1 ? "" : "s") selected")
+                        .font(.subheadline)
+                        .foregroundStyle(.secondary)
+                }
+
+                if let currentUser {
+                    Section {
+                        Button("Assign to Me") {
+                            Task {
+                                if let result = await viewModel.assignSelectedTickets(username: currentUser.username) {
+                                    onComplete(result)
+                                    isPresented = false
+                                }
+                            }
+                        }
+                        .disabled(viewModel.selectedTicketCount == 0 || viewModel.isPerformingAction)
+                    }
+                }
+
+                Section("Assign User") {
+                    TextField("Username or ~username", text: $username)
+                        .textContentType(.username)
+                        .autocorrectionDisabled()
+                        .textInputAutocapitalization(.never)
+                }
+            }
+            .navigationTitle("Assign Tickets")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button("Cancel") { isPresented = false }
+                }
+                ToolbarItem(placement: .confirmationAction) {
+                    Button("Assign") {
+                        Task {
+                            if let result = await viewModel.assignSelectedTickets(username: username) {
+                                onComplete(result)
+                                isPresented = false
+                            }
+                        }
+                    }
+                    .disabled(
+                        username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+                        || viewModel.selectedTicketCount == 0
+                        || viewModel.isPerformingAction
+                    )
+                }
+            }
+        }
+    }
+}
+
+private struct SelectableTicketRow: View {
+    let ticket: TicketSummary
+    let isSelected: Bool
+
+    var body: some View {
+        HStack(alignment: .top, spacing: 12) {
+            Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
+                .font(.title3)
+                .foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
+                .padding(.top, 4)
+
+            TicketRowView(ticket: ticket)
+        }
+        .padding(.vertical, 2)
+        .contentShape(Rectangle())
+    }
+}
+
 // MARK: - Ticket Row
 
 private struct TicketRowView: View {
diff --git a/Hutch/Views/Tickets/TicketListViewModel.swift b/Hutch/Views/Tickets/TicketListViewModel.swift
index 3b1310d..a2e7135 100644
--- a/Hutch/Views/Tickets/TicketListViewModel.swift
+++ b/Hutch/Views/Tickets/TicketListViewModel.swift
@@ -53,6 +53,14 @@ private struct MutationEventRef: Decodable, Sendable {
     let eventType: String
 }
 
+private struct TicketListUserLookupResponse: Decodable, Sendable {
+    let user: TicketListUserIDPayload?
+}
+
+private struct TicketListUserIDPayload: Decodable, Sendable {
+    let id: Int
+}
+
 // MARK: - Filter
 
 enum TicketFilter: String, CaseIterable, Codable, Sendable {
@@ -78,6 +86,8 @@ final class TicketListViewModel {
     private(set) var isPerformingAction = false
     private(set) var trackerLabels: [TicketLabel] = []
     private(set) var savedFilters: [SavedTicketFilter]
+    private(set) var isSelectionMode = false
+    private(set) var selectedTicketIDs: Set<Int> = []
     var error: String?
     var filter: TicketFilter = .open {
         didSet {
@@ -198,6 +208,12 @@ final class TicketListViewModel {
     }
     """
 
+    private static let userLookupQuery = """
+    query userLookup($username: String!) {
+        user(username: $username) { id }
+    }
+    """
+
     // MARK: - Computed
 
     var currentFilterState: TicketListFilterState {
@@ -216,6 +232,14 @@ final class TicketListViewModel {
         availableLabels.filter { selectedLabelIDs.contains($0.id) }
     }
 
+    var selectedTickets: [TicketSummary] {
+        tickets.filter { selectedTicketIDs.contains($0.id) }
+    }
+
+    var selectedTicketCount: Int {
+        selectedTicketIDs.count
+    }
+
     var suggestedSavedFilterName: String {
         let labelNames = selectedLabels.map(\.name).sorted()
         var components: [String] = []
@@ -252,6 +276,7 @@ final class TicketListViewModel {
             tickets = page.results
             cursor = page.cursor
             hasMore = page.cursor != nil
+            reconcileSelectionWithLoadedTickets()
         } catch {
             self.error = error.userFacingMessage
         }
@@ -274,6 +299,7 @@ final class TicketListViewModel {
             tickets.append(contentsOf: page.results)
             cursor = page.cursor
             hasMore = page.cursor != nil
+            reconcileSelectionWithLoadedTickets()
         } catch {
             self.error = error.userFacingMessage
         }
@@ -427,12 +453,18 @@ final class TicketListViewModel {
                 variables: ["rid": trackerRid],
                 responseType: TrackerLabelsResponse.self
             )
-            trackerLabels = result.tracker.labels.results
+            syncTrackerLabels(result.tracker.labels.results)
         } catch {
             self.error = error.userFacingMessage
         }
     }
 
+    func syncTrackerLabels(_ labels: [TicketLabel]) {
+        trackerLabels = labels.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
+        tickets = Self.synchronizeTickets(tickets, with: trackerLabels)
+        selectedLabelIDs = Self.reconciledSelectedLabelIDs(selectedLabelIDs, availableLabels: trackerLabels)
+    }
+
     func toggleLabelSelection(_ label: TicketLabel) {
         if selectedLabelIDs.contains(label.id) {
             selectedLabelIDs.remove(label.id)
@@ -445,6 +477,29 @@ final class TicketListViewModel {
         selectedLabelIDs = []
     }
 
+    func setSelectionMode(_ enabled: Bool) {
+        isSelectionMode = enabled
+        if !enabled {
+            clearTicketSelection()
+        }
+    }
+
+    func toggleTicketSelection(_ ticket: TicketSummary) {
+        if selectedTicketIDs.contains(ticket.id) {
+            selectedTicketIDs.remove(ticket.id)
+        } else {
+            selectedTicketIDs.insert(ticket.id)
+        }
+    }
+
+    func selectVisibleTickets(_ tickets: [TicketSummary]) {
+        selectedTicketIDs = Set(tickets.map(\.id))
+    }
+
+    func clearTicketSelection() {
+        selectedTicketIDs = []
+    }
+
     func resetFilters() {
         filter = .open
         selectedLabelIDs = []
@@ -564,6 +619,72 @@ final class TicketListViewModel {
         tickets.first(where: { $0.id == ticketId })
     }
 
+    func closeSelectedTickets(resolution: TicketResolution) async -> TicketBulkActionResult? {
+        await performBulkAction(
+            kind: .close,
+            prepare: { ticket in
+                guard ticket.status != .resolved else { return .unchanged }
+
+                let input = Self.bulkStatusUpdateInput(resolution: resolution)
+                let updatedTicket = updatedTicket(from: ticket, input: input)
+                return .request(updatedTicket: updatedTicket) {
+                    try await self.executeBulkStatusUpdate(ticketID: ticket.id, input: input)
+                }
+            }
+        )
+    }
+
+    func assignSelectedTickets(username: String) async -> TicketBulkActionResult? {
+        let normalizedUsername = Self.normalizedUsername(username)
+        guard !normalizedUsername.isEmpty else {
+            error = "Enter a SourceHut username."
+            return nil
+        }
+
+        do {
+            let userResult = try await client.execute(
+                service: .todo,
+                query: Self.userLookupQuery,
+                variables: ["username": normalizedUsername],
+                responseType: TicketListUserLookupResponse.self
+            )
+            guard let userID = userResult.user?.id else {
+                error = "That user couldn’t be found."
+                return nil
+            }
+
+            let assignee = Entity(canonicalName: Self.normalizedCanonicalName(normalizedUsername))
+            return await performBulkAction(
+                kind: .assign,
+                prepare: { ticket in
+                    guard !ticket.assignees.contains(where: {
+                        Self.normalizedCanonicalName($0.canonicalName) == assignee.canonicalName
+                    }) else {
+                        return .unchanged
+                    }
+
+                    let updatedTicket = 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 + [assignee]
+                    )
+
+                    return .request(updatedTicket: updatedTicket) {
+                        try await self.executeBulkAssign(ticketID: ticket.id, userID: userID)
+                    }
+                }
+            )
+        } catch {
+            self.error = error.userFacingMessage
+            return nil
+        }
+    }
+
     // MARK: - Private
 
     private func performStatusUpdate(ticket: TicketSummary, input: [String: any Sendable]) async {
@@ -655,6 +776,137 @@ final class TicketListViewModel {
         activeSavedFilterID = savedFilters.first(where: { $0.state == currentFilterState })?.id
     }
 
+    private func reconcileSelectionWithLoadedTickets() {
+        let loadedTicketIDs = Set(tickets.map(\.id))
+        selectedTicketIDs.formIntersection(loadedTicketIDs)
+        if isSelectionMode, selectedTicketIDs.isEmpty {
+            isSelectionMode = false
+        }
+    }
+
+    private func performBulkAction(
+        kind: TicketBulkActionKind,
+        prepare: (TicketSummary) -> TicketBulkTicketOperation
+    ) async -> TicketBulkActionResult? {
+        guard !isPerformingAction else { return nil }
+
+        let selected = selectedTickets
+        guard !selected.isEmpty else { return nil }
+
+        isPerformingAction = true
+        error = nil
+
+        var updatedCount = 0
+        var unchangedCount = 0
+        var failures: [TicketBulkActionFailure] = []
+        var failedTicketIDs = Set<Int>()
+
+        for ticket in selected {
+            switch prepare(ticket) {
+            case .unchanged:
+                unchangedCount += 1
+            case .request(let updatedTicket, let request):
+                replaceTicket(updatedTicket)
+                do {
+                    try await request()
+                    updatedCount += 1
+                } catch {
+                    replaceTicket(ticket)
+                    failedTicketIDs.insert(ticket.id)
+                    failures.append(
+                        TicketBulkActionFailure(
+                            ticketID: ticket.id,
+                            message: error.userFacingMessage
+                        )
+                    )
+                }
+            }
+        }
+
+        isPerformingAction = false
+
+        let result = TicketBulkActionResult(
+            action: kind,
+            totalCount: selected.count,
+            updatedCount: updatedCount,
+            unchangedCount: unchangedCount,
+            failures: failures
+        )
+
+        if failedTicketIDs.isEmpty {
+            clearTicketSelection()
+            isSelectionMode = false
+        } else {
+            selectedTicketIDs = failedTicketIDs
+            isSelectionMode = true
+        }
+
+        return result
+    }
+
+    private func replaceTicket(_ ticket: TicketSummary) {
+        guard let index = tickets.firstIndex(where: { $0.id == ticket.id }) else { return }
+        tickets[index] = ticket
+    }
+
+    private func executeBulkStatusUpdate(ticketID: Int, input: [String: any Sendable]) async throws {
+        _ = try await client.execute(
+            service: .todo,
+            query: Self.updateStatusMutation,
+            variables: [
+                "trackerId": trackerId,
+                "ticketId": ticketID,
+                "input": input
+            ],
+            responseType: UpdateStatusResponse.self
+        )
+    }
+
+    private func executeBulkAssign(ticketID: Int, userID: Int) async throws {
+        _ = try await client.execute(
+            service: .todo,
+            query: Self.assignUserMutation,
+            variables: [
+                "trackerId": trackerId,
+                "ticketId": ticketID,
+                "userId": userID
+            ],
+            responseType: AssignmentMutationResponse.self
+        )
+    }
+
+    private static func bulkStatusUpdateInput(resolution: TicketResolution) -> [String: any Sendable] {
+        [
+            "status": TicketStatus.resolved.rawValue,
+            "resolution": resolution.rawValue
+        ]
+    }
+
+    static func synchronizeTickets(_ tickets: [TicketSummary], with labels: [TicketLabel]) -> [TicketSummary] {
+        let labelsByID = Dictionary(uniqueKeysWithValues: labels.map { ($0.id, $0) })
+
+        return tickets.map { ticket in
+            let updatedLabels = ticket.labels.compactMap { labelsByID[$0.id] }
+            return TicketSummary(
+                id: ticket.id,
+                title: ticket.title,
+                status: ticket.status,
+                resolution: ticket.resolution,
+                created: ticket.created,
+                submitter: ticket.submitter,
+                labels: updatedLabels,
+                assignees: ticket.assignees
+            )
+        }
+    }
+
+    static func reconciledSelectedLabelIDs(
+        _ selectedLabelIDs: Set<Int>,
+        availableLabels: [TicketLabel]
+    ) -> Set<Int> {
+        selectedLabelIDs.intersection(Set(availableLabels.map(\.id)))
+    }
+
     static func filterTickets(
         _ tickets: [TicketSummary],
         state: TicketListFilterState,
@@ -690,3 +942,8 @@ final class TicketListViewModel {
         }
     }
 }
+
+private enum TicketBulkTicketOperation {
+    case unchanged
+    case request(updatedTicket: TicketSummary, operation: @Sendable () async throws -> Void)
+}
diff --git a/Hutch/Views/Tickets/TrackerManagementView.swift b/Hutch/Views/Tickets/TrackerManagementView.swift
index 1542d54..cf923b6 100644
--- a/Hutch/Views/Tickets/TrackerManagementView.swift
+++ b/Hutch/Views/Tickets/TrackerManagementView.swift
@@ -1069,6 +1069,12 @@ struct TrackerLabelManagementSheet: View {
     var body: some View {
         NavigationStack {
             List {
+                Section {
+                    Text("Labels are managed here and reused throughout the tracker.")
+                        .font(.footnote)
+                        .foregroundStyle(.secondary)
+                }
+
                 if viewModel.isLoadingLabels {
                     HStack {
                         Spacer()
@@ -1083,10 +1089,12 @@ struct TrackerLabelManagementSheet: View {
                     )
                 } else {
                     ForEach(viewModel.labels) { label in
-                        HStack {
-                            LabelPill(label: label)
-                            Spacer()
+                        Button {
+                            editingLabel = label
+                        } label: {
+                            TrackerLabelManagementRow(label: label)
                         }
+                        .buttonStyle(.plain)
                         .swipeActions(edge: .trailing, allowsFullSwipe: false) {
                             Button {
                                 editingLabel = label
@@ -1178,6 +1186,44 @@ struct TrackerLabelManagementSheet: View {
     }
 }
 
+private struct TrackerLabelManagementRow: View {
+    let label: TicketLabel
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 8) {
+            HStack(alignment: .top, spacing: 12) {
+                LabelPill(label: label)
+                Spacer()
+                Image(systemName: "chevron.right")
+                    .font(.caption.weight(.semibold))
+                    .foregroundStyle(.tertiary)
+            }
+
+            HStack(spacing: 12) {
+                colorSwatch(hex: label.backgroundColor, title: "Background")
+                colorSwatch(hex: label.foregroundColor, title: "Text")
+            }
+        }
+        .padding(.vertical, 4)
+    }
+
+    private func colorSwatch(hex: String, title: String) -> some View {
+        HStack(spacing: 6) {
+            Circle()
+                .fill(Color(hex: hex) ?? .clear)
+                .frame(width: 10, height: 10)
+                .overlay {
+                    Circle()
+                        .stroke(Color.secondary.opacity(0.2), lineWidth: 1)
+                }
+
+            Text("\(title): \(hex.uppercased())")
+                .font(.caption)
+                .foregroundStyle(.secondary)
+        }
+    }
+}
+
 private struct TrackerLabelEditorSheet: View {
     let title: String
     let submitTitle: String
diff --git a/HutchTests/TicketBulkActionTests.swift b/HutchTests/TicketBulkActionTests.swift
new file mode 100644
index 0000000..2f4d1fd
--- /dev/null
+++ b/HutchTests/TicketBulkActionTests.swift
@@ -0,0 +1,35 @@
+import Testing
+@testable import Hutch
+
+struct TicketBulkActionTests {
+
+    @Test
+    func bulkActionResultFormatsSuccessMessage() {
+        let result = TicketBulkActionResult(
+            action: .close,
+            totalCount: 3,
+            updatedCount: 3,
+            unchangedCount: 0,
+            failures: []
+        )
+
+        #expect(result.title == "Close Complete")
+        #expect(result.message == "Closed 3 tickets.")
+    }
+
+    @Test
+    func bulkActionResultFormatsPartialFailureMessage() {
+        let result = TicketBulkActionResult(
+            action: .assign,
+            totalCount: 4,
+            updatedCount: 2,
+            unchangedCount: 1,
+            failures: [
+                TicketBulkActionFailure(ticketID: 42, message: "Network error")
+            ]
+        )
+
+        #expect(result.title == "Assign Partially Applied")
+        #expect(result.message == "Assigned 2 tickets. 1 already assigned. Failed: #42.")
+    }
+}
diff --git a/HutchTests/TicketListViewModelTests.swift b/HutchTests/TicketListViewModelTests.swift
index 0f42bf8..a237071 100644
--- a/HutchTests/TicketListViewModelTests.swift
+++ b/HutchTests/TicketListViewModelTests.swift
@@ -2,6 +2,7 @@ import Foundation
 import Testing
 @testable import Hutch
 
+@MainActor
 struct TicketListViewModelTests {
 
     @Test
@@ -81,6 +82,43 @@ struct TicketListViewModelTests {
         #expect(filtered.map(\.id) == [2])
     }
 
+    @Test
+    func synchronizeTicketsReplacesEditedLabelsAndRemovesDeletedOnes() {
+        let existing = [
+            makeTicket(
+                id: 7,
+                title: "Triage me",
+                status: .reported,
+                submitter: "~owner",
+                labels: [
+                    makeLabel(id: 1, name: "bug"),
+                    makeLabel(id: 2, name: "stale")
+                ]
+            )
+        ]
+
+        let updated = TicketListViewModel.synchronizeTickets(
+            existing,
+            with: [makeLabel(id: 1, name: "bugfix")]
+        )
+
+        #expect(updated.first?.labels.map(\.id) == [1])
+        #expect(updated.first?.labels.first?.name == "bugfix")
+    }
+
+    @Test
+    func reconciledSelectedLabelIDsRemovesUnknownIDs() {
+        let reconciled = TicketListViewModel.reconciledSelectedLabelIDs(
+            [1, 2, 5],
+            availableLabels: [
+                makeLabel(id: 2, name: "triage"),
+                makeLabel(id: 3, name: "qa")
+            ]
+        )
+
+        #expect(reconciled == [2])
+    }
+
     @Test
     @MainActor
     func resolveTicketInputHasCorrectStatusAndDefaultResolution() {