krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2.3.0: Hutch/Views/Tickets/TicketListViewModel.swift · raw

  1import Foundation
  2
  3// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
  4
  5private struct TrackerTicketsResponse: Decodable, Sendable {
  6    let user: UserTrackerWrapper
  7}
  8
  9private struct UserTrackerWrapper: Decodable, Sendable {
 10    let tracker: TrackerTicketsWrapper
 11}
 12
 13private struct TrackerTicketsWrapper: Decodable, Sendable {
 14    let tickets: TicketsPage
 15}
 16
 17private struct TicketsPage: Decodable, Sendable {
 18    let results: [TicketSummary]
 19    let cursor: String?
 20}
 21
 22private struct AssignmentMutationResponse: Decodable, Sendable {
 23    struct EventRef: Decodable, Sendable {
 24        let id: Int
 25    }
 26
 27    let assignUser: EventRef?
 28    let unassignUser: EventRef?
 29}
 30
 31private struct LabelMutationResponse: Decodable, Sendable {
 32    struct EventRef: Decodable, Sendable {
 33        let id: Int
 34    }
 35
 36    let labelTicket: EventRef?
 37    let unlabelTicket: EventRef?
 38}
 39
 40private struct TrackerLabelsResponse: Decodable, Sendable {
 41    let user: UserTrackerLabelsWrapper
 42}
 43
 44private struct UserTrackerLabelsWrapper: Decodable, Sendable {
 45    let tracker: TrackerLabelsWrapper
 46}
 47
 48private struct TrackerLabelsWrapper: Decodable, Sendable {
 49    let labels: LabelsPage
 50}
 51
 52private struct LabelsPage: Decodable, Sendable {
 53    let results: [TicketLabel]
 54}
 55
 56private struct UpdateStatusResponse: Decodable, Sendable {
 57    let updateTicketStatus: MutationEventRef
 58}
 59
 60private struct MutationEventRef: Decodable, Sendable {
 61    let eventType: String
 62}
 63
 64// MARK: - Filter
 65
 66enum TicketFilter: String, CaseIterable, Sendable {
 67    case open = "Open"
 68    case resolved = "Resolved"
 69    case all = "All"
 70}
 71
 72// MARK: - View Model
 73
 74@Observable
 75@MainActor
 76final class TicketListViewModel {
 77    let ownerUsername: String
 78    let trackerName: String
 79    let trackerId: Int
 80    let trackerRid: String
 81
 82    private(set) var tickets: [TicketSummary] = []
 83    private(set) var isLoading = false
 84    private(set) var isLoadingMore = false
 85    private(set) var isCreatingTicket = false
 86    private(set) var isPerformingAction = false
 87    private(set) var trackerLabels: [TicketLabel] = []
 88    var error: String?
 89    var filter: TicketFilter = .open {
 90        didSet {
 91            UserDefaults.standard.set(filter.rawValue, forKey: filterDefaultsKey)
 92        }
 93    }
 94    var searchText = ""
 95
 96    private var cursor: String?
 97    private var hasMore = true
 98    private let client: SRHTClient
 99
100    private var filterDefaultsKey: String {
101        "ticketFilter_\(trackerRid)"
102    }
103
104    init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, client: SRHTClient) {
105        self.ownerUsername = ownerUsername
106        self.trackerName = trackerName
107        self.trackerId = trackerId
108        self.trackerRid = trackerRid
109        self.client = client
110        if let raw = UserDefaults.standard.string(forKey: filterDefaultsKey),
111           let restored = TicketFilter(rawValue: raw) {
112            self.filter = restored
113        }
114    }
115
116    // MARK: - Query
117
118    private static let query = """
119    query tickets($owner: String!, $tracker: String!, $cursor: Cursor) {
120        user(username: $owner) {
121            tracker(name: $tracker) {
122                tickets(cursor: $cursor) {
123                    results {
124                        id
125                        title: subject
126                        status
127                        resolution
128                        created
129                        submitter { canonicalName }
130                        labels { id name backgroundColor foregroundColor }
131                        assignees { canonicalName }
132                    }
133                    cursor
134                }
135            }
136        }
137    }
138    """
139
140    private static let submitTicketMutation = """
141    mutation submitTicket($trackerId: Int!, $input: SubmitTicketInput!) {
142        submitTicket(trackerId: $trackerId, input: $input) {
143            id
144            title: subject
145            status
146            resolution
147            created
148            submitter { canonicalName }
149            labels { id name backgroundColor foregroundColor }
150            assignees { canonicalName }
151        }
152    }
153    """
154
155    private static let updateStatusMutation = """
156    mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) {
157        updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
158            eventType: __typename
159        }
160    }
161    """
162
163    private static let assignUserMutation = """
164    mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
165        assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
166    }
167    """
168
169    private static let unassignUserMutation = """
170    mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
171        unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
172    }
173    """
174
175    private static let labelTicketMutation = """
176    mutation labelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
177        labelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
178    }
179    """
180
181    private static let unlabelTicketMutation = """
182    mutation unlabelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
183        unlabelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
184    }
185    """
186
187    private static let trackerLabelsQuery = """
188    query trackerLabels($owner: String!, $tracker: String!) {
189        user(username: $owner) {
190            tracker(name: $tracker) {
191                labels {
192                    results { id name backgroundColor foregroundColor }
193                }
194            }
195        }
196    }
197    """
198
199    // MARK: - Computed
200
201    /// Tickets filtered by the selected status filter.
202    var filteredTickets: [TicketSummary] {
203        let statusFiltered: [TicketSummary]
204        switch filter {
205        case .open:
206            statusFiltered = tickets.filter { $0.status.isOpen }
207        case .resolved:
208            statusFiltered = tickets.filter { !$0.status.isOpen }
209        case .all:
210            statusFiltered = tickets
211        }
212        let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
213        guard !q.isEmpty else { return statusFiltered }
214        return statusFiltered.filter {
215            String($0.id).contains(q) ||
216            $0.title.lowercased().contains(q) ||
217            $0.submitter.canonicalName.lowercased().contains(q) ||
218            $0.labels.contains { $0.name.lowercased().contains(q) }
219        }
220    }
221
222    // MARK: - Public API
223
224    func loadTickets() async {
225        isLoading = true
226        error = nil
227        cursor = nil
228        hasMore = true
229
230        do {
231            let page = try await fetchPage(cursor: nil)
232            tickets = page.results
233            cursor = page.cursor
234            hasMore = page.cursor != nil
235        } catch {
236            self.error = error.userFacingMessage
237        }
238
239        isLoading = false
240    }
241
242    func loadMoreIfNeeded(currentItem: TicketSummary) async {
243        guard let last = tickets.last,
244              last.id == currentItem.id,
245              hasMore,
246              !isLoadingMore else {
247            return
248        }
249
250        isLoadingMore = true
251
252        do {
253            let page = try await fetchPage(cursor: cursor)
254            tickets.append(contentsOf: page.results)
255            cursor = page.cursor
256            hasMore = page.cursor != nil
257        } catch {
258            self.error = error.userFacingMessage
259        }
260
261        isLoadingMore = false
262    }
263
264    func createTicket(subject: String, body: String) async -> TicketSummary? {
265        guard !isCreatingTicket else { return nil }
266
267        let trimmedSubject = subject.trimmingCharacters(in: .whitespacesAndNewlines)
268        guard !trimmedSubject.isEmpty else {
269            error = "Enter a ticket title."
270            return nil
271        }
272
273        isCreatingTicket = true
274        error = nil
275        defer { isCreatingTicket = false }
276
277        var input: [String: any Sendable] = [
278            "subject": trimmedSubject
279        ]
280        let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
281        if !trimmedBody.isEmpty {
282            input["body"] = trimmedBody
283        }
284        let variables: [String: any Sendable] = [
285            "trackerId": trackerId,
286            "input": input
287        ]
288
289        do {
290            let result = try await client.execute(
291                service: .todo,
292                query: Self.submitTicketMutation,
293                variables: variables,
294                responseType: SubmitTicketResponse.self
295            )
296            let ticket = result.submitTicket
297            tickets.insert(ticket, at: 0)
298            return ticket
299        } catch {
300            self.error = "Couldn’t create the ticket. \(error.userFacingMessage)"
301            return nil
302        }
303    }
304
305    func resolveTicket(_ ticket: TicketSummary) async {
306        let input: [String: any Sendable] = [
307            "status": TicketStatus.resolved.rawValue,
308            "resolution": TicketResolution.fixed.rawValue
309        ]
310        await performStatusUpdate(ticket: ticket, input: input)
311    }
312
313    func reopenTicket(_ ticket: TicketSummary) async {
314        let input: [String: any Sendable] = [
315            "status": TicketStatus.reported.rawValue
316        ]
317        await performStatusUpdate(ticket: ticket, input: input)
318    }
319
320    func assignToMe(ticket: TicketSummary, user: User) async {
321        guard !isPerformingAction else { return }
322        isPerformingAction = true
323        error = nil
324
325        let original = tickets
326        if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
327            let entity = Entity(canonicalName: user.canonicalName)
328            let updated = TicketSummary(
329                id: ticket.id,
330                title: ticket.title,
331                status: ticket.status,
332                resolution: ticket.resolution,
333                created: ticket.created,
334                submitter: ticket.submitter,
335                labels: ticket.labels,
336                assignees: ticket.assignees + [entity]
337            )
338            tickets[index] = updated
339        }
340
341        do {
342            _ = try await client.execute(
343                service: .todo,
344                query: Self.assignUserMutation,
345                variables: [
346                    "trackerId": trackerId,
347                    "ticketId": ticket.id,
348                    "userId": user.id
349                ],
350                responseType: AssignmentMutationResponse.self
351            )
352        } catch {
353            tickets = original
354            self.error = error.userFacingMessage
355        }
356
357        isPerformingAction = false
358    }
359
360    func unassignFromMe(ticket: TicketSummary, user: User) async {
361        guard !isPerformingAction else { return }
362        isPerformingAction = true
363        error = nil
364
365        let original = tickets
366        if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
367            let filtered = ticket.assignees.filter { assignee in
368                !Self.matchesAssignee(assignee, user: user)
369            }
370            let updated = TicketSummary(
371                id: ticket.id,
372                title: ticket.title,
373                status: ticket.status,
374                resolution: ticket.resolution,
375                created: ticket.created,
376                submitter: ticket.submitter,
377                labels: ticket.labels,
378                assignees: filtered
379            )
380            tickets[index] = updated
381        }
382
383        do {
384            _ = try await client.execute(
385                service: .todo,
386                query: Self.unassignUserMutation,
387                variables: [
388                    "trackerId": trackerId,
389                    "ticketId": ticket.id,
390                    "userId": user.id
391                ],
392                responseType: AssignmentMutationResponse.self
393            )
394        } catch {
395            tickets = original
396            self.error = error.userFacingMessage
397        }
398
399        isPerformingAction = false
400    }
401
402    func loadTrackerLabels() async {
403        do {
404            let result = try await client.execute(
405                service: .todo,
406                query: Self.trackerLabelsQuery,
407                variables: [
408                    "owner": ownerUsername,
409                    "tracker": trackerName
410                ],
411                responseType: TrackerLabelsResponse.self
412            )
413            trackerLabels = result.user.tracker.labels.results
414        } catch {
415            self.error = error.userFacingMessage
416        }
417    }
418
419    func labelTicket(_ ticket: TicketSummary, label: TicketLabel) async {
420        guard !isPerformingAction else { return }
421        isPerformingAction = true
422        error = nil
423
424        let original = tickets
425        if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
426            let updated = TicketSummary(
427                id: ticket.id,
428                title: ticket.title,
429                status: ticket.status,
430                resolution: ticket.resolution,
431                created: ticket.created,
432                submitter: ticket.submitter,
433                labels: ticket.labels + [label],
434                assignees: ticket.assignees
435            )
436            tickets[index] = updated
437        }
438
439        do {
440            _ = try await client.execute(
441                service: .todo,
442                query: Self.labelTicketMutation,
443                variables: [
444                    "trackerId": trackerId,
445                    "ticketId": ticket.id,
446                    "labelId": label.id
447                ],
448                responseType: LabelMutationResponse.self
449            )
450        } catch {
451            tickets = original
452            self.error = error.userFacingMessage
453        }
454
455        isPerformingAction = false
456    }
457
458    func unlabelTicket(_ ticket: TicketSummary, label: TicketLabel) async {
459        guard !isPerformingAction else { return }
460        isPerformingAction = true
461        error = nil
462
463        let original = tickets
464        if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
465            let filtered = ticket.labels.filter { $0.id != label.id }
466            let updated = TicketSummary(
467                id: ticket.id,
468                title: ticket.title,
469                status: ticket.status,
470                resolution: ticket.resolution,
471                created: ticket.created,
472                submitter: ticket.submitter,
473                labels: filtered,
474                assignees: ticket.assignees
475            )
476            tickets[index] = updated
477        }
478
479        do {
480            _ = try await client.execute(
481                service: .todo,
482                query: Self.unlabelTicketMutation,
483                variables: [
484                    "trackerId": trackerId,
485                    "ticketId": ticket.id,
486                    "labelId": label.id
487                ],
488                responseType: LabelMutationResponse.self
489            )
490        } catch {
491            tickets = original
492            self.error = error.userFacingMessage
493        }
494
495        isPerformingAction = false
496    }
497
498    func ticket(withId ticketId: Int) -> TicketSummary? {
499        tickets.first(where: { $0.id == ticketId })
500    }
501
502    // MARK: - Private
503
504    private func performStatusUpdate(ticket: TicketSummary, input: [String: any Sendable]) async {
505        guard !isPerformingAction else { return }
506        isPerformingAction = true
507        error = nil
508
509        do {
510            let variables: [String: any Sendable] = [
511                "trackerId": trackerId,
512                "ticketId": ticket.id,
513                "input": input
514            ]
515            let result = try await client.execute(
516                service: .todo,
517                query: Self.updateStatusMutation,
518                variables: variables,
519                responseType: UpdateStatusResponse.self
520            )
521            _ = result.updateTicketStatus
522            if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
523                tickets[index] = updatedTicket(from: ticket, input: input)
524            }
525        } catch {
526            self.error = error.userFacingMessage
527        }
528
529        isPerformingAction = false
530    }
531
532    private func fetchPage(cursor: String?) async throws -> TicketsPage {
533        var variables: [String: any Sendable] = [
534            "owner": ownerUsername,
535            "tracker": trackerName
536        ]
537        if let cursor {
538            variables["cursor"] = cursor
539        }
540        let result = try await client.execute(
541            service: .todo,
542            query: Self.query,
543            variables: variables,
544            responseType: TrackerTicketsResponse.self
545        )
546        return result.user.tracker.tickets
547    }
548
549    private struct SubmitTicketResponse: Decodable, Sendable {
550        let submitTicket: TicketSummary
551    }
552
553    private static func matchesAssignee(_ entity: Entity, user: User) -> Bool {
554        let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
555        let userCanonical = normalizedCanonicalName(user.canonicalName)
556        if assigneeCanonical == userCanonical {
557            return true
558        }
559        return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username)
560    }
561
562    private static func normalizedCanonicalName(_ value: String) -> String {
563        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
564        if trimmed.hasPrefix("~") {
565            return trimmed
566        }
567        return "~\(trimmed)"
568    }
569
570    private static func normalizedUsername(_ value: String) -> String {
571        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
572        return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
573    }
574
575    private func updatedTicket(from ticket: TicketSummary, input: [String: any Sendable]) -> TicketSummary {
576        let updatedStatus = (input["status"] as? String).flatMap(TicketStatus.init(rawValue:)) ?? ticket.status
577        let updatedResolution = (input["resolution"] as? String).flatMap(TicketResolution.init(rawValue:))
578
579        return TicketSummary(
580            id: ticket.id,
581            title: ticket.title,
582            status: updatedStatus,
583            resolution: updatedStatus == .resolved ? updatedResolution : nil,
584            created: ticket.created,
585            submitter: ticket.submitter,
586            labels: ticket.labels,
587            assignees: ticket.assignees
588        )
589    }
590}