krz/hutch

an ios client for sourcehut

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

v2.1: Hutch/Models/Todo.swift · raw

  1import Foundation
  2
  3// MARK: - Enums
  4
  5/// Status of a ticket.
  6enum TicketStatus: String, Codable, Sendable, CaseIterable {
  7    case reported = "REPORTED"
  8    case confirmed = "CONFIRMED"
  9    case inProgress = "IN_PROGRESS"
 10    case pending = "PENDING"
 11    case resolved = "RESOLVED"
 12
 13    /// Whether this status represents an open (unresolved) ticket.
 14    var isOpen: Bool {
 15        switch self {
 16        case .reported, .confirmed, .inProgress, .pending: true
 17        case .resolved: false
 18        }
 19    }
 20
 21    var displayName: String {
 22        switch self {
 23        case .reported:   "Reported"
 24        case .confirmed:  "Confirmed"
 25        case .inProgress: "In Progress"
 26        case .pending:    "Pending"
 27        case .resolved:   "Resolved"
 28        }
 29    }
 30}
 31
 32/// Resolution of a ticket.
 33enum TicketResolution: String, Codable, Sendable {
 34    case unresolved = "UNRESOLVED"
 35    case fixed = "FIXED"
 36    case implemented = "IMPLEMENTED"
 37    case wontFix = "WONT_FIX"
 38    case byDesign = "BY_DESIGN"
 39    case invalid = "INVALID"
 40    case duplicate = "DUPLICATE"
 41    case notOurBug = "NOT_OUR_BUG"
 42    case closed = "CLOSED"
 43    case notApplicable = "NOT_APPLICABLE"
 44
 45    init(from decoder: Decoder) throws {
 46        let value = try decoder.singleValueContainer().decode(String.self)
 47        self = TicketResolution(rawValue: value) ?? .unresolved
 48    }
 49
 50    var displayName: String {
 51        switch self {
 52        case .unresolved:     "Unresolved"
 53        case .fixed:          "Fixed"
 54        case .implemented:    "Implemented"
 55        case .wontFix:        "Won't Fix"
 56        case .byDesign:       "By Design"
 57        case .invalid:        "Invalid"
 58        case .duplicate:      "Duplicate"
 59        case .notOurBug:      "Not Our Bug"
 60        case .closed:         "Closed"
 61        case .notApplicable:  "Not Applicable"
 62        }
 63    }
 64}
 65
 66/// Authenticity of a ticket or comment.
 67enum Authenticity: String, Codable, Sendable {
 68    case authentic = "AUTHENTIC"
 69    case tampered = "TAMPERED"
 70    case unauthenticated = "UNAUTHENTICATED"
 71}
 72
 73// MARK: - Label
 74
 75/// A label that can be applied to tickets.
 76/// Named `TicketLabel` to avoid collision with `SwiftUI.Label`.
 77struct TicketLabel: Codable, Sendable, Identifiable, Hashable {
 78    let id: Int
 79    let name: String
 80    let backgroundColor: String
 81    let foregroundColor: String
 82}
 83
 84// MARK: - Tracker
 85
 86/// A bug tracker from todo.sr.ht.
 87struct Tracker: Codable, Sendable, Identifiable, Hashable {
 88    let id: Int
 89    let created: Date
 90    let updated: Date
 91    let name: String
 92    let description: String?
 93    let visibility: Visibility
 94    let owner: Entity
 95
 96    static func == (lhs: Tracker, rhs: Tracker) -> Bool {
 97        lhs.id == rhs.id
 98    }
 99
100    func hash(into hasher: inout Hasher) {
101        hasher.combine(id)
102    }
103}
104
105// MARK: - Tracker Summary (for list view)
106
107/// Lightweight tracker model matching the fields returned by the trackers list query.
108struct TrackerSummary: Codable, Sendable, Identifiable, Hashable {
109    let id: Int
110    /// GraphQL resource identifier used by `tracker(id:)` queries.
111    let rid: String
112    let name: String
113    let description: String?
114    let visibility: Visibility
115    let updated: Date
116    let owner: Entity
117}
118
119// MARK: - Ticket Summary (for list view)
120
121/// Lightweight ticket model for the list query.
122struct TicketSummary: Codable, Sendable, Identifiable, Hashable {
123    let id: Int
124    let title: String
125    let status: TicketStatus
126    let resolution: TicketResolution?
127    let created: Date
128    let submitter: Entity
129    let labels: [TicketLabel]
130    let assignees: [Entity]
131
132    static func == (lhs: TicketSummary, rhs: TicketSummary) -> Bool {
133        lhs.id == rhs.id
134    }
135
136    func hash(into hasher: inout Hasher) {
137        hasher.combine(id)
138    }
139}
140
141// MARK: - Ticket Detail
142
143/// Full ticket model for the detail view.
144struct TicketDetail: Codable, Sendable {
145    let id: Int
146    let created: Date
147    let updated: Date
148    let title: String
149    let description: String?
150    let status: TicketStatus
151    let resolution: TicketResolution?
152    let authenticity: Authenticity
153    let submitter: Entity
154    let assignees: [Entity]
155    let labels: [TicketLabel]
156}
157
158// MARK: - Event
159
160/// A timeline event on a ticket. Each event contains one or more changes.
161struct TicketEvent: Codable, Sendable, Identifiable {
162    let id: Int
163    let created: Date
164    var changes: [EventChange]
165}
166
167/// A single change within an event, decoded from the polymorphic EventDetail
168/// interface using inline fragments.
169struct EventChange: Codable, Sendable, Identifiable {
170    let id: UUID
171
172    let eventType: String
173
174    // Comment fields
175    let author: Entity?
176    var text: String?
177    let authenticity: Authenticity?
178
179    // StatusChange fields
180    let oldStatus: TicketStatus?
181    let newStatus: TicketStatus?
182
183    // LabelUpdate fields
184    let labeler: Entity?
185    let label: EventLabel?
186
187    // Assignment fields
188    let assigner: Entity?
189    let assignee: Entity?
190
191    // Mention fields
192    // TicketMention: mentioned is a Ticket with { id }
193    // UserMention: mentioned is an Entity with { canonicalName }
194    let mentioned: MentionTarget?
195
196    // Created fields (author is shared with Comment)
197
198    private enum CodingKeys: String, CodingKey {
199        case eventType
200        case author, text, authenticity
201        case oldStatus, newStatus
202        case labeler, label
203        case assigner, assignee
204        case mentioned
205    }
206
207    init(from decoder: any Decoder) throws {
208        let container = try decoder.container(keyedBy: CodingKeys.self)
209        self.id = UUID()
210        self.eventType = try container.decode(String.self, forKey: .eventType)
211        self.author = try container.decodeIfPresent(Entity.self, forKey: .author)
212        self.text = try container.decodeIfPresent(String.self, forKey: .text)
213        self.authenticity = try container.decodeIfPresent(Authenticity.self, forKey: .authenticity)
214        self.oldStatus = try container.decodeIfPresent(TicketStatus.self, forKey: .oldStatus)
215        self.newStatus = try container.decodeIfPresent(TicketStatus.self, forKey: .newStatus)
216        self.labeler = try container.decodeIfPresent(Entity.self, forKey: .labeler)
217        self.label = try container.decodeIfPresent(EventLabel.self, forKey: .label)
218        self.assigner = try container.decodeIfPresent(Entity.self, forKey: .assigner)
219        self.assignee = try container.decodeIfPresent(Entity.self, forKey: .assignee)
220        self.mentioned = try container.decodeIfPresent(MentionTarget.self, forKey: .mentioned)
221    }
222}
223
224/// Decoded from the `mentioned` field which may be a Ticket (with `id`) or
225/// an Entity (with `canonicalName`) depending on the event type.
226struct MentionTarget: Codable, Sendable {
227    let id: Int?
228    let canonicalName: String?
229}
230
231/// Label info as returned within a LabelUpdate event change.
232struct EventLabel: Codable, Sendable {
233    let name: String
234}