diff options
Diffstat (limited to 'Hutch/Models')
| -rw-r--r-- | Hutch/Models/ArtifactInfo.swift | 22 | ||||
| -rw-r--r-- | Hutch/Models/Builds.swift | 111 | ||||
| -rw-r--r-- | Hutch/Models/CommitDetail.swift | 65 | ||||
| -rw-r--r-- | Hutch/Models/CommitSummary.swift | 22 | ||||
| -rw-r--r-- | Hutch/Models/Git.swift | 241 | ||||
| -rw-r--r-- | Hutch/Models/Meta.swift | 65 | ||||
| -rw-r--r-- | Hutch/Models/RepositorySummary.swift | 57 | ||||
| -rw-r--r-- | Hutch/Models/Todo.swift | 234 | ||||
| -rw-r--r-- | Hutch/Models/User.swift | 10 |
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? +} |
