summaryrefslogtreecommitdiff
path: root/Hutch
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch')
-rw-r--r--Hutch/Views/Builds/BuildListView.swift1
-rw-r--r--Hutch/Views/Builds/BuildListViewModel.swift37
-rw-r--r--Hutch/Views/Home/HomeView.swift87
-rw-r--r--Hutch/Views/Home/HomeViewModel.swift150
-rw-r--r--Hutch/Views/Pastes/PasteListView.swift77
-rw-r--r--Hutch/Views/Pastes/PasteListViewModel.swift41
-rw-r--r--Hutch/Views/Tickets/TicketListView.swift208
-rw-r--r--Hutch/Views/Tickets/TicketListViewModel.swift305
8 files changed, 887 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
+ )
+ }
}