krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.1.3: 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 "ticket.detail.\(ownerUsername).\(trackerRid).\(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 var commentText = ""
118 var error: String?
119
120 private let client: SRHTClient
121
122 private static func timelineOrder(lhs: TicketEvent, rhs: TicketEvent) -> Bool {
123 if lhs.created == rhs.created {
124 return lhs.id < rhs.id
125 }
126 return lhs.created < rhs.created
127 }
128
129 static func statusUpdateInput(
130 status: TicketStatus,
131 resolution: TicketResolution?
132 ) -> [String: any Sendable] {
133 var input: [String: any Sendable] = [
134 "status": status.rawValue
135 ]
136 if status == .resolved, let resolution {
137 input["resolution"] = resolution.rawValue
138 }
139 return input
140 }
141
142 init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, ticketId: Int, client: SRHTClient) {
143 self.ownerUsername = ownerUsername
144 self.trackerName = trackerName
145 self.trackerId = trackerId
146 self.trackerRid = trackerRid
147 self.ticketId = ticketId
148 self.client = client
149 }
150
151 // MARK: - Queries
152
153 private static let detailQuery = """
154 query ticket($rid: ID!, $ticketId: Int!) {
155 tracker(rid: $rid) {
156 ticket(id: $ticketId) {
157 id
158 created
159 updated
160 title: subject
161 description: body
162 status
163 resolution
164 authenticity
165 submitter { canonicalName }
166 assignees { canonicalName }
167 labels { id name backgroundColor foregroundColor }
168 events {
169 results {
170 id
171 created
172 changes {
173 eventType
174 ... on Comment {
175 author { canonicalName }
176 text
177 authenticity
178 }
179 ... on StatusChange {
180 oldStatus
181 newStatus
182 }
183 ... on LabelUpdate {
184 labeler { canonicalName }
185 label { name }
186 }
187 ... on Assignment {
188 assigner { canonicalName }
189 assignee { canonicalName }
190 }
191 ... on TicketMention {
192 mentioned { id }
193 }
194 ... on UserMention {
195 mentioned { canonicalName }
196 }
197 ... on Created {
198 author { canonicalName }
199 }
200 }
201 }
202 cursor
203 }
204 }
205 }
206 }
207 """
208
209 private static let submitCommentMutation = """
210 mutation submitComment($trackerId: Int!, $ticketId: Int!, $input: SubmitCommentInput!) {
211 submitComment(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
212 id
213 created
214 changes {
215 eventType
216 ... on Comment {
217 author { canonicalName }
218 text
219 authenticity
220 }
221 }
222 }
223 }
224 """
225
226 private static let updateStatusMutation = """
227 mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) {
228 updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
229 eventType: __typename
230 }
231 }
232 """
233
234 private static let assignUserMutation = """
235 mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
236 assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
237 }
238 """
239
240 private static let unassignUserMutation = """
241 mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
242 unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
243 }
244 """
245
246 private static let labelTicketMutation = """
247 mutation labelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
248 labelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
249 }
250 """
251
252 private static let unlabelTicketMutation = """
253 mutation unlabelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
254 unlabelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
255 }
256 """
257
258 private static let userLookupQuery = """
259 query userLookup($username: String!) {
260 user(username: $username) { id }
261 }
262 """
263
264 private static let trackerLabelsQuery = """
265 query trackerLabels($rid: ID!) {
266 tracker(rid: $rid) {
267 labels {
268 results { id name backgroundColor foregroundColor }
269 }
270 }
271 }
272 """
273
274 private static let createLabelMutation = """
275 mutation createLabel($trackerId: Int!, $name: String!, $backgroundColor: String!, $foregroundColor: String!) {
276 createLabel(trackerId: $trackerId, name: $name, backgroundColor: $backgroundColor, foregroundColor: $foregroundColor) {
277 id
278 name
279 backgroundColor
280 foregroundColor
281 }
282 }
283 """
284
285 // MARK: - Public API
286
287 func loadTicket() async {
288 guard !isLoading else { return }
289 isLoading = true
290 error = nil
291 rawTicketResponse = nil
292
293 do {
294 let result = try await client.execute(
295 service: .todo,
296 query: Self.detailQuery,
297 variables: [
298 "rid": trackerRid,
299 "ticketId": ticketId
300 ],
301 responseType: TicketDetailResponse.self
302 )
303 let payload = result.tracker.ticket
304 ticket = TicketDetail(
305 id: payload.id,
306 created: payload.created,
307 updated: payload.updated,
308 title: payload.title,
309 description: payload.description,
310 status: payload.status,
311 resolution: payload.resolution,
312 authenticity: payload.authenticity,
313 submitter: payload.submitter,
314 assignees: payload.assignees,
315 labels: payload.labels
316 )
317 events = payload.events.results.sorted(by: Self.timelineOrder)
318 } catch {
319 self.error = error.userFacingMessage
320 }
321
322 isLoading = false
323 }
324
325 func loadTicketWithDebugCapture() async {
326 guard !isLoading else { return }
327 isLoading = true
328 error = nil
329
330 do {
331 let cacheKey = Self.cacheKey(ownerUsername: ownerUsername, trackerRid: trackerRid, ticketId: ticketId)
332 let result = try await client.executeAndCache(
333 service: .todo,
334 query: Self.detailQuery,
335 variables: [
336 "rid": trackerRid,
337 "ticketId": ticketId
338 ],
339 responseType: TicketDetailResponse.self,
340 cacheKey: cacheKey
341 )
342 rawTicketResponse = client.responseCache.get(forKey: cacheKey)
343 .flatMap { String(data: $0, encoding: .utf8) }
344 let payload = result.tracker.ticket
345 ticket = TicketDetail(
346 id: payload.id,
347 created: payload.created,
348 updated: payload.updated,
349 title: payload.title,
350 description: payload.description,
351 status: payload.status,
352 resolution: payload.resolution,
353 authenticity: payload.authenticity,
354 submitter: payload.submitter,
355 assignees: payload.assignees,
356 labels: payload.labels
357 )
358 events = payload.events.results.sorted(by: Self.timelineOrder)
359 } catch {
360 self.error = error.userFacingMessage
361 }
362
363 isLoading = false
364 }
365
366 func submitComment() async {
367 let text = commentText.trimmingCharacters(in: .whitespacesAndNewlines)
368 guard !text.isEmpty, !isSubmitting else { return }
369 isSubmitting = true
370 error = nil
371
372 do {
373 let input: [String: any Sendable] = ["text": text]
374 let result = try await client.execute(
375 service: .todo,
376 query: Self.submitCommentMutation,
377 variables: [
378 "trackerId": trackerId,
379 "ticketId": ticketId,
380 "input": input
381 ],
382 responseType: SubmitCommentResponse.self
383 )
384 // Append the returned event so the comment shows immediately.
385 let submitted = result.submitComment
386 let event = TicketEvent(
387 id: submitted.id,
388 created: submitted.created,
389 changes: submitted.changes
390 )
391 events.append(event)
392 events.sort(by: Self.timelineOrder)
393 commentText = ""
394 } catch {
395 self.error = error.userFacingMessage
396 }
397
398 isSubmitting = false
399 }
400
401 func updateComment(commentId: Int, text: String) async {
402 _ = commentId
403 _ = text
404 error = "Comment editing is not available in todo.sr.ht's public GraphQL API."
405 }
406
407 // MARK: - Ticket Actions
408
409 func updateStatus(status: TicketStatus, resolution: TicketResolution? = nil) async {
410 guard !isPerformingAction else { return }
411 isPerformingAction = true
412 error = nil
413
414 do {
415 let input = Self.statusUpdateInput(status: status, resolution: resolution)
416 _ = try await client.execute(
417 service: .todo,
418 query: Self.updateStatusMutation,
419 variables: [
420 "trackerId": trackerId,
421 "ticketId": ticketId,
422 "input": input
423 ],
424 responseType: UpdateStatusResponse.self
425 )
426 // Re-fetch the ticket to get updated status/resolution
427 await reloadTicketPreservingDebugState()
428 } catch {
429 self.error = error.userFacingMessage
430 }
431
432 isPerformingAction = false
433 }
434
435 func assignUser(username: String) async {
436 guard !isPerformingAction else { return }
437 isPerformingAction = true
438 error = nil
439
440 do {
441 // Resolve username to user ID
442 let userResult = try await client.execute(
443 service: .todo,
444 query: Self.userLookupQuery,
445 variables: ["username": username],
446 responseType: UserLookupResponse.self
447 )
448 let userId = userResult.user.id
449
450 _ = try await client.execute(
451 service: .todo,
452 query: Self.assignUserMutation,
453 variables: [
454 "trackerId": trackerId,
455 "ticketId": ticketId,
456 "userId": userId
457 ],
458 responseType: AssignUserResponse.self
459 )
460 // Reload to reflect the change
461 await reloadTicketPreservingDebugState()
462 } catch {
463 self.error = error.userFacingMessage
464 }
465
466 isPerformingAction = false
467 }
468
469 func assignToCurrentUser(_ user: User) async {
470 guard !isPerformingAction, let currentTicket = ticket else { return }
471
472 let currentAssignees = currentTicket.assignees
473 let currentEntity = Entity(canonicalName: user.canonicalName)
474 guard !currentAssignees.contains(where: { Self.matchesAssignee($0, user: user) }) else {
475 return
476 }
477
478 isPerformingAction = true
479 error = nil
480
481 ticket = TicketDetail(
482 id: currentTicket.id,
483 created: currentTicket.created,
484 updated: currentTicket.updated,
485 title: currentTicket.title,
486 description: currentTicket.description,
487 status: currentTicket.status,
488 resolution: currentTicket.resolution,
489 authenticity: currentTicket.authenticity,
490 submitter: currentTicket.submitter,
491 assignees: currentAssignees + [currentEntity],
492 labels: currentTicket.labels
493 )
494
495 do {
496 _ = try await client.execute(
497 service: .todo,
498 query: Self.assignUserMutation,
499 variables: [
500 "trackerId": trackerId,
501 "ticketId": ticketId,
502 "userId": user.id
503 ],
504 responseType: AssignUserResponse.self
505 )
506 await reloadTicketPreservingDebugState()
507 } catch {
508 ticket = TicketDetail(
509 id: currentTicket.id,
510 created: currentTicket.created,
511 updated: currentTicket.updated,
512 title: currentTicket.title,
513 description: currentTicket.description,
514 status: currentTicket.status,
515 resolution: currentTicket.resolution,
516 authenticity: currentTicket.authenticity,
517 submitter: currentTicket.submitter,
518 assignees: currentAssignees,
519 labels: currentTicket.labels
520 )
521 self.error = error.userFacingMessage
522 }
523
524 isPerformingAction = false
525 }
526
527 func unassignUser(username: String) async {
528 guard !isPerformingAction else { return }
529 isPerformingAction = true
530 error = nil
531
532 do {
533 // Resolve username to user ID
534 let stripped = username.hasPrefix("~") ? String(username.dropFirst()) : username
535 let userResult = try await client.execute(
536 service: .todo,
537 query: Self.userLookupQuery,
538 variables: ["username": stripped],
539 responseType: UserLookupResponse.self
540 )
541 let userId = userResult.user.id
542
543 _ = try await client.execute(
544 service: .todo,
545 query: Self.unassignUserMutation,
546 variables: [
547 "trackerId": trackerId,
548 "ticketId": ticketId,
549 "userId": userId
550 ],
551 responseType: UnassignUserResponse.self
552 )
553 // Reload to reflect the change
554 await reloadTicketPreservingDebugState()
555 } catch {
556 self.error = error.userFacingMessage
557 }
558
559 isPerformingAction = false
560 }
561
562 func labelTicket(labelId: Int) async {
563 guard !isPerformingAction else { return }
564 isPerformingAction = true
565 error = nil
566
567 do {
568 _ = try await client.execute(
569 service: .todo,
570 query: Self.labelTicketMutation,
571 variables: [
572 "trackerId": trackerId,
573 "ticketId": ticketId,
574 "labelId": labelId
575 ],
576 responseType: LabelTicketResponse.self
577 )
578 await reloadTicketPreservingDebugState()
579 } catch {
580 self.error = error.userFacingMessage
581 }
582
583 isPerformingAction = false
584 }
585
586 func unlabelTicket(labelId: Int) async {
587 guard !isPerformingAction else { return }
588 isPerformingAction = true
589 error = nil
590
591 do {
592 _ = try await client.execute(
593 service: .todo,
594 query: Self.unlabelTicketMutation,
595 variables: [
596 "trackerId": trackerId,
597 "ticketId": ticketId,
598 "labelId": labelId
599 ],
600 responseType: UnlabelTicketResponse.self
601 )
602 await reloadTicketPreservingDebugState()
603 } catch {
604 self.error = error.userFacingMessage
605 }
606
607 isPerformingAction = false
608 }
609
610 func loadTrackerLabels() async {
611 do {
612 let result = try await client.execute(
613 service: .todo,
614 query: Self.trackerLabelsQuery,
615 variables: ["rid": trackerRid],
616 responseType: TrackerLabelsResponse.self
617 )
618 trackerLabels = result.tracker.labels.results
619 } catch {
620 self.error = error.userFacingMessage
621 }
622 }
623
624 func createLabel(name: String, backgroundColor: String, foregroundColor: String) async {
625 guard !isPerformingAction else { return }
626 isPerformingAction = true
627 error = nil
628
629 do {
630 let result = try await client.execute(
631 service: .todo,
632 query: Self.createLabelMutation,
633 variables: [
634 "trackerId": trackerId,
635 "name": name,
636 "backgroundColor": backgroundColor,
637 "foregroundColor": foregroundColor
638 ],
639 responseType: CreateLabelResponse.self
640 )
641 trackerLabels.append(result.createLabel)
642 } catch {
643 self.error = error.userFacingMessage
644 }
645
646 isPerformingAction = false
647 }
648
649 private func reloadTicketPreservingDebugState() async {
650 if rawTicketResponse != nil {
651 await loadTicketWithDebugCapture()
652 } else {
653 await loadTicket()
654 }
655 }
656
657 static func matchesAssignee(_ entity: Entity, user: User) -> Bool {
658 let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
659 let userCanonical = normalizedCanonicalName(user.canonicalName)
660 if assigneeCanonical == userCanonical {
661 return true
662 }
663 return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username)
664 }
665
666 private static func normalizedCanonicalName(_ value: String) -> String {
667 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
668 if trimmed.hasPrefix("~") {
669 return trimmed
670 }
671 return "~\(trimmed)"
672 }
673
674 private static func normalizedUsername(_ value: String) -> String {
675 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
676 return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
677 }
678
679}