Hutch/Views/Tickets/TicketDetailViewModel.swift
551 lines · 17101 bytes
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 init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, ticketId: Int, client: SRHTClient) {
134 self.ownerUsername = ownerUsername
135 self.trackerName = trackerName
136 self.trackerId = trackerId
137 self.trackerRid = trackerRid
138 self.ticketId = ticketId
139 self.client = client
140 }
141
142 // MARK: - Queries
143
144 private static let detailQuery = """
145 query ticket($owner: String!, $tracker: String!, $ticketId: Int!) {
146 user(username: $owner) {
147 tracker(name: $tracker) {
148 ticket(id: $ticketId) {
149 id
150 created
151 updated
152 title: subject
153 description: body
154 status
155 resolution
156 authenticity
157 submitter { canonicalName }
158 assignees { canonicalName }
159 labels { id name backgroundColor foregroundColor }
160 events {
161 results {
162 id
163 created
164 changes {
165 eventType
166 ... on Comment {
167 author { canonicalName }
168 text
169 authenticity
170 }
171 ... on StatusChange {
172 oldStatus
173 newStatus
174 }
175 ... on LabelUpdate {
176 labeler { canonicalName }
177 label { name }
178 }
179 ... on Assignment {
180 assigner { canonicalName }
181 assignee { canonicalName }
182 }
183 ... on TicketMention {
184 mentioned { id }
185 }
186 ... on UserMention {
187 mentioned { canonicalName }
188 }
189 ... on Created {
190 author { canonicalName }
191 }
192 }
193 }
194 cursor
195 }
196 }
197 }
198 }
199 }
200 """
201
202 private static let submitCommentMutation = """
203 mutation submitComment($trackerId: Int!, $ticketId: Int!, $input: SubmitCommentInput!) {
204 submitComment(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
205 id
206 created
207 changes {
208 eventType
209 ... on Comment {
210 author { canonicalName }
211 text
212 authenticity
213 }
214 }
215 }
216 }
217 """
218
219 private static let updateStatusMutation = """
220 mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) {
221 updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
222 eventType: __typename
223 }
224 }
225 """
226
227 private static let assignUserMutation = """
228 mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
229 assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
230 }
231 """
232
233 private static let unassignUserMutation = """
234 mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
235 unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
236 }
237 """
238
239 private static let labelTicketMutation = """
240 mutation labelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
241 labelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
242 }
243 """
244
245 private static let unlabelTicketMutation = """
246 mutation unlabelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
247 unlabelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
248 }
249 """
250
251 private static let userLookupQuery = """
252 query userLookup($username: String!) {
253 user(username: $username) { id }
254 }
255 """
256
257 private static let trackerLabelsQuery = """
258 query trackerLabels($owner: String!, $tracker: String!) {
259 user(username: $owner) {
260 tracker(name: $tracker) {
261 labels {
262 results { id name backgroundColor foregroundColor }
263 }
264 }
265 }
266 }
267 """
268
269 private static let createLabelMutation = """
270 mutation createLabel($trackerId: Int!, $name: String!, $backgroundColor: String!, $foregroundColor: String!) {
271 createLabel(trackerId: $trackerId, name: $name, backgroundColor: $backgroundColor, foregroundColor: $foregroundColor) {
272 id
273 name
274 backgroundColor
275 foregroundColor
276 }
277 }
278 """
279
280 // MARK: - Public API
281
282 func loadTicket() async {
283 guard !isLoading else { return }
284 isLoading = true
285 error = nil
286
287 do {
288 let result = try await client.execute(
289 service: .todo,
290 query: Self.detailQuery,
291 variables: [
292 "owner": ownerUsername,
293 "tracker": trackerName,
294 "ticketId": ticketId
295 ],
296 responseType: TicketDetailResponse.self
297 )
298 let payload = result.user.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.localizedDescription
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.localizedDescription
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) async {
364 guard !isPerformingAction else { return }
365 isPerformingAction = true
366 error = nil
367
368 do {
369 let input: [String: any Sendable] = [
370 "status": status.rawValue,
371 "resolution": resolution.rawValue
372 ]
373 _ = try await client.execute(
374 service: .todo,
375 query: Self.updateStatusMutation,
376 variables: [
377 "trackerId": trackerId,
378 "ticketId": ticketId,
379 "input": input
380 ],
381 responseType: UpdateStatusResponse.self
382 )
383 // Re-fetch the ticket to get updated status/resolution
384 await loadTicket()
385 } catch {
386 self.error = error.localizedDescription
387 }
388
389 isPerformingAction = false
390 }
391
392 func assignUser(username: String) async {
393 guard !isPerformingAction else { return }
394 isPerformingAction = true
395 error = nil
396
397 do {
398 // Resolve username to user ID
399 let userResult = try await client.execute(
400 service: .todo,
401 query: Self.userLookupQuery,
402 variables: ["username": username],
403 responseType: UserLookupResponse.self
404 )
405 let userId = userResult.user.id
406
407 _ = try await client.execute(
408 service: .todo,
409 query: Self.assignUserMutation,
410 variables: [
411 "trackerId": trackerId,
412 "ticketId": ticketId,
413 "userId": userId
414 ],
415 responseType: AssignUserResponse.self
416 )
417 // Reload to reflect the change
418 await loadTicket()
419 } catch {
420 self.error = error.localizedDescription
421 }
422
423 isPerformingAction = false
424 }
425
426 func unassignUser(username: String) async {
427 guard !isPerformingAction else { return }
428 isPerformingAction = true
429 error = nil
430
431 do {
432 // Resolve username to user ID
433 let stripped = username.hasPrefix("~") ? String(username.dropFirst()) : username
434 let userResult = try await client.execute(
435 service: .todo,
436 query: Self.userLookupQuery,
437 variables: ["username": stripped],
438 responseType: UserLookupResponse.self
439 )
440 let userId = userResult.user.id
441
442 _ = try await client.execute(
443 service: .todo,
444 query: Self.unassignUserMutation,
445 variables: [
446 "trackerId": trackerId,
447 "ticketId": ticketId,
448 "userId": userId
449 ],
450 responseType: UnassignUserResponse.self
451 )
452 // Reload to reflect the change
453 await loadTicket()
454 } catch {
455 self.error = error.localizedDescription
456 }
457
458 isPerformingAction = false
459 }
460
461 func labelTicket(labelId: Int) async {
462 guard !isPerformingAction else { return }
463 isPerformingAction = true
464 error = nil
465
466 do {
467 _ = try await client.execute(
468 service: .todo,
469 query: Self.labelTicketMutation,
470 variables: [
471 "trackerId": trackerId,
472 "ticketId": ticketId,
473 "labelId": labelId
474 ],
475 responseType: LabelTicketResponse.self
476 )
477 await loadTicket()
478 } catch {
479 self.error = error.localizedDescription
480 }
481
482 isPerformingAction = false
483 }
484
485 func unlabelTicket(labelId: Int) async {
486 guard !isPerformingAction else { return }
487 isPerformingAction = true
488 error = nil
489
490 do {
491 _ = try await client.execute(
492 service: .todo,
493 query: Self.unlabelTicketMutation,
494 variables: [
495 "trackerId": trackerId,
496 "ticketId": ticketId,
497 "labelId": labelId
498 ],
499 responseType: UnlabelTicketResponse.self
500 )
501 await loadTicket()
502 } catch {
503 self.error = error.localizedDescription
504 }
505
506 isPerformingAction = false
507 }
508
509 func loadTrackerLabels() async {
510 do {
511 let result = try await client.execute(
512 service: .todo,
513 query: Self.trackerLabelsQuery,
514 variables: [
515 "owner": ownerUsername,
516 "tracker": trackerName
517 ],
518 responseType: TrackerLabelsResponse.self
519 )
520 trackerLabels = result.user.tracker.labels.results
521 } catch {
522 self.error = error.localizedDescription
523 }
524 }
525
526 func createLabel(name: String, backgroundColor: String, foregroundColor: String) async {
527 guard !isPerformingAction else { return }
528 isPerformingAction = true
529 error = nil
530
531 do {
532 let result = try await client.execute(
533 service: .todo,
534 query: Self.createLabelMutation,
535 variables: [
536 "trackerId": trackerId,
537 "name": name,
538 "backgroundColor": backgroundColor,
539 "foregroundColor": foregroundColor
540 ],
541 responseType: CreateLabelResponse.self
542 )
543 trackerLabels.append(result.createLabel)
544 } catch {
545 self.error = error.localizedDescription
546 }
547
548 isPerformingAction = false
549 }
550
551}