krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2: Hutch/Views/Builds/BuildDetailViewModel.swift · raw
1import Foundation
2
3// MARK: - Response types
4
5private struct JobDetailResponse: Decodable, Sendable {
6 let job: JobDetail
7}
8
9private struct CancelResponse: Decodable, Sendable {
10 let cancel: CancelResult
11}
12
13private struct CancelResult: Decodable, Sendable {
14 let id: Int
15}
16
17private struct SubmitJobResponse: Decodable, Sendable {
18 let submit: SubmittedJob
19}
20
21private struct SubmittedJob: Decodable, Sendable {
22 let id: Int
23}
24
25// MARK: - View Model
26
27@Observable
28@MainActor
29final class BuildDetailViewModel {
30
31 let jobId: Int
32 private let client: SRHTClient
33
34 private(set) var job: JobDetail?
35 private(set) var isLoading = false
36 private(set) var taskLogs: [String: String] = [:]
37 private(set) var loadingTaskLogs: Set<String> = []
38 private(set) var isCancelling = false
39 private(set) var isRebuilding = false
40 private(set) var isSubmittingEditedBuild = false
41 var error: String?
42
43 init(jobId: Int, client: SRHTClient) {
44 self.jobId = jobId
45 self.client = client
46 }
47
48 // MARK: - Queries
49
50 private static let detailQuery = """
51 query job($id: Int!) {
52 job(id: $id) {
53 id
54 created
55 updated
56 status
57 note
58 tags
59 visibility
60 image
61 manifest
62 tasks { name status log { fullURL } }
63 log { fullURL }
64 owner { canonicalName }
65 }
66 }
67 """
68
69 private static let cancelMutation = """
70 mutation cancel($id: Int!) {
71 cancel(jobId: $id) {
72 id
73 }
74 }
75 """
76
77 private static let submitMutation = """
78 mutation submit($manifest: String!, $tags: [String!], $note: String, $visibility: Visibility) {
79 submit(manifest: $manifest, tags: $tags, note: $note, visibility: $visibility) {
80 id
81 }
82 }
83 """
84
85 private static let editableSubmitMutation = """
86 mutation submit($manifest: String!, $tags: [String!], $note: String, $secrets: Boolean, $execute: Boolean, $visibility: Visibility) {
87 submit(manifest: $manifest, tags: $tags, note: $note, secrets: $secrets, execute: $execute, visibility: $visibility) {
88 id
89 }
90 }
91 """
92
93 // MARK: - Public API
94
95 func loadJob() async {
96 guard !isLoading else { return }
97 isLoading = true
98 error = nil
99
100 do {
101 let result = try await client.execute(
102 service: .builds,
103 query: Self.detailQuery,
104 variables: ["id": jobId],
105 responseType: JobDetailResponse.self
106 )
107 var loadedJob = result.job
108 loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in
109 task.withOrdinal(index)
110 }
111 job = loadedJob
112 } catch {
113 self.error = error.localizedDescription
114 }
115
116 isLoading = false
117 }
118
119 func loadTaskLog(task: BuildTask) async {
120 let cacheKey = task.logCacheKey
121 guard let log = task.log,
122 let logURL = URL(string: log.fullURL),
123 !loadingTaskLogs.contains(cacheKey),
124 taskLogs[cacheKey] == nil else { return }
125 loadingTaskLogs.insert(cacheKey)
126
127 do {
128 taskLogs[cacheKey] = try await client.fetchText(url: logURL)
129 } catch {
130 self.error = error.localizedDescription
131 }
132
133 loadingTaskLogs.remove(cacheKey)
134 }
135
136 func cancelJob() async {
137 guard let job, job.status.isCancellable, !isCancelling else { return }
138 isCancelling = true
139 error = nil
140
141 do {
142 _ = try await client.execute(
143 service: .builds,
144 query: Self.cancelMutation,
145 variables: ["id": jobId],
146 responseType: CancelResponse.self
147 )
148 // Reload job to get updated status.
149 await loadJob()
150 } catch {
151 self.error = error.localizedDescription
152 }
153
154 isCancelling = false
155 }
156
157 func rebuildJob() async -> Int? {
158 guard let job, let manifest = job.manifest, !manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !isRebuilding else {
159 return nil
160 }
161
162 isRebuilding = true
163 error = nil
164 defer { isRebuilding = false }
165
166 var variables: [String: any Sendable] = [
167 "manifest": manifest.trimmingCharacters(in: .whitespacesAndNewlines)
168 ]
169 if !job.tags.isEmpty {
170 variables["tags"] = job.tags
171 }
172 if let note = job.note, !note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
173 variables["note"] = note.trimmingCharacters(in: .whitespacesAndNewlines)
174 }
175 if let visibility = job.visibility {
176 variables["visibility"] = visibility.rawValue
177 }
178
179 do {
180 let result = try await client.execute(
181 service: .builds,
182 query: Self.submitMutation,
183 variables: variables,
184 responseType: SubmitJobResponse.self
185 )
186 return result.submit.id
187 } catch {
188 self.error = error.localizedDescription
189 return nil
190 }
191 }
192
193 func submitBuild(
194 manifest: String,
195 tags: [String],
196 note: String,
197 secrets: Bool,
198 execute: Bool,
199 visibility: Visibility
200 ) async -> Int? {
201 guard !isSubmittingEditedBuild else { return nil }
202
203 let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines)
204 guard !trimmedManifest.isEmpty else {
205 error = "Paste a build manifest."
206 return nil
207 }
208
209 isSubmittingEditedBuild = true
210 error = nil
211 defer { isSubmittingEditedBuild = false }
212
213 var variables: [String: any Sendable] = [
214 "manifest": trimmedManifest,
215 "secrets": secrets,
216 "execute": execute,
217 "visibility": visibility.rawValue
218 ]
219 if !tags.isEmpty {
220 variables["tags"] = tags
221 }
222 let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines)
223 if !trimmedNote.isEmpty {
224 variables["note"] = trimmedNote
225 }
226
227 do {
228 let result = try await client.execute(
229 service: .builds,
230 query: Self.editableSubmitMutation,
231 variables: variables,
232 responseType: SubmitJobResponse.self
233 )
234 return result.submit.id
235 } catch {
236 self.error = "Couldn’t submit the build. \(error.localizedDescription)"
237 return nil
238 }
239 }
240}