summaryrefslogtreecommitdiff
path: root/Hutch/Models
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-17 23:19:43 -0500
committerChristian Cleberg <[email protected]>2026-03-17 23:19:43 -0500
commit32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5 (patch)
treeee36d421d704e508d5617d803b4c8cbdb4804fe1 /Hutch/Models
parent8f2057c53e9009c2529c9c4849c914666c0e4b40 (diff)
downloadhutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.tar.gz
hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.tar.bz2
hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.zip
v1.0
Diffstat (limited to 'Hutch/Models')
-rw-r--r--Hutch/Models/ArtifactInfo.swift22
-rw-r--r--Hutch/Models/Builds.swift111
-rw-r--r--Hutch/Models/CommitDetail.swift65
-rw-r--r--Hutch/Models/CommitSummary.swift22
-rw-r--r--Hutch/Models/Git.swift241
-rw-r--r--Hutch/Models/Meta.swift65
-rw-r--r--Hutch/Models/RepositorySummary.swift57
-rw-r--r--Hutch/Models/Todo.swift234
-rw-r--r--Hutch/Models/User.swift10
9 files changed, 827 insertions, 0 deletions
diff --git a/Hutch/Models/ArtifactInfo.swift b/Hutch/Models/ArtifactInfo.swift
new file mode 100644
index 0000000..dbe9d96
--- /dev/null
+++ b/Hutch/Models/ArtifactInfo.swift
@@ -0,0 +1,22 @@
+import Foundation
+
+/// An artifact with its parent reference name, used in the artifacts tab.
+struct ArtifactInfo: Codable, Sendable, Identifiable {
+ let id: Int
+ let filename: String
+ let checksum: String
+ let size: Int
+ let url: URL
+}
+
+struct ArtifactPage: Codable, Sendable {
+ let results: [ArtifactInfo]
+ let cursor: String?
+}
+
+/// A reference (tag) that has associated artifacts.
+struct ReferenceWithArtifacts: Codable, Sendable, Identifiable {
+ var id: String { name }
+ let name: String
+ let artifacts: [ArtifactInfo]
+}
diff --git a/Hutch/Models/Builds.swift b/Hutch/Models/Builds.swift
new file mode 100644
index 0000000..6694c35
--- /dev/null
+++ b/Hutch/Models/Builds.swift
@@ -0,0 +1,111 @@
+import Foundation
+
+// MARK: - Enums
+
+/// Status of a build job.
+enum JobStatus: String, Codable, Sendable {
+ case pending = "PENDING"
+ case queued = "QUEUED"
+ case running = "RUNNING"
+ case success = "SUCCESS"
+ case failed = "FAILED"
+ case cancelled = "CANCELLED"
+ case timeout = "TIMEOUT"
+
+ /// Whether the job can be cancelled.
+ var isCancellable: Bool {
+ switch self {
+ case .pending, .queued, .running: true
+ default: false
+ }
+ }
+}
+
+/// Status of a single build task within a job.
+enum TaskStatus: String, Codable, Sendable {
+ case pending = "PENDING"
+ case running = "RUNNING"
+ case success = "SUCCESS"
+ case failed = "FAILED"
+ case skipped = "SKIPPED"
+}
+
+// MARK: - Build Task
+
+/// A single task within a build job.
+struct BuildTask: Codable, Sendable, Identifiable {
+ var id: String { name }
+ let name: String
+ let status: TaskStatus
+ let log: BuildLog?
+}
+
+// MARK: - Job Summary (for list view)
+
+/// Lightweight job model matching the fields returned by the jobs list query.
+struct JobSummary: Codable, Sendable, Identifiable, Hashable {
+ let id: Int
+ let created: Date
+ let updated: Date
+ let status: JobStatus
+ let note: String?
+ let tags: [String]
+ let visibility: Visibility?
+ let image: String?
+ let tasks: [JobTaskSummary]
+
+ /// Number of completed (success) tasks.
+ var completedTaskCount: Int {
+ tasks.filter { $0.status == .success }.count
+ }
+
+ /// Display label: note if available, otherwise tags joined.
+ var displayLabel: String {
+ if let note, !note.isEmpty {
+ return note
+ }
+ if !tags.isEmpty {
+ return tags.joined(separator: ", ")
+ }
+ return "Job #\(id)"
+ }
+}
+
+/// Minimal task info for the list query.
+struct JobTaskSummary: Codable, Sendable, Hashable {
+ let name: String
+ let status: TaskStatus
+}
+
+// MARK: - Job Detail (for detail view)
+
+/// Full job model with all fields for the detail view.
+struct JobDetail: Codable, Sendable {
+ let id: Int
+ let created: Date
+ let updated: Date
+ let status: JobStatus
+ let note: String?
+ let tags: [String]
+ let visibility: Visibility?
+ let image: String?
+ let manifest: String?
+ let tasks: [BuildTask]
+ let log: BuildLog?
+ let owner: Entity
+}
+
+/// The log associated with a build job.
+struct BuildLog: Codable, Sendable {
+ let fullURL: String
+}
+
+// MARK: - Job Group
+
+/// A group of related build jobs.
+struct JobGroup: Codable, Sendable, Identifiable {
+ let id: Int
+ let created: Date
+ let note: String?
+ let jobs: [JobSummary]
+}
diff --git a/Hutch/Models/CommitDetail.swift b/Hutch/Models/CommitDetail.swift
new file mode 100644
index 0000000..c95199d
--- /dev/null
+++ b/Hutch/Models/CommitDetail.swift
@@ -0,0 +1,65 @@
+import Foundation
+
+/// Full commit detail returned by the revparse_single query.
+struct CommitDetail: Codable, Sendable, Identifiable {
+ let id: String
+ let shortId: String
+ let author: CommitAuthor
+ let committer: CommitAuthor
+ let message: String
+ let diff: String?
+ let trailers: [CommitTrailer]
+ let parents: [ParentCommit]
+ let tree: CommitTree?
+
+ /// First line of the commit message.
+ var title: String {
+ message.prefix(while: { $0 != "\n" }).trimmingCharacters(in: .whitespaces)
+ }
+
+ /// The commit message body (everything after the first line), if any.
+ var body: String? {
+ guard let newlineIndex = message.firstIndex(of: "\n") else { return nil }
+ let body = message[message.index(after: newlineIndex)...]
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ return body.isEmpty ? nil : body
+ }
+}
+
+struct CommitTrailer: Codable, Sendable, Identifiable {
+ var id: String { "\(name):\(value)" }
+ let name: String
+ let value: String
+}
+
+struct ParentCommit: Codable, Sendable, Identifiable, Hashable {
+ let id: String
+ let shortId: String
+ let author: ParentAuthor
+}
+
+struct ParentAuthor: Codable, Sendable, Hashable {
+ let name: String
+}
+
+struct CommitTree: Codable, Sendable {
+ let entries: CommitTreeEntries
+}
+
+struct CommitTreeEntries: Codable, Sendable {
+ let results: [CommitTreeEntry]
+ let cursor: String?
+}
+
+struct CommitTreeEntry: Codable, Sendable, Identifiable {
+ let id: String
+ let name: String
+ let mode: Int
+ let object: CommitTreeObject?
+}
+
+struct CommitTreeObject: Codable, Sendable {
+ let type: String?
+ let id: String?
+ let shortId: String?
+}
diff --git a/Hutch/Models/CommitSummary.swift b/Hutch/Models/CommitSummary.swift
new file mode 100644
index 0000000..88c3e7d
--- /dev/null
+++ b/Hutch/Models/CommitSummary.swift
@@ -0,0 +1,22 @@
+import Foundation
+
+/// Lightweight commit model for list views. Matches the subset of fields
+/// returned by the repository log query.
+struct CommitSummary: Codable, Sendable, Identifiable, Hashable {
+ let id: String
+ let shortId: String
+ let author: CommitAuthor
+ let message: String
+
+ /// First line of the commit message.
+ var title: String {
+ message.prefix(while: { $0 != "\n" }).trimmingCharacters(in: .whitespaces)
+ }
+}
+
+/// A compact author representation used in commit list responses.
+struct CommitAuthor: Codable, Sendable, Hashable {
+ let name: String
+ let email: String?
+ let time: Date
+}
diff --git a/Hutch/Models/Git.swift b/Hutch/Models/Git.swift
new file mode 100644
index 0000000..19ec31b
--- /dev/null
+++ b/Hutch/Models/Git.swift
@@ -0,0 +1,241 @@
+import Foundation
+
+// MARK: - Enums
+
+/// Repository visibility level.
+enum Visibility: String, Codable, Sendable {
+ case `public` = "PUBLIC"
+ case unlisted = "UNLISTED"
+ case `private` = "PRIVATE"
+}
+
+/// Repository access mode.
+enum AccessMode: String, Codable, Sendable {
+ case ro = "RO"
+ case rw = "RW"
+}
+
+// MARK: - Entity
+
+/// The `Entity` GraphQL interface from git.sr.ht. Represents the owner of a
+/// resource (typically a user).
+struct Entity: Codable, Sendable, Hashable {
+ let canonicalName: String
+}
+
+// MARK: - Repository
+
+/// A git repository from git.sr.ht.
+struct Repository: Codable, Sendable, Identifiable {
+ let id: Int
+ let created: Date
+ let updated: Date
+ let name: String
+ let description: String?
+ let visibility: Visibility
+ let readme: String?
+ let accessMode: AccessMode
+ let owner: Entity
+
+ enum CodingKeys: String, CodingKey {
+ case id, created, updated, name, description, visibility, readme
+ case accessMode = "access"
+ case owner
+ }
+}
+
+// MARK: - Signature
+
+/// A Git commit/tag signature (author or committer).
+struct Signature: Codable, Sendable {
+ let name: String
+ let email: String
+ let time: Date
+}
+
+// MARK: - Trailer
+
+/// A Git commit trailer (e.g. "Signed-off-by", "Co-authored-by").
+struct Trailer: Codable, Sendable {
+ let name: String
+ let value: String
+}
+
+// MARK: - Commit
+
+/// A Git commit from git.sr.ht.
+struct Commit: Codable, Sendable, Identifiable {
+ let id: String
+ let shortId: String
+ let author: Signature
+ let committer: Signature
+ let message: String
+ let diff: String?
+ let trailers: [Trailer]
+}
+
+// MARK: - Reference
+
+/// A Git reference (branch or tag name).
+struct Reference: Codable, Sendable, Hashable {
+ let name: String
+ let target: String?
+}
+
+// MARK: - TreeEntry
+
+/// An entry in a Git tree (file or directory).
+struct TreeEntry: Codable, Sendable, Identifiable {
+ let id: String
+ let name: String
+ let mode: Int?
+ let object: GitObject?
+}
+
+/// A Git object returned by the git.sr.ht GraphQL API.
+/// The API returns `type` as "TREE", "BLOB", "COMMIT", or "TAG".
+/// Both TextBlob and BinaryBlob have type == "BLOB"; they are
+/// distinguished by the presence of the "text" key (TextBlob) vs
+/// the "content" key (BinaryBlob).
+enum GitObject: Sendable {
+ case tree(GitTree)
+ case textBlob(GitTextBlob)
+ case binaryBlob(GitBinaryBlob)
+ case unknown
+}
+
+struct GitTree: Codable, Sendable {
+ let id: String?
+ let shortId: String?
+ let entries: GitTreeEntryPage?
+}
+
+struct GitTextBlob: Codable, Sendable {
+ let id: String?
+ let shortId: String?
+ let text: String
+ let size: Int?
+}
+
+struct GitBinaryBlob: Codable, Sendable {
+ let id: String?
+ let shortId: String?
+ let size: Int?
+ let content: String?
+}
+
+struct GitTreeEntryPage: Codable, Sendable {
+ let results: [TreeEntry]
+ let cursor: String?
+}
+
+// MARK: - GitObject Codable
+
+extension GitObject: Codable {
+ private enum CodingKeys: String, CodingKey {
+ case type, id, shortId, entries, text, size, content
+ }
+
+ init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let type = try container.decodeIfPresent(String.self, forKey: .type)
+
+ switch type {
+ case "TREE":
+ let tree = GitTree(
+ id: try container.decodeIfPresent(String.self, forKey: .id),
+ shortId: try container.decodeIfPresent(String.self, forKey: .shortId),
+ entries: try container.decodeIfPresent(GitTreeEntryPage.self, forKey: .entries)
+ )
+ self = .tree(tree)
+
+ case "BLOB":
+ // TextBlob has a "text" key; BinaryBlob does not
+ if container.contains(.text) {
+ let blob = GitTextBlob(
+ id: try container.decodeIfPresent(String.self, forKey: .id),
+ shortId: try container.decodeIfPresent(String.self, forKey: .shortId),
+ text: try container.decode(String.self, forKey: .text),
+ size: try container.decodeIfPresent(Int.self, forKey: .size)
+ )
+ self = .textBlob(blob)
+ } else {
+ let blob = GitBinaryBlob(
+ id: try container.decodeIfPresent(String.self, forKey: .id),
+ shortId: try container.decodeIfPresent(String.self, forKey: .shortId),
+ size: try container.decodeIfPresent(Int.self, forKey: .size),
+ content: try container.decodeIfPresent(String.self, forKey: .content)
+ )
+ self = .binaryBlob(blob)
+ }
+
+ default:
+ self = .unknown
+ }
+ }
+
+ func encode(to encoder: any Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ switch self {
+ case .tree(let tree):
+ try container.encode("TREE", forKey: .type)
+ try container.encodeIfPresent(tree.id, forKey: .id)
+ try container.encodeIfPresent(tree.shortId, forKey: .shortId)
+ try container.encodeIfPresent(tree.entries, forKey: .entries)
+ case .textBlob(let blob):
+ try container.encode("BLOB", forKey: .type)
+ try container.encodeIfPresent(blob.id, forKey: .id)
+ try container.encodeIfPresent(blob.shortId, forKey: .shortId)
+ try container.encode(blob.text, forKey: .text)
+ try container.encodeIfPresent(blob.size, forKey: .size)
+ case .binaryBlob(let blob):
+ try container.encode("BLOB", forKey: .type)
+ try container.encodeIfPresent(blob.id, forKey: .id)
+ try container.encodeIfPresent(blob.shortId, forKey: .shortId)
+ try container.encodeIfPresent(blob.size, forKey: .size)
+ try container.encodeIfPresent(blob.content, forKey: .content)
+ case .unknown:
+ break
+ }
+ }
+}
+
+/// Convenience helpers for checking object type.
+extension GitObject {
+ var isTree: Bool {
+ if case .tree = self { return true }
+ return false
+ }
+
+ var treeId: String? {
+ switch self {
+ case .tree(let t): t.id
+ case .textBlob(let b): b.id
+ case .binaryBlob(let b): b.id
+ case .unknown: nil
+ }
+ }
+}
+
+// MARK: - Tag
+
+/// An annotated Git tag from git.sr.ht.
+struct Tag: Codable, Sendable, Identifiable {
+ let id: String
+ let shortId: String
+ let name: String
+ let message: String?
+ let tagger: Signature?
+}
+
+// MARK: - Artifact
+
+/// A release artifact attached to a Git tag.
+struct Artifact: Codable, Sendable, Identifiable {
+ let id: Int
+ let created: Date
+ let filename: String
+ let checksum: String
+ let size: Int
+ let url: URL
+}
diff --git a/Hutch/Models/Meta.swift b/Hutch/Models/Meta.swift
new file mode 100644
index 0000000..c0434bc
--- /dev/null
+++ b/Hutch/Models/Meta.swift
@@ -0,0 +1,65 @@
+import Foundation
+
+// MARK: - User Profile (full)
+
+/// Extended user profile from meta.sr.ht with all fields from the `me` query.
+struct UserProfile: Codable, Sendable {
+ let username: String
+ let canonicalName: String
+ let email: String
+ let url: String?
+ let location: String?
+ let bio: String?
+ let avatar: String?
+ let userType: String?
+ let sshKeys: SSHKeyPage
+ let pgpKeys: PGPKeyPage
+ let paymentStatus: String?
+ let subscription: Subscription?
+}
+
+// MARK: - SSH Key
+
+struct SSHKey: Codable, Sendable, Identifiable {
+ let id: Int
+ let fingerprint: String
+ let comment: String?
+ let created: Date
+ let lastUsed: Date?
+}
+
+struct SSHKeyPage: Codable, Sendable {
+ let results: [SSHKey]
+ let cursor: String?
+}
+
+// MARK: - PGP Key
+
+struct PGPKey: Codable, Sendable, Identifiable {
+ let id: Int
+ let fingerprint: String
+ let created: Date
+}
+
+struct PGPKeyPage: Codable, Sendable {
+ let results: [PGPKey]
+ let cursor: String?
+}
+
+// MARK: - Subscription
+
+struct Subscription: Codable, Sendable {
+ let status: String?
+ let autorenew: Bool?
+ let interval: String?
+}
+
+// MARK: - Personal Access Token
+
+struct PersonalAccessToken: Codable, Sendable, Identifiable {
+ let id: Int
+ let issued: Date
+ let expires: Date?
+ let comment: String?
+ let grants: String?
+}
diff --git a/Hutch/Models/RepositorySummary.swift b/Hutch/Models/RepositorySummary.swift
new file mode 100644
index 0000000..f4ecc6e
--- /dev/null
+++ b/Hutch/Models/RepositorySummary.swift
@@ -0,0 +1,57 @@
+import Foundation
+
+/// Lightweight repository model matching the fields returned by the
+/// repositories list query. Avoids optionalizing all fields on the full
+/// `Repository` model.
+struct RepositorySummary: Codable, Sendable, Identifiable, Hashable {
+ let id: Int
+ /// GraphQL resource identifier used by `repository(rid:)` queries.
+ let rid: String
+ let service: SRHTService
+ let name: String
+ let description: String?
+ let visibility: Visibility
+ let updated: Date
+ let owner: Entity
+ let head: Reference?
+
+ enum CodingKeys: String, CodingKey {
+ case id, rid, service, name, description, visibility, updated, owner
+ case head = "HEAD"
+ }
+
+ init(
+ id: Int,
+ rid: String,
+ service: SRHTService,
+ name: String,
+ description: String?,
+ visibility: Visibility,
+ updated: Date,
+ owner: Entity,
+ head: Reference?
+ ) {
+ self.id = id
+ self.rid = rid
+ self.service = service
+ self.name = name
+ self.description = description
+ self.visibility = visibility
+ self.updated = updated
+ self.owner = owner
+ self.head = head
+ }
+
+ init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.id = try container.decode(Int.self, forKey: .id)
+ self.rid = try container.decode(String.self, forKey: .rid)
+ self.service = try container.decodeIfPresent(SRHTService.self, forKey: .service) ?? .git
+ self.name = try container.decode(String.self, forKey: .name)
+ self.description = try container.decodeIfPresent(String.self, forKey: .description)
+ self.visibility = try container.decode(Visibility.self, forKey: .visibility)
+ self.updated = try container.decode(Date.self, forKey: .updated)
+ self.owner = try container.decode(Entity.self, forKey: .owner)
+ self.head = try container.decodeIfPresent(Reference.self, forKey: .head)
+ }
+}
diff --git a/Hutch/Models/Todo.swift b/Hutch/Models/Todo.swift
new file mode 100644
index 0000000..8a2b06c
--- /dev/null
+++ b/Hutch/Models/Todo.swift
@@ -0,0 +1,234 @@
+import Foundation
+
+// MARK: - Enums
+
+/// Status of a ticket.
+enum TicketStatus: String, Codable, Sendable, CaseIterable {
+ case reported = "REPORTED"
+ case confirmed = "CONFIRMED"
+ case inProgress = "IN_PROGRESS"
+ case pending = "PENDING"
+ case resolved = "RESOLVED"
+
+ /// Whether this status represents an open (unresolved) ticket.
+ var isOpen: Bool {
+ switch self {
+ case .reported, .confirmed, .inProgress, .pending: true
+ case .resolved: false
+ }
+ }
+
+ var displayName: String {
+ switch self {
+ case .reported: "Reported"
+ case .confirmed: "Confirmed"
+ case .inProgress: "In Progress"
+ case .pending: "Pending"
+ case .resolved: "Resolved"
+ }
+ }
+}
+
+/// Resolution of a ticket.
+enum TicketResolution: String, Codable, Sendable {
+ case unresolved = "UNRESOLVED"
+ case fixed = "FIXED"
+ case implemented = "IMPLEMENTED"
+ case wontFix = "WONT_FIX"
+ case byDesign = "BY_DESIGN"
+ case invalid = "INVALID"
+ case duplicate = "DUPLICATE"
+ case notOurBug = "NOT_OUR_BUG"
+ case closed = "CLOSED"
+ case notApplicable = "NOT_APPLICABLE"
+
+ init(from decoder: Decoder) throws {
+ let value = try decoder.singleValueContainer().decode(String.self)
+ self = TicketResolution(rawValue: value) ?? .unresolved
+ }
+
+ var displayName: String {
+ switch self {
+ case .unresolved: "Unresolved"
+ case .fixed: "Fixed"
+ case .implemented: "Implemented"
+ case .wontFix: "Won't Fix"
+ case .byDesign: "By Design"
+ case .invalid: "Invalid"
+ case .duplicate: "Duplicate"
+ case .notOurBug: "Not Our Bug"
+ case .closed: "Closed"
+ case .notApplicable: "Not Applicable"
+ }
+ }
+}
+
+/// Authenticity of a ticket or comment.
+enum Authenticity: String, Codable, Sendable {
+ case authentic = "AUTHENTIC"
+ case tampered = "TAMPERED"
+ case unauthenticated = "UNAUTHENTICATED"
+}
+
+// MARK: - Label
+
+/// A label that can be applied to tickets.
+/// Named `TicketLabel` to avoid collision with `SwiftUI.Label`.
+struct TicketLabel: Codable, Sendable, Identifiable, Hashable {
+ let id: Int
+ let name: String
+ let backgroundColor: String
+ let foregroundColor: String
+}
+
+// MARK: - Tracker
+
+/// A bug tracker from todo.sr.ht.
+struct Tracker: Codable, Sendable, Identifiable, Hashable {
+ let id: Int
+ let created: Date
+ let updated: Date
+ let name: String
+ let description: String?
+ let visibility: Visibility
+ let owner: Entity
+
+ static func == (lhs: Tracker, rhs: Tracker) -> Bool {
+ lhs.id == rhs.id
+ }
+
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(id)
+ }
+}
+
+// MARK: - Tracker Summary (for list view)
+
+/// Lightweight tracker model matching the fields returned by the trackers list query.
+struct TrackerSummary: Codable, Sendable, Identifiable, Hashable {
+ let id: Int
+ /// GraphQL resource identifier used by `tracker(id:)` queries.
+ let rid: String
+ let name: String
+ let description: String?
+ let visibility: Visibility
+ let updated: Date
+ let owner: Entity
+}
+
+// MARK: - Ticket Summary (for list view)
+
+/// Lightweight ticket model for the list query.
+struct TicketSummary: Codable, Sendable, Identifiable, Hashable {
+ let id: Int
+ let title: String
+ let status: TicketStatus
+ let resolution: TicketResolution?
+ let created: Date
+ let submitter: Entity
+ let labels: [TicketLabel]
+ let assignees: [Entity]
+
+ static func == (lhs: TicketSummary, rhs: TicketSummary) -> Bool {
+ lhs.id == rhs.id
+ }
+
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(id)
+ }
+}
+
+// MARK: - Ticket Detail
+
+/// Full ticket model for the detail view.
+struct TicketDetail: Codable, Sendable {
+ let id: Int
+ let created: Date
+ let updated: Date
+ let title: String
+ let description: String?
+ let status: TicketStatus
+ let resolution: TicketResolution?
+ let authenticity: Authenticity
+ let submitter: Entity
+ let assignees: [Entity]
+ let labels: [TicketLabel]
+}
+
+// MARK: - Event
+
+/// A timeline event on a ticket. Each event contains one or more changes.
+struct TicketEvent: Codable, Sendable, Identifiable {
+ let id: Int
+ let created: Date
+ var changes: [EventChange]
+}
+
+/// A single change within an event, decoded from the polymorphic EventDetail
+/// interface using inline fragments.
+struct EventChange: Codable, Sendable, Identifiable {
+ let id: UUID
+
+ let eventType: String
+
+ // Comment fields
+ let author: Entity?
+ var text: String?
+ let authenticity: Authenticity?
+
+ // StatusChange fields
+ let oldStatus: TicketStatus?
+ let newStatus: TicketStatus?
+
+ // LabelUpdate fields
+ let labeler: Entity?
+ let label: EventLabel?
+
+ // Assignment fields
+ let assigner: Entity?
+ let assignee: Entity?
+
+ // Mention fields
+ // TicketMention: mentioned is a Ticket with { id }
+ // UserMention: mentioned is an Entity with { canonicalName }
+ let mentioned: MentionTarget?
+
+ // Created fields (author is shared with Comment)
+
+ private enum CodingKeys: String, CodingKey {
+ case eventType
+ case author, text, authenticity
+ case oldStatus, newStatus
+ case labeler, label
+ case assigner, assignee
+ case mentioned
+ }
+
+ init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.id = UUID()
+ self.eventType = try container.decode(String.self, forKey: .eventType)
+ self.author = try container.decodeIfPresent(Entity.self, forKey: .author)
+ self.text = try container.decodeIfPresent(String.self, forKey: .text)
+ self.authenticity = try container.decodeIfPresent(Authenticity.self, forKey: .authenticity)
+ self.oldStatus = try container.decodeIfPresent(TicketStatus.self, forKey: .oldStatus)
+ self.newStatus = try container.decodeIfPresent(TicketStatus.self, forKey: .newStatus)
+ self.labeler = try container.decodeIfPresent(Entity.self, forKey: .labeler)
+ self.label = try container.decodeIfPresent(EventLabel.self, forKey: .label)
+ self.assigner = try container.decodeIfPresent(Entity.self, forKey: .assigner)
+ self.assignee = try container.decodeIfPresent(Entity.self, forKey: .assignee)
+ self.mentioned = try container.decodeIfPresent(MentionTarget.self, forKey: .mentioned)
+ }
+}
+
+/// Decoded from the `mentioned` field which may be a Ticket (with `id`) or
+/// an Entity (with `canonicalName`) depending on the event type.
+struct MentionTarget: Codable, Sendable {
+ let id: Int?
+ let canonicalName: String?
+}
+
+/// Label info as returned within a LabelUpdate event change.
+struct EventLabel: Codable, Sendable {
+ let name: String
+}
diff --git a/Hutch/Models/User.swift b/Hutch/Models/User.swift
new file mode 100644
index 0000000..a9350a3
--- /dev/null
+++ b/Hutch/Models/User.swift
@@ -0,0 +1,10 @@
+import Foundation
+
+/// A Sourcehut user from meta.sr.ht.
+struct User: Decodable, Sendable {
+ let id: Int
+ let username: String
+ let canonicalName: String
+ let email: String
+ let avatar: String?
+}