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