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