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