krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.13.1: Hutch/Views/Home/HomeViewModel.swift · raw
1import Foundation
2import os
3
4private let homeLogger = Logger(subsystem: "net.cleberg.Hutch", category: "Home")
5
6private struct HomeJobsResponse: Decodable, Sendable {
7 let jobs: HomeJobsPage
8}
9
10private struct HomeJobsPage: Decodable, Sendable {
11 let results: [HomeJobPayload]
12}
13
14private struct HomeTrackersResponse: Decodable, Sendable {
15 let trackers: HomeTrackersPage
16}
17
18private struct HomeTrackersPage: Decodable, Sendable {
19 let results: [TrackerSummary]
20 let cursor: String?
21}
22
23private struct HomeTrackerTicketsResponse: Decodable, Sendable {
24 let user: HomeTrackerTicketsUser
25}
26
27private struct HomeTrackerTicketsUser: Decodable, Sendable {
28 let tracker: HomeTrackerTicketsTracker
29}
30
31private struct HomeTrackerTicketsTracker: Decodable, Sendable {
32 let tickets: HomeTrackerTicketsPage
33}
34
35private struct HomeTrackerTicketsPage: Decodable, Sendable {
36 let results: [HomeTicketPayload]
37}
38
39private struct HomeInboxSubscriptionsResponse: Decodable, Sendable {
40 let subscriptions: HomeInboxSubscriptionPage
41}
42
43private struct HomeInboxSubscriptionPage: Decodable, Sendable {
44 let results: [HomeInboxSubscription]
45 let cursor: String?
46}
47
48private struct HomeInboxSubscription: Decodable, Sendable {
49 let list: InboxMailingListReference?
50}
51
52private struct HomeInboxListThreadsResponse: Decodable, Sendable {
53 let list: HomeInboxMailingListThreads
54}
55
56private struct HomeInboxMailingListThreads: Decodable, Sendable {
57 let threads: HomeInboxThreadPage
58}
59
60private struct HomeInboxThreadPage: Decodable, Sendable {
61 let results: [HomeInboxThreadPayload]
62 let cursor: String?
63}
64
65private struct HomeInboxThreadPayload: Decodable, Sendable {
66 let updated: Date
67 let subject: String
68}
69
70private struct HomeTicketPayload: Decodable, Sendable {
71 let id: Int
72 let title: String
73 let status: TicketStatus
74 let resolution: TicketResolution?
75 let created: Date
76 let submitter: Entity
77 let labels: [TicketLabel]
78 let assignees: [Entity]
79
80 enum CodingKeys: String, CodingKey {
81 case id
82 case title = "subject"
83 case status
84 case resolution
85 case created
86 case submitter
87 case labels
88 case assignees
89 }
90
91 var ticketSummary: TicketSummary {
92 TicketSummary(
93 id: id,
94 title: title,
95 status: status,
96 resolution: resolution,
97 created: created,
98 submitter: submitter,
99 labels: labels,
100 assignees: assignees
101 )
102 }
103}
104
105struct HomeAssignedTicket: Identifiable, Hashable, Sendable {
106 let trackerId: Int
107 let trackerRid: String
108 let trackerName: String
109 let ownerCanonicalName: String
110 let ticket: TicketSummary
111
112 var id: String {
113 "\(trackerRid)#\(ticket.id)"
114 }
115
116 var ownerUsername: String {
117 if ownerCanonicalName.hasPrefix("~") {
118 return String(ownerCanonicalName.dropFirst())
119 }
120 return ownerCanonicalName
121 }
122}
123
124struct HomeBuildItem: Identifiable, Hashable, Sendable {
125 let job: JobSummary
126 let repositoryName: String?
127 let repositoryOwner: String?
128
129 var id: Int { job.id }
130
131 var repositoryDisplayName: String? {
132 guard let repositoryName else { return nil }
133 if let repositoryOwner {
134 return "\(repositoryOwner)/\(repositoryName)"
135 }
136 return repositoryName
137 }
138}
139
140@Observable
141@MainActor
142final class HomeViewModel {
143 private(set) var projects: [Project] = []
144 var assignedTickets: [HomeAssignedTicket] = []
145 var recentBuilds: [HomeBuildItem] = []
146 private(set) var systemStatusSnapshot: SystemStatusSnapshot?
147 private(set) var isLoadingSystemStatus = false
148 private(set) var isShowingStaleSystemStatus = false
149 private(set) var systemStatusErrorMessage: String?
150 private(set) var hasUnreadInboxThreads = false
151 private(set) var unreadInboxThreadCount: Int?
152 private(set) var isLoadingProjects = false
153 private(set) var isLoadingAssignedTickets = false
154 private(set) var isLoadingRecentBuilds = false
155 private(set) var assignedTicketsError: String?
156 private(set) var recentBuildsError: String?
157
158 private let currentUser: User
159 private let client: SRHTClient
160 private let systemStatusRepository: SystemStatusRepository
161 private let projectService: ProjectService
162 private let ticketFetchConcurrencyLimit = 6
163 private let inboxUnreadConcurrencyLimit = 4
164
165 private static let jobsQuery = """
166 query jobs {
167 jobs {
168 results {
169 id
170 created
171 updated
172 status
173 note
174 tags
175 visibility
176 image
177 tasks { name status }
178 manifest
179 }
180 }
181 }
182 """
183
184 private static let trackersQuery = """
185 query trackers($cursor: Cursor) {
186 trackers(cursor: $cursor) {
187 results {
188 id
189 rid
190 name
191 description
192 visibility
193 updated
194 owner { canonicalName }
195 }
196 cursor
197 }
198 }
199 """
200
201 private static let trackerTicketsQuery = """
202 query tickets($owner: String!, $tracker: String!) {
203 user(username: $owner) {
204 tracker(name: $tracker) {
205 tickets {
206 results {
207 id
208 subject
209 status
210 resolution
211 created
212 submitter { canonicalName }
213 labels { id name backgroundColor foregroundColor }
214 assignees { canonicalName }
215 }
216 }
217 }
218 }
219 }
220 """
221
222 private static let inboxSubscriptionsQuery = """
223 query inboxSubscriptions($cursor: Cursor) {
224 subscriptions(cursor: $cursor) {
225 results {
226 ... on MailingListSubscription {
227 list {
228 id
229 rid
230 name
231 owner { canonicalName }
232 }
233 }
234 }
235 cursor
236 }
237 }
238 """
239
240 private static let inboxListThreadsQuery = """
241 query inboxListThreads($rid: ID!, $cursor: Cursor) {
242 list(rid: $rid) {
243 threads(cursor: $cursor) {
244 results {
245 updated
246 subject
247 }
248 cursor
249 }
250 }
251 }
252 """
253
254 private static let updateTicketStatusMutation = """
255 mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) {
256 updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
257 eventType: __typename
258 }
259 }
260 """
261
262 private static let unassignUserMutation = """
263 mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
264 unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
265 }
266 """
267
268 private static let cancelBuildMutation = """
269 mutation cancel($id: Int!) {
270 cancel(jobId: $id) { id }
271 }
272 """
273
274 init(currentUser: User, client: SRHTClient, systemStatusRepository: SystemStatusRepository) {
275 self.currentUser = currentUser
276 self.client = client
277 self.systemStatusRepository = systemStatusRepository
278 self.projectService = ProjectService(client: client)
279 }
280
281 func loadDashboard() async {
282 isLoadingProjects = true
283 isLoadingAssignedTickets = true
284 isLoadingRecentBuilds = true
285 isLoadingSystemStatus = true
286 assignedTicketsError = nil
287 recentBuildsError = nil
288 isShowingStaleSystemStatus = false
289 systemStatusErrorMessage = nil
290
291 async let projectsTask = loadProjects()
292 async let jobsTask = loadRecentJobs()
293 async let assignedTicketsTask = loadAssignedTickets()
294 async let inboxUnreadTask = loadInboxUnreadCount()
295 async let systemStatusTask = loadSystemStatusSnapshot()
296
297 let projectsResult = await projectsTask
298 switch projectsResult {
299 case .success(let projects):
300 self.projects = projects
301 case .failure:
302 self.projects = []
303 }
304 isLoadingProjects = false
305
306 let recentJobsResult = await jobsTask
307
308 switch recentJobsResult {
309 case .success(let recentJobs):
310 let buildItems = Self.buildItems(from: recentJobs)
311 self.recentBuilds = buildItems
312 self.recentBuildsError = nil
313 case .failure(let error):
314 self.recentBuilds = []
315 self.recentBuildsError = error.userFacingMessage
316 }
317 isLoadingRecentBuilds = false
318
319 let assignedTicketsResult = await assignedTicketsTask
320
321 switch assignedTicketsResult {
322 case .success(let assignedTickets):
323 self.assignedTickets = assignedTickets
324 self.assignedTicketsError = nil
325 case .failure(let error):
326 self.assignedTickets = []
327 self.assignedTicketsError = error.userFacingMessage
328 }
329 isLoadingAssignedTickets = false
330
331 unreadInboxThreadCount = await inboxUnreadTask
332 hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
333 let systemStatusResult = await systemStatusTask
334 switch systemStatusResult {
335 case .success(let result):
336 systemStatusSnapshot = result.value
337 isShowingStaleSystemStatus = result.isStale
338 systemStatusErrorMessage = result.isStale ? result.refreshErrorMessage : nil
339 case .failure(let error):
340 systemStatusErrorMessage = error.userFacingMessage
341 }
342 isLoadingSystemStatus = false
343 persistNeedsAttentionSnapshot()
344 }
345
346 var hasDashboardContent: Bool {
347 !projects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || systemStatusSnapshot != nil
348 }
349
350 func resolveTicket(_ ticket: HomeAssignedTicket) async {
351 let input: [String: any Sendable] = [
352 "status": TicketStatus.resolved.rawValue,
353 "resolution": TicketResolution.fixed.rawValue
354 ]
355 await performTicketStatusUpdate(ticket: ticket, input: input)
356 }
357
358 func reopenTicket(_ ticket: HomeAssignedTicket) async {
359 let input: [String: any Sendable] = [
360 "status": TicketStatus.reported.rawValue
361 ]
362 await performTicketStatusUpdate(ticket: ticket, input: input)
363 }
364
365 func unassignFromMe(_ ticket: HomeAssignedTicket) async {
366 do {
367 _ = try await client.execute(
368 service: .todo,
369 query: Self.unassignUserMutation,
370 variables: [
371 "trackerId": ticket.trackerId,
372 "ticketId": ticket.ticket.id,
373 "userId": currentUser.id
374 ],
375 responseType: UnassignResponse.self
376 )
377 assignedTickets.removeAll { $0.id == ticket.id }
378 persistNeedsAttentionSnapshot()
379 } catch {
380 homeLogger.error("Unassign from me failed: \(error, privacy: .public)")
381 }
382 }
383
384 func cancelBuild(_ build: HomeBuildItem) async {
385 guard build.job.status.isCancellable else { return }
386
387 do {
388 _ = try await client.execute(
389 service: .builds,
390 query: Self.cancelBuildMutation,
391 variables: ["id": build.job.id],
392 responseType: CancelBuildResponse.self
393 )
394 if let index = recentBuilds.firstIndex(where: { $0.id == build.id }) {
395 let updatedJob = JobSummary(
396 id: build.job.id,
397 created: build.job.created,
398 updated: build.job.updated,
399 status: .cancelled,
400 note: build.job.note,
401 tags: build.job.tags,
402 visibility: build.job.visibility,
403 image: build.job.image,
404 tasks: build.job.tasks
405 )
406 recentBuilds[index] = HomeBuildItem(
407 job: updatedJob,
408 repositoryName: build.repositoryName,
409 repositoryOwner: build.repositoryOwner
410 )
411 }
412 persistNeedsAttentionSnapshot()
413 } catch {
414 homeLogger.error("Cancel build failed: \(error, privacy: .public)")
415 }
416 }
417
418 private func loadProjects() async -> Result<[Project], Error> {
419 do {
420 return .success(try await projectService.fetchProjects())
421 } catch {
422 return .failure(error)
423 }
424 }
425
426 private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> {
427 do {
428 let response = try await client.execute(
429 service: .builds,
430 query: Self.jobsQuery,
431 responseType: HomeJobsResponse.self
432 )
433 return .success(response.jobs.results)
434 } catch {
435 return .failure(error)
436 }
437 }
438
439 private func loadInboxUnreadCount() async -> Int? {
440 do {
441 return try await fetchUnreadInboxThreadCount()
442 } catch {
443 return nil
444 }
445 }
446
447 private func loadSystemStatusSnapshot() async -> Result<CachedSystemStatusValue<SystemStatusSnapshot>, Error> {
448 do {
449 return .success(try await systemStatusRepository.snapshotResult())
450 } catch {
451 return .failure(error)
452 }
453 }
454
455 private func fetchUnreadInboxThreadCount() async throws -> Int {
456 let mailingLists = try await fetchInboxMailingLists()
457 guard !mailingLists.isEmpty else { return 0 }
458
459 var startIndex = mailingLists.startIndex
460 var unreadCount = 0
461 var successfulFetchCount = 0
462 while startIndex < mailingLists.endIndex {
463 let endIndex = mailingLists.index(
464 startIndex,
465 offsetBy: inboxUnreadConcurrencyLimit,
466 limitedBy: mailingLists.endIndex
467 ) ?? mailingLists.endIndex
468 let batch = Array(mailingLists[startIndex..<endIndex])
469
470 let batchResult = await withTaskGroup(of: Result<Int, Error>.self) { group in
471 for mailingList in batch {
472 group.addTask {
473 do {
474 return .success(try await self.fetchUnreadThreadCount(for: mailingList))
475 } catch {
476 return .failure(error)
477 }
478 }
479 }
480
481 var counts: [Int] = []
482 var errors: [Error] = []
483 for await result in group {
484 switch result {
485 case .success(let count):
486 counts.append(count)
487 case .failure(let error):
488 errors.append(error)
489 }
490 }
491 return (counts, errors)
492 }
493
494 unreadCount += batchResult.0.reduce(0, +)
495 successfulFetchCount += batchResult.0.count
496
497 startIndex = endIndex
498 }
499
500 guard successfulFetchCount > 0 else {
501 throw SRHTError.graphQLErrors([GraphQLError(message: "Failed to load inbox threads", locations: nil)])
502 }
503
504 return unreadCount
505 }
506
507 private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] {
508 var subscriptions: [HomeInboxSubscription] = []
509 var cursor: String?
510
511 while true {
512 var variables: [String: any Sendable] = [:]
513 if let cursor {
514 variables["cursor"] = cursor
515 }
516
517 let response = try await client.execute(
518 service: .lists,
519 query: Self.inboxSubscriptionsQuery,
520 variables: variables.isEmpty ? nil : variables,
521 responseType: HomeInboxSubscriptionsResponse.self
522 )
523
524 subscriptions.append(contentsOf: response.subscriptions.results)
525 guard let nextCursor = response.subscriptions.cursor else {
526 break
527 }
528 cursor = nextCursor
529 }
530
531 var seen = Set<String>()
532 return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted }
533 }
534
535 private func fetchUnreadThreadCount(for mailingList: InboxMailingListReference) async throws -> Int {
536 var unreadCount = 0
537 var cursor: String?
538
539 while true {
540 var variables: [String: any Sendable] = ["rid": mailingList.rid]
541 if let cursor {
542 variables["cursor"] = cursor
543 }
544
545 let response = try await client.execute(
546 service: .lists,
547 query: Self.inboxListThreadsQuery,
548 variables: variables,
549 responseType: HomeInboxListThreadsResponse.self
550 )
551
552 unreadCount += response.list.threads.results.filter { thread in
553 let normalizedSubject = thread.subject
554 .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
555 .trimmingCharacters(in: .whitespacesAndNewlines)
556 .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive])
557 .lowercased()
558 let threadID = "\(mailingList.rid)#\(normalizedSubject)"
559 return InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated)
560 }.count
561
562 guard let nextCursor = response.list.threads.cursor else {
563 break
564 }
565 cursor = nextCursor
566 }
567
568 return unreadCount
569 }
570
571 private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> {
572 do {
573 let trackers = try await fetchAllTrackers()
574 let tickets = try await fetchAssignedTickets(for: trackers)
575 .sorted { $0.ticket.created > $1.ticket.created }
576 return .success(tickets)
577 } catch {
578 return .failure(error)
579 }
580 }
581
582 private func fetchAllTrackers() async throws -> [TrackerSummary] {
583 var allTrackers: [TrackerSummary] = []
584 var cursor: String?
585
586 while true {
587 var variables: [String: any Sendable] = [:]
588 if let cursor {
589 variables["cursor"] = cursor
590 }
591
592 let response = try await client.execute(
593 service: .todo,
594 query: Self.trackersQuery,
595 variables: variables.isEmpty ? nil : variables,
596 responseType: HomeTrackersResponse.self
597 )
598
599 allTrackers.append(contentsOf: response.trackers.results)
600 guard let nextCursor = response.trackers.cursor else {
601 break
602 }
603 cursor = nextCursor
604 }
605
606 return allTrackers
607 }
608
609 private func fetchAssignedTickets(for trackers: [TrackerSummary]) async throws -> [HomeAssignedTicket] {
610 guard !trackers.isEmpty else { return [] }
611
612 var assignedTickets: [HomeAssignedTicket] = []
613 var startIndex = trackers.startIndex
614
615 while startIndex < trackers.endIndex {
616 let endIndex = trackers.index(startIndex, offsetBy: ticketFetchConcurrencyLimit, limitedBy: trackers.endIndex) ?? trackers.endIndex
617 let batch = Array(trackers[startIndex..<endIndex])
618
619 let batchTickets = try await withThrowingTaskGroup(of: [HomeAssignedTicket].self) { group in
620 for tracker in batch {
621 group.addTask {
622 try await self.fetchAssignedTickets(for: tracker)
623 }
624 }
625
626 var ticketsForBatch: [HomeAssignedTicket] = []
627 for try await tickets in group {
628 ticketsForBatch.append(contentsOf: tickets)
629 }
630 return ticketsForBatch
631 }
632
633 assignedTickets.append(contentsOf: batchTickets)
634 startIndex = endIndex
635 }
636
637 return assignedTickets
638 }
639
640 private func fetchAssignedTickets(for tracker: TrackerSummary) async throws -> [HomeAssignedTicket] {
641 let response = try await client.execute(
642 service: .todo,
643 query: Self.trackerTicketsQuery,
644 variables: [
645 "owner": tracker.owner.canonicalName.hasPrefix("~")
646 ? String(tracker.owner.canonicalName.dropFirst())
647 : tracker.owner.canonicalName,
648 "tracker": tracker.name
649 ],
650 responseType: HomeTrackerTicketsResponse.self
651 )
652
653 return response.user.tracker.tickets.results.compactMap { payload in
654 guard payload.status.isOpen else {
655 return nil
656 }
657 guard payload.assignees.contains(where: { Self.matchesCurrentUserAssignee($0, currentUser: currentUser) }) else {
658 return nil
659 }
660
661 return HomeAssignedTicket(
662 trackerId: tracker.id,
663 trackerRid: tracker.rid,
664 trackerName: tracker.name,
665 ownerCanonicalName: tracker.owner.canonicalName,
666 ticket: payload.ticketSummary
667 )
668 }
669 }
670
671 private func performTicketStatusUpdate(
672 ticket: HomeAssignedTicket,
673 input: [String: any Sendable]
674 ) async {
675 do {
676 _ = try await client.execute(
677 service: .todo,
678 query: Self.updateTicketStatusMutation,
679 variables: [
680 "trackerId": ticket.trackerId,
681 "ticketId": ticket.ticket.id,
682 "input": input
683 ],
684 responseType: StatusEventResponse.self
685 )
686 assignedTickets.removeAll { $0.id == ticket.id }
687 persistNeedsAttentionSnapshot()
688 } catch {
689 homeLogger.error("Ticket status update failed: \(error, privacy: .public)")
690 }
691 }
692
693 private func persistNeedsAttentionSnapshot() {
694 NeedsAttentionSnapshotStore.save(
695 NeedsAttentionSnapshot(
696 unreadInboxThreads: unreadInboxThreadCount,
697 assignedOpenTickets: assignedTicketsError == nil ? assignedTickets.count : nil,
698 failedBuilds: recentBuildsError == nil
699 ? recentBuilds.filter {
700 switch $0.job.status {
701 case .failed, .timeout:
702 true
703 default:
704 false
705 }
706 }.count
707 : nil,
708 updatedAt: .now
709 )
710 )
711 }
712
713 nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
714 jobs.map { job in
715 let repository = primaryRepositoryReference(in: job.manifest)
716 return HomeBuildItem(
717 job: job.jobSummary,
718 repositoryName: repository?.name,
719 repositoryOwner: repository?.ownerCanonicalName
720 )
721 }
722 }
723
724 nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
725 buildItems(from: jobs).filter { build in
726 switch build.job.status {
727 case .failed, .timeout:
728 true
729 default:
730 false
731 }
732 }
733 }
734
735 nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool {
736 let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
737 let currentCanonical = normalizedCanonicalName(currentUser.canonicalName)
738 if assigneeCanonical == currentCanonical {
739 return true
740 }
741
742 let assigneeUsername = normalizedUsername(entity.canonicalName)
743 let currentUsername = normalizedUsername(currentUser.username)
744 return assigneeUsername == currentUsername
745 }
746
747 nonisolated static func primaryRepositoryReference(in manifest: String?) -> (ownerCanonicalName: String, name: String)? {
748 guard let manifest else { return nil }
749 let pattern = #"(?:https://|ssh://(?:git|hg)@|(?:git|hg)@)(?:git|hg)\.sr\.ht[:/]([~][^/\s]+)/([^\s"'#]+)"#
750 guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
751 return nil
752 }
753 let nsRange = NSRange(manifest.startIndex..<manifest.endIndex, in: manifest)
754 guard let match = regex.firstMatch(in: manifest, options: [], range: nsRange),
755 let ownerRange = Range(match.range(at: 1), in: manifest),
756 let nameRange = Range(match.range(at: 2), in: manifest) else {
757 return nil
758 }
759
760 let owner = String(manifest[ownerRange])
761 var name = String(manifest[nameRange])
762 if let suffixRange = name.range(of: ".git", options: [.backwards, .anchored]) {
763 name.removeSubrange(suffixRange)
764 }
765 name = name.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
766 guard !name.isEmpty else { return nil }
767 return (owner, name)
768 }
769
770 private nonisolated static func normalizedCanonicalName(_ value: String) -> String {
771 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
772 guard !trimmed.isEmpty else { return trimmed }
773 if trimmed.hasPrefix("~") {
774 return trimmed
775 }
776 return "~\(trimmed)"
777 }
778
779 private nonisolated static func normalizedUsername(_ value: String) -> String {
780 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
781 if trimmed.hasPrefix("~") {
782 return String(trimmed.dropFirst())
783 }
784 return trimmed
785 }
786
787 private struct StatusEventResponse: Decodable, Sendable {
788 struct EventRef: Decodable, Sendable {
789 let eventType: String
790 }
791
792 let updateTicketStatus: EventRef
793 }
794
795 private struct UnassignResponse: Decodable, Sendable {
796 struct EventRef: Decodable, Sendable {
797 let id: Int
798 }
799
800 let unassignUser: EventRef
801 }
802
803 private struct CancelBuildResponse: Decodable, Sendable {
804 struct CancelResult: Decodable, Sendable {
805 let id: Int
806 }
807
808 let cancel: CancelResult
809 }
810}
811struct HomeJobPayload: Decodable, Sendable {
812 let id: Int
813 let created: Date
814 let updated: Date
815 let status: JobStatus
816 let note: String?
817 let tags: [String]
818 let visibility: Visibility?
819 let image: String?
820 let tasks: [JobTaskSummary]
821 let manifest: String?
822
823 nonisolated var jobSummary: JobSummary {
824 JobSummary(
825 id: id,
826 created: created,
827 updated: updated,
828 status: status,
829 note: note,
830 tags: tags,
831 visibility: visibility,
832 image: image,
833 tasks: tasks
834 )
835 }
836}