krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.3.1: Hutch/Views/Tickets/TicketDetailViewModel.swift · raw
1import Foundation
2
3// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
4
5private struct TicketDetailResponse: Decodable, Sendable {
6 let tracker: TrackerTicketWrapper
7}
8
9private struct TrackerTicketWrapper: Decodable, Sendable {
10 let ticket: TicketDetailPayload
11}
12
13private struct TicketDetailPayload: Decodable, Sendable {
14 let id: Int
15 let created: Date
16 let updated: Date
17 let title: String
18 let description: String?
19 let status: TicketStatus
20 let resolution: TicketResolution?
21 let authenticity: Authenticity
22 let submitter: Entity
23 let assignees: [Entity]
24 let labels: [TicketLabel]
25 let events: EventsPage
26}
27
28private struct EventsPage: Decodable, Sendable {
29 let results: [TicketEvent]
30 let cursor: String?
31}
32
33private struct SubmitCommentResponse: Decodable, Sendable {
34 let submitComment: SubmittedEvent
35}
36
37private struct SubmittedEvent: Decodable, Sendable {
38 let id: Int
39 let created: Date
40 let changes: [EventChange]
41}
42
43private struct MutationEventResponse: Decodable, Sendable {
44 let id: Int
45}
46
47private struct UpdateStatusResponse: Decodable, Sendable {
48 let updateTicketStatus: UpdatedStatusEvent
49}
50
51private struct UpdatedStatusEvent: Decodable, Sendable {
52 let eventType: String
53}
54
55private struct AssignUserResponse: Decodable, Sendable {
56 let assignUser: MutationEventResponse
57}
58
59private struct UnassignUserResponse: Decodable, Sendable {
60 let unassignUser: MutationEventResponse
61}
62
63private struct LabelTicketResponse: Decodable, Sendable {
64 let labelTicket: MutationEventResponse
65}
66
67private struct UnlabelTicketResponse: Decodable, Sendable {
68 let unlabelTicket: MutationEventResponse
69}
70
71private struct UserLookupResponse: Decodable, Sendable {
72 let user: UserIdPayload
73}
74
75private struct UserIdPayload: Decodable, Sendable {
76 let id: Int
77}
78
79private struct CreateLabelResponse: Decodable, Sendable {
80 let createLabel: TicketLabel
81}
82
83private struct TrackerLabelsResponse: Decodable, Sendable {
84 let tracker: TrackerLabelsWrapper
85}
86
87private struct TrackerLabelsWrapper: Decodable, Sendable {
88 let labels: LabelsPage
89}
90
91private struct LabelsPage: Decodable, Sendable {
92 let results: [TicketLabel]
93}
94
95// MARK: - View Model
96
97@Observable
98@MainActor
99final class TicketDetailViewModel {
100 private static func cacheKey(ownerUsername: String, trackerRid: String, ticketId: Int) -> String {
101 APICacheKeys.ticketDetail(owner: ownerUsername, trackerRid: trackerRid, ticketId: ticketId)
102 }
103
104 let ownerUsername: String
105 let trackerName: String
106 let trackerId: Int
107 let trackerRid: String
108 let ticketId: Int
109
110 private(set) var ticket: TicketDetail?
111 private(set) var events: [TicketEvent] = []
112 private(set) var isLoading = false
113 private(set) var isSubmitting = false
114 private(set) var isPerformingAction = false
115 private(set) var trackerLabels: [TicketLabel] = []
116 private(set) var rawTicketResponse: String?
117 private(set) var cacheMetadata: CacheEntryMetadata?
118 private(set) var isRefreshingCachedData = false
119 var commentText = ""
120 var error: String?
121
122 private let client: SRHTClient
123
124 private static func timelineOrder(lhs: TicketEvent, rhs: TicketEvent) -> Bool {
125 if lhs.created == rhs.created {
126 return lhs.id < rhs.id
127 }
128 return lhs.created < rhs.created
129 }
130
131 static func statusUpdateInput(
132 status: TicketStatus,
133 resolution: TicketResolution?
134 ) -> [String: any Sendable] {
135 var input: [String: any Sendable] = [
136 "status": status.rawValue
137 ]
138 if status == .resolved, let resolution {
139 input["resolution"] = resolution.rawValue
140 }
141 return input
142 }
143
144 init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, ticketId: Int, client: SRHTClient) {
145 self.ownerUsername = ownerUsername
146 self.trackerName = trackerName
147 self.trackerId = trackerId
148 self.trackerRid = trackerRid
149 self.ticketId = ticketId
150 self.client = client
151 }
152
153 // MARK: - Queries
154
155 private static let detailQuery = """
156 query ticket($rid: ID!, $ticketId: Int!) {
157 tracker(rid: $rid) {
158 ticket(id: $ticketId) {
159 id
160 created
161 updated
162 title: subject
163 description: body
164 status
165 resolution
166 authenticity
167 submitter { canonicalName }
168 assignees { canonicalName }
169 labels { id name backgroundColor foregroundColor }
170 events {
171 results {
172 id
173 created
174 changes {
175 eventType
176 ... on Comment {
177 author { canonicalName }
178 text
179 authenticity
180 }
181 ... on StatusChange {
182 oldStatus
183 newStatus
184 }
185 ... on LabelUpdate {
186 labeler { canonicalName }
187 label { name }
188 }
189 ... on Assignment {
190 assigner { canonicalName }
191 assignee { canonicalName }
192 }
193 ... on TicketMention {
194 mentioned { id }
195 }
196 ... on UserMention {
197 mentioned { canonicalName }
198 }
199 ... on Created {
200 author { canonicalName }
201 }
202 }
203 }
204 cursor
205 }
206 }
207 }
208 }
209 """
210
211 private static let submitCommentMutation = """
212 mutation submitComment($trackerId: Int!, $ticketId: Int!, $input: SubmitCommentInput!) {
213 submitComment(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
214 id
215 created
216 changes {
217 eventType
218 ... on Comment {
219 author { canonicalName }
220 text
221 authenticity
222 }
223 }
224 }
225 }
226 """
227
228 private static let updateStatusMutation = """
229 mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) {
230 updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
231 eventType: __typename
232 }
233 }
234 """
235
236 private static let assignUserMutation = """
237 mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
238 assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
239 }
240 """
241
242 private static let unassignUserMutation = """
243 mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
244 unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
245 }
246 """
247
248 private static let labelTicketMutation = """
249 mutation labelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
250 labelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
251 }
252 """
253
254 private static let unlabelTicketMutation = """
255 mutation unlabelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
256 unlabelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
257 }
258 """
259
260 private static let userLookupQuery = """
261 query userLookup($username: String!) {
262 user(username: $username) { id }
263 }
264 """
265
266 private static let trackerLabelsQuery = """
267 query trackerLabels($rid: ID!) {
268 tracker(rid: $rid) {
269 labels {
270 results { id name backgroundColor foregroundColor }
271 }
272 }
273 }
274 """
275
276 private static let createLabelMutation = """
277 mutation createLabel($trackerId: Int!, $name: String!, $backgroundColor: String!, $foregroundColor: String!) {
278 createLabel(trackerId: $trackerId, name: $name, backgroundColor: $backgroundColor, foregroundColor: $foregroundColor) {
279 id
280 name
281 backgroundColor
282 foregroundColor
283 }
284 }
285 """
286
287 // MARK: - Public API
288
289 func loadTicket() async {
290 guard !isLoading else { return }
291 isLoading = true
292 error = nil
293 rawTicketResponse = nil
294
295 do {
296 let result = try await client.executeCached(
297 service: .todo,
298 query: Self.detailQuery,
299 variables: [
300 "rid": trackerRid,
301 "ticketId": ticketId
302 ],
303 responseType: TicketDetailResponse.self,
304 cacheKey: Self.cacheKey(ownerUsername: ownerUsername, trackerRid: trackerRid, ticketId: ticketId),
305 resourceType: .ticketDetail,
306 ttl: APICacheTTLs.ticketDetail,
307 policy: .cacheFirstThenRefresh
308 )
309 apply(result.value, metadata: result.metadata)
310 if result.isFromCache {
311 isLoading = false
312 await refreshTicketInBackground()
313 return
314 }
315 } catch {
316 self.error = error.userFacingMessage
317 }
318
319 isLoading = false
320 }
321
322 func loadTicketWithDebugCapture() async {
323 guard !isLoading else { return }
324 isLoading = true
325 error = nil
326
327 do {
328 let cacheKey = Self.cacheKey(ownerUsername: ownerUsername, trackerRid: trackerRid, ticketId: ticketId)
329 let result = try await client.executeCached(
330 service: .todo,
331 query: Self.detailQuery,
332 variables: [
333 "rid": trackerRid,
334 "ticketId": ticketId
335 ],
336 responseType: TicketDetailResponse.self,
337 cacheKey: cacheKey,
338 resourceType: .ticketDetail,
339 ttl: APICacheTTLs.ticketDetail,
340 policy: .refreshIgnoringCache
341 )
342 rawTicketResponse = await client.cachedPayload(forKey: cacheKey)
343 .flatMap { String(data: $0, encoding: .utf8) }
344 apply(result.value, metadata: result.metadata)
345 } catch {
346 self.error = error.userFacingMessage
347 }
348
349 isLoading = false
350 }
351
352 func submitComment() async {
353 let text = commentText.trimmingCharacters(in: .whitespacesAndNewlines)
354 guard !text.isEmpty, !isSubmitting else { return }
355 isSubmitting = true
356 error = nil
357
358 do {
359 let input: [String: any Sendable] = ["text": text]
360 let result = try await client.execute(
361 service: .todo,
362 query: Self.submitCommentMutation,
363 variables: [
364 "trackerId": trackerId,
365 "ticketId": ticketId,
366 "input": input
367 ],
368 responseType: SubmitCommentResponse.self
369 )
370 // Append the returned event so the comment shows immediately.
371 let submitted = result.submitComment
372 let event = TicketEvent(
373 id: submitted.id,
374 created: submitted.created,
375 changes: submitted.changes
376 )
377 events.append(event)
378 events.sort(by: Self.timelineOrder)
379 commentText = ""
380 await invalidateAfterMutation()
381 } catch {
382 self.error = error.userFacingMessage
383 }
384
385 isSubmitting = false
386 }
387
388 func updateComment(commentId: Int, text: String) async {
389 _ = commentId
390 _ = text
391 error = "Comment editing is not available in todo.sr.ht's public GraphQL API."
392 }
393
394 // MARK: - Ticket Actions
395
396 func updateStatus(status: TicketStatus, resolution: TicketResolution? = nil) async {
397 guard !isPerformingAction else { return }
398 isPerformingAction = true
399 error = nil
400
401 do {
402 let input = Self.statusUpdateInput(status: status, resolution: resolution)
403 _ = try await client.execute(
404 service: .todo,
405 query: Self.updateStatusMutation,
406 variables: [
407 "trackerId": trackerId,
408 "ticketId": ticketId,
409 "input": input
410 ],
411 responseType: UpdateStatusResponse.self
412 )
413 await invalidateAfterMutation()
414 // Re-fetch the ticket to get updated status/resolution
415 await reloadTicketPreservingDebugState()
416 } catch {
417 self.error = error.userFacingMessage
418 }
419
420 isPerformingAction = false
421 }
422
423 func assignUser(username: String) async {
424 guard !isPerformingAction else { return }
425 isPerformingAction = true
426 error = nil
427
428 do {
429 // Resolve username to user ID
430 let userResult = try await client.execute(
431 service: .todo,
432 query: Self.userLookupQuery,
433 variables: ["username": username],
434 responseType: UserLookupResponse.self
435 )
436 let userId = userResult.user.id
437
438 _ = try await client.execute(
439 service: .todo,
440 query: Self.assignUserMutation,
441 variables: [
442 "trackerId": trackerId,
443 "ticketId": ticketId,
444 "userId": userId
445 ],
446 responseType: AssignUserResponse.self
447 )
448 await invalidateAfterMutation()
449 // Reload to reflect the change
450 await reloadTicketPreservingDebugState()
451 } catch {
452 self.error = error.userFacingMessage
453 }
454
455 isPerformingAction = false
456 }
457
458 func assignToCurrentUser(_ user: User) async {
459 guard !isPerformingAction, let currentTicket = ticket else { return }
460
461 let currentAssignees = currentTicket.assignees
462 let currentEntity = Entity(canonicalName: user.canonicalName)
463 guard !currentAssignees.contains(where: { Self.matchesAssignee($0, user: user) }) else {
464 return
465 }
466
467 isPerformingAction = true
468 error = nil
469
470 ticket = TicketDetail(
471 id: currentTicket.id,
472 created: currentTicket.created,
473 updated: currentTicket.updated,
474 title: currentTicket.title,
475 description: currentTicket.description,
476 status: currentTicket.status,
477 resolution: currentTicket.resolution,
478 authenticity: currentTicket.authenticity,
479 submitter: currentTicket.submitter,
480 assignees: currentAssignees + [currentEntity],
481 labels: currentTicket.labels
482 )
483
484 do {
485 _ = try await client.execute(
486 service: .todo,
487 query: Self.assignUserMutation,
488 variables: [
489 "trackerId": trackerId,
490 "ticketId": ticketId,
491 "userId": user.id
492 ],
493 responseType: AssignUserResponse.self
494 )
495 await invalidateAfterMutation()
496 await reloadTicketPreservingDebugState()
497 } catch {
498 ticket = TicketDetail(
499 id: currentTicket.id,
500 created: currentTicket.created,
501 updated: currentTicket.updated,
502 title: currentTicket.title,
503 description: currentTicket.description,
504 status: currentTicket.status,
505 resolution: currentTicket.resolution,
506 authenticity: currentTicket.authenticity,
507 submitter: currentTicket.submitter,
508 assignees: currentAssignees,
509 labels: currentTicket.labels
510 )
511 self.error = error.userFacingMessage
512 }
513
514 isPerformingAction = false
515 }
516
517 func unassignUser(username: String) async {
518 guard !isPerformingAction else { return }
519 isPerformingAction = true
520 error = nil
521
522 do {
523 // Resolve username to user ID
524 let stripped = username.hasPrefix("~") ? String(username.dropFirst()) : username
525 let userResult = try await client.execute(
526 service: .todo,
527 query: Self.userLookupQuery,
528 variables: ["username": stripped],
529 responseType: UserLookupResponse.self
530 )
531 let userId = userResult.user.id
532
533 _ = try await client.execute(
534 service: .todo,
535 query: Self.unassignUserMutation,
536 variables: [
537 "trackerId": trackerId,
538 "ticketId": ticketId,
539 "userId": userId
540 ],
541 responseType: UnassignUserResponse.self
542 )
543 await invalidateAfterMutation()
544 // Reload to reflect the change
545 await reloadTicketPreservingDebugState()
546 } catch {
547 self.error = error.userFacingMessage
548 }
549
550 isPerformingAction = false
551 }
552
553 func labelTicket(labelId: Int) async {
554 guard !isPerformingAction else { return }
555 isPerformingAction = true
556 error = nil
557
558 do {
559 _ = try await client.execute(
560 service: .todo,
561 query: Self.labelTicketMutation,
562 variables: [
563 "trackerId": trackerId,
564 "ticketId": ticketId,
565 "labelId": labelId
566 ],
567 responseType: LabelTicketResponse.self
568 )
569 await invalidateAfterMutation()
570 await reloadTicketPreservingDebugState()
571 } catch {
572 self.error = error.userFacingMessage
573 }
574
575 isPerformingAction = false
576 }
577
578 func unlabelTicket(labelId: Int) async {
579 guard !isPerformingAction else { return }
580 isPerformingAction = true
581 error = nil
582
583 do {
584 _ = try await client.execute(
585 service: .todo,
586 query: Self.unlabelTicketMutation,
587 variables: [
588 "trackerId": trackerId,
589 "ticketId": ticketId,
590 "labelId": labelId
591 ],
592 responseType: UnlabelTicketResponse.self
593 )
594 await invalidateAfterMutation()
595 await reloadTicketPreservingDebugState()
596 } catch {
597 self.error = error.userFacingMessage
598 }
599
600 isPerformingAction = false
601 }
602
603 func loadTrackerLabels() async {
604 do {
605 let result = try await client.execute(
606 service: .todo,
607 query: Self.trackerLabelsQuery,
608 variables: ["rid": trackerRid],
609 responseType: TrackerLabelsResponse.self
610 )
611 trackerLabels = result.tracker.labels.results
612 } catch {
613 self.error = error.userFacingMessage
614 }
615 }
616
617 func createLabel(name: String, backgroundColor: String, foregroundColor: String) async {
618 guard !isPerformingAction else { return }
619 isPerformingAction = true
620 error = nil
621
622 do {
623 let result = try await client.execute(
624 service: .todo,
625 query: Self.createLabelMutation,
626 variables: [
627 "trackerId": trackerId,
628 "name": name,
629 "backgroundColor": backgroundColor,
630 "foregroundColor": foregroundColor
631 ],
632 responseType: CreateLabelResponse.self
633 )
634 trackerLabels.append(result.createLabel)
635 } catch {
636 self.error = error.userFacingMessage
637 }
638
639 isPerformingAction = false
640 }
641
642 private func reloadTicketPreservingDebugState() async {
643 if rawTicketResponse != nil {
644 await loadTicketWithDebugCapture()
645 } else {
646 await loadTicket()
647 }
648 }
649
650 private func refreshTicketInBackground() async {
651 guard !isRefreshingCachedData else { return }
652 isRefreshingCachedData = true
653 defer { isRefreshingCachedData = false }
654
655 do {
656 let result = try await client.executeCached(
657 service: .todo,
658 query: Self.detailQuery,
659 variables: [
660 "rid": trackerRid,
661 "ticketId": ticketId
662 ],
663 responseType: TicketDetailResponse.self,
664 cacheKey: Self.cacheKey(ownerUsername: ownerUsername, trackerRid: trackerRid, ticketId: ticketId),
665 resourceType: .ticketDetail,
666 ttl: APICacheTTLs.ticketDetail,
667 policy: .refreshIgnoringCache
668 )
669 apply(result.value, metadata: result.metadata)
670 } catch {
671 if ticket == nil {
672 self.error = error.userFacingMessage
673 }
674 }
675 }
676
677 private func apply(_ response: TicketDetailResponse, metadata: CacheEntryMetadata?) {
678 cacheMetadata = metadata
679 let payload = response.tracker.ticket
680 let updatedTicket = TicketDetail(
681 id: payload.id,
682 created: payload.created,
683 updated: payload.updated,
684 title: payload.title,
685 description: payload.description,
686 status: payload.status,
687 resolution: payload.resolution,
688 authenticity: payload.authenticity,
689 submitter: payload.submitter,
690 assignees: payload.assignees,
691 labels: payload.labels
692 )
693 ticket = updatedTicket
694 let updatedEvents = payload.events.results.sorted(by: Self.timelineOrder)
695 events = updatedEvents
696 }
697
698 private func invalidateAfterMutation() async {
699 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "ticket"))
700 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tickets"))
701 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tracker"))
702 await client.invalidateCache(prefix: APICacheKeys.prefix("home"))
703 }
704
705 static func matchesAssignee(_ entity: Entity, user: User) -> Bool {
706 let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
707 let userCanonical = normalizedCanonicalName(user.canonicalName)
708 if assigneeCanonical == userCanonical {
709 return true
710 }
711 return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username)
712 }
713
714 private static func normalizedCanonicalName(_ value: String) -> String {
715 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
716 if trimmed.hasPrefix("~") {
717 return trimmed
718 }
719 return "~\(trimmed)"
720 }
721
722 private static func normalizedUsername(_ value: String) -> String {
723 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
724 return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
725 }
726
727}