krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.5.1: Hutch/Views/Tickets/TicketListViewModel.swift · raw
1import Foundation
2
3// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
4
5private struct TrackerTicketsResponse: Decodable, Sendable {
6 let user: UserTrackerWrapper
7}
8
9private struct UserTrackerWrapper: Decodable, Sendable {
10 let tracker: TrackerTicketsWrapper
11}
12
13private struct TrackerTicketsWrapper: Decodable, Sendable {
14 let tickets: TicketsPage
15}
16
17private struct TicketsPage: Decodable, Sendable {
18 let results: [TicketSummary]
19 let cursor: String?
20}
21
22private struct AssignmentMutationResponse: Decodable, Sendable {
23 struct EventRef: Decodable, Sendable {
24 let id: Int
25 }
26
27 let assignUser: EventRef?
28 let unassignUser: EventRef?
29}
30
31private struct LabelMutationResponse: Decodable, Sendable {
32 struct EventRef: Decodable, Sendable {
33 let id: Int
34 }
35
36 let labelTicket: EventRef?
37 let unlabelTicket: EventRef?
38}
39
40private struct TrackerLabelsResponse: Decodable, Sendable {
41 let user: UserTrackerLabelsWrapper
42}
43
44private struct UserTrackerLabelsWrapper: Decodable, Sendable {
45 let tracker: TrackerLabelsWrapper
46}
47
48private struct TrackerLabelsWrapper: Decodable, Sendable {
49 let labels: LabelsPage
50}
51
52private struct LabelsPage: Decodable, Sendable {
53 let results: [TicketLabel]
54}
55
56private struct UpdateStatusResponse: Decodable, Sendable {
57 let updateTicketStatus: MutationEventRef
58}
59
60private struct MutationEventRef: Decodable, Sendable {
61 let eventType: String
62}
63
64// MARK: - Filter
65
66enum TicketFilter: String, CaseIterable, Sendable {
67 case open = "Open"
68 case resolved = "Resolved"
69 case all = "All"
70}
71
72// MARK: - View Model
73
74@Observable
75@MainActor
76final class TicketListViewModel {
77 let ownerUsername: String
78 let trackerName: String
79 let trackerId: Int
80 let trackerRid: String
81
82 private(set) var tickets: [TicketSummary] = []
83 private(set) var isLoading = false
84 private(set) var isLoadingMore = false
85 private(set) var isCreatingTicket = false
86 private(set) var isPerformingAction = false
87 private(set) var trackerLabels: [TicketLabel] = []
88 var error: String?
89 var filter: TicketFilter = .open {
90 didSet {
91 UserDefaults.standard.set(filter.rawValue, forKey: filterDefaultsKey)
92 }
93 }
94 var searchText = ""
95
96 private var cursor: String?
97 private var hasMore = true
98 private let client: SRHTClient
99
100 private var filterDefaultsKey: String {
101 "ticketFilter_\(trackerRid)"
102 }
103
104 init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, client: SRHTClient) {
105 self.ownerUsername = ownerUsername
106 self.trackerName = trackerName
107 self.trackerId = trackerId
108 self.trackerRid = trackerRid
109 self.client = client
110 if let raw = UserDefaults.standard.string(forKey: filterDefaultsKey),
111 let restored = TicketFilter(rawValue: raw) {
112 self.filter = restored
113 }
114 }
115
116 // MARK: - Query
117
118 private static let query = """
119 query tickets($owner: String!, $tracker: String!, $cursor: Cursor) {
120 user(username: $owner) {
121 tracker(name: $tracker) {
122 tickets(cursor: $cursor) {
123 results {
124 id
125 title: subject
126 status
127 resolution
128 created
129 submitter { canonicalName }
130 labels { id name backgroundColor foregroundColor }
131 assignees { canonicalName }
132 }
133 cursor
134 }
135 }
136 }
137 }
138 """
139
140 private static let submitTicketMutation = """
141 mutation submitTicket($trackerId: Int!, $input: SubmitTicketInput!) {
142 submitTicket(trackerId: $trackerId, input: $input) {
143 id
144 title: subject
145 status
146 resolution
147 created
148 submitter { canonicalName }
149 labels { id name backgroundColor foregroundColor }
150 assignees { canonicalName }
151 }
152 }
153 """
154
155 private static let updateStatusMutation = """
156 mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) {
157 updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
158 eventType: __typename
159 }
160 }
161 """
162
163 private static let assignUserMutation = """
164 mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
165 assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
166 }
167 """
168
169 private static let unassignUserMutation = """
170 mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
171 unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
172 }
173 """
174
175 private static let labelTicketMutation = """
176 mutation labelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
177 labelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
178 }
179 """
180
181 private static let unlabelTicketMutation = """
182 mutation unlabelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
183 unlabelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
184 }
185 """
186
187 private static let trackerLabelsQuery = """
188 query trackerLabels($owner: String!, $tracker: String!) {
189 user(username: $owner) {
190 tracker(name: $tracker) {
191 labels {
192 results { id name backgroundColor foregroundColor }
193 }
194 }
195 }
196 }
197 """
198
199 // MARK: - Computed
200
201 /// Tickets filtered by the selected status filter.
202 var filteredTickets: [TicketSummary] {
203 let statusFiltered: [TicketSummary]
204 switch filter {
205 case .open:
206 statusFiltered = tickets.filter { $0.status.isOpen }
207 case .resolved:
208 statusFiltered = tickets.filter { !$0.status.isOpen }
209 case .all:
210 statusFiltered = tickets
211 }
212 let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
213 guard !q.isEmpty else { return statusFiltered }
214 return statusFiltered.filter {
215 String($0.id).contains(q) ||
216 $0.title.lowercased().contains(q) ||
217 $0.submitter.canonicalName.lowercased().contains(q) ||
218 $0.labels.contains { $0.name.lowercased().contains(q) }
219 }
220 }
221
222 // MARK: - Public API
223
224 func loadTickets() async {
225 isLoading = true
226 error = nil
227 cursor = nil
228 hasMore = true
229
230 do {
231 let page = try await fetchPage(cursor: nil)
232 tickets = page.results
233 cursor = page.cursor
234 hasMore = page.cursor != nil
235 } catch {
236 self.error = error.userFacingMessage
237 }
238
239 isLoading = false
240 }
241
242 func loadMoreIfNeeded(currentItem: TicketSummary) async {
243 guard let last = tickets.last,
244 last.id == currentItem.id,
245 hasMore,
246 !isLoadingMore else {
247 return
248 }
249
250 isLoadingMore = true
251
252 do {
253 let page = try await fetchPage(cursor: cursor)
254 tickets.append(contentsOf: page.results)
255 cursor = page.cursor
256 hasMore = page.cursor != nil
257 } catch {
258 self.error = error.userFacingMessage
259 }
260
261 isLoadingMore = false
262 }
263
264 func createTicket(subject: String, body: String) async -> TicketSummary? {
265 guard !isCreatingTicket else { return nil }
266
267 let trimmedSubject = subject.trimmingCharacters(in: .whitespacesAndNewlines)
268 guard !trimmedSubject.isEmpty else {
269 error = "Enter a ticket title."
270 return nil
271 }
272
273 isCreatingTicket = true
274 error = nil
275 defer { isCreatingTicket = false }
276
277 var input: [String: any Sendable] = [
278 "subject": trimmedSubject
279 ]
280 let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
281 if !trimmedBody.isEmpty {
282 input["body"] = trimmedBody
283 }
284 let variables: [String: any Sendable] = [
285 "trackerId": trackerId,
286 "input": input
287 ]
288
289 do {
290 let result = try await client.execute(
291 service: .todo,
292 query: Self.submitTicketMutation,
293 variables: variables,
294 responseType: SubmitTicketResponse.self
295 )
296 let ticket = result.submitTicket
297 tickets.insert(ticket, at: 0)
298 return ticket
299 } catch {
300 self.error = "Couldn’t create the ticket. \(error.userFacingMessage)"
301 return nil
302 }
303 }
304
305 func resolveTicket(_ ticket: TicketSummary) async {
306 let input: [String: any Sendable] = [
307 "status": TicketStatus.resolved.rawValue,
308 "resolution": TicketResolution.fixed.rawValue
309 ]
310 await performStatusUpdate(ticket: ticket, input: input)
311 }
312
313 func reopenTicket(_ ticket: TicketSummary) async {
314 let input: [String: any Sendable] = [
315 "status": TicketStatus.reported.rawValue
316 ]
317 await performStatusUpdate(ticket: ticket, input: input)
318 }
319
320 func assignToMe(ticket: TicketSummary, user: User) async {
321 guard !isPerformingAction else { return }
322 isPerformingAction = true
323 error = nil
324
325 let original = tickets
326 if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
327 let entity = Entity(canonicalName: user.canonicalName)
328 let updated = TicketSummary(
329 id: ticket.id,
330 title: ticket.title,
331 status: ticket.status,
332 resolution: ticket.resolution,
333 created: ticket.created,
334 submitter: ticket.submitter,
335 labels: ticket.labels,
336 assignees: ticket.assignees + [entity]
337 )
338 tickets[index] = updated
339 }
340
341 do {
342 _ = try await client.execute(
343 service: .todo,
344 query: Self.assignUserMutation,
345 variables: [
346 "trackerId": trackerId,
347 "ticketId": ticket.id,
348 "userId": user.id
349 ],
350 responseType: AssignmentMutationResponse.self
351 )
352 } catch {
353 tickets = original
354 self.error = error.userFacingMessage
355 }
356
357 isPerformingAction = false
358 }
359
360 func unassignFromMe(ticket: TicketSummary, user: User) async {
361 guard !isPerformingAction else { return }
362 isPerformingAction = true
363 error = nil
364
365 let original = tickets
366 if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
367 let filtered = ticket.assignees.filter { assignee in
368 !Self.matchesAssignee(assignee, user: user)
369 }
370 let updated = TicketSummary(
371 id: ticket.id,
372 title: ticket.title,
373 status: ticket.status,
374 resolution: ticket.resolution,
375 created: ticket.created,
376 submitter: ticket.submitter,
377 labels: ticket.labels,
378 assignees: filtered
379 )
380 tickets[index] = updated
381 }
382
383 do {
384 _ = try await client.execute(
385 service: .todo,
386 query: Self.unassignUserMutation,
387 variables: [
388 "trackerId": trackerId,
389 "ticketId": ticket.id,
390 "userId": user.id
391 ],
392 responseType: AssignmentMutationResponse.self
393 )
394 } catch {
395 tickets = original
396 self.error = error.userFacingMessage
397 }
398
399 isPerformingAction = false
400 }
401
402 func loadTrackerLabels() async {
403 do {
404 let result = try await client.execute(
405 service: .todo,
406 query: Self.trackerLabelsQuery,
407 variables: [
408 "owner": ownerUsername,
409 "tracker": trackerName
410 ],
411 responseType: TrackerLabelsResponse.self
412 )
413 trackerLabels = result.user.tracker.labels.results
414 } catch {
415 self.error = error.userFacingMessage
416 }
417 }
418
419 func labelTicket(_ ticket: TicketSummary, label: TicketLabel) async {
420 guard !isPerformingAction else { return }
421 isPerformingAction = true
422 error = nil
423
424 let original = tickets
425 if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
426 let updated = TicketSummary(
427 id: ticket.id,
428 title: ticket.title,
429 status: ticket.status,
430 resolution: ticket.resolution,
431 created: ticket.created,
432 submitter: ticket.submitter,
433 labels: ticket.labels + [label],
434 assignees: ticket.assignees
435 )
436 tickets[index] = updated
437 }
438
439 do {
440 _ = try await client.execute(
441 service: .todo,
442 query: Self.labelTicketMutation,
443 variables: [
444 "trackerId": trackerId,
445 "ticketId": ticket.id,
446 "labelId": label.id
447 ],
448 responseType: LabelMutationResponse.self
449 )
450 } catch {
451 tickets = original
452 self.error = error.userFacingMessage
453 }
454
455 isPerformingAction = false
456 }
457
458 func unlabelTicket(_ ticket: TicketSummary, label: TicketLabel) async {
459 guard !isPerformingAction else { return }
460 isPerformingAction = true
461 error = nil
462
463 let original = tickets
464 if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
465 let filtered = ticket.labels.filter { $0.id != label.id }
466 let updated = TicketSummary(
467 id: ticket.id,
468 title: ticket.title,
469 status: ticket.status,
470 resolution: ticket.resolution,
471 created: ticket.created,
472 submitter: ticket.submitter,
473 labels: filtered,
474 assignees: ticket.assignees
475 )
476 tickets[index] = updated
477 }
478
479 do {
480 _ = try await client.execute(
481 service: .todo,
482 query: Self.unlabelTicketMutation,
483 variables: [
484 "trackerId": trackerId,
485 "ticketId": ticket.id,
486 "labelId": label.id
487 ],
488 responseType: LabelMutationResponse.self
489 )
490 } catch {
491 tickets = original
492 self.error = error.userFacingMessage
493 }
494
495 isPerformingAction = false
496 }
497
498 func ticket(withId ticketId: Int) -> TicketSummary? {
499 tickets.first(where: { $0.id == ticketId })
500 }
501
502 // MARK: - Private
503
504 private func performStatusUpdate(ticket: TicketSummary, input: [String: any Sendable]) async {
505 guard !isPerformingAction else { return }
506 isPerformingAction = true
507 error = nil
508
509 do {
510 let variables: [String: any Sendable] = [
511 "trackerId": trackerId,
512 "ticketId": ticket.id,
513 "input": input
514 ]
515 let result = try await client.execute(
516 service: .todo,
517 query: Self.updateStatusMutation,
518 variables: variables,
519 responseType: UpdateStatusResponse.self
520 )
521 _ = result.updateTicketStatus
522 if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
523 tickets[index] = updatedTicket(from: ticket, input: input)
524 }
525 } catch {
526 self.error = error.userFacingMessage
527 }
528
529 isPerformingAction = false
530 }
531
532 private func fetchPage(cursor: String?) async throws -> TicketsPage {
533 var variables: [String: any Sendable] = [
534 "owner": ownerUsername,
535 "tracker": trackerName
536 ]
537 if let cursor {
538 variables["cursor"] = cursor
539 }
540 let result = try await client.execute(
541 service: .todo,
542 query: Self.query,
543 variables: variables,
544 responseType: TrackerTicketsResponse.self
545 )
546 return result.user.tracker.tickets
547 }
548
549 private struct SubmitTicketResponse: Decodable, Sendable {
550 let submitTicket: TicketSummary
551 }
552
553 private static func matchesAssignee(_ entity: Entity, user: User) -> Bool {
554 let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
555 let userCanonical = normalizedCanonicalName(user.canonicalName)
556 if assigneeCanonical == userCanonical {
557 return true
558 }
559 return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username)
560 }
561
562 private static func normalizedCanonicalName(_ value: String) -> String {
563 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
564 if trimmed.hasPrefix("~") {
565 return trimmed
566 }
567 return "~\(trimmed)"
568 }
569
570 private static func normalizedUsername(_ value: String) -> String {
571 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
572 return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
573 }
574
575 private func updatedTicket(from ticket: TicketSummary, input: [String: any Sendable]) -> TicketSummary {
576 let updatedStatus = (input["status"] as? String).flatMap(TicketStatus.init(rawValue:)) ?? ticket.status
577 let updatedResolution = (input["resolution"] as? String).flatMap(TicketResolution.init(rawValue:))
578
579 return TicketSummary(
580 id: ticket.id,
581 title: ticket.title,
582 status: updatedStatus,
583 resolution: updatedStatus == .resolved ? updatedResolution : nil,
584 created: ticket.created,
585 submitter: ticket.submitter,
586 labels: ticket.labels,
587 assignees: ticket.assignees
588 )
589 }
590}