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