krz/hutch

an ios client for sourcehut

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

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