krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.10.1: 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 private static let autoRefreshInterval: Duration = .seconds(5)
31
32 let jobId: Int
33 private let client: SRHTClient
34
35 private var autoRefreshTask: Task<Void, Never>?
36 private(set) var job: JobDetail?
37 private(set) var isLoading = false
38 private(set) var buildLogText: String?
39 private(set) var isLoadingBuildLog = false
40 private(set) var taskLogs: [String: String] = [:]
41 private(set) var loadingTaskLogs: Set<String> = []
42 private(set) var failedTaskLogs: Set<String> = []
43 private var taskLogRetryCounts: [String: Int] = [:]
44 private(set) var isCancelling = false
45 private(set) var isRebuilding = false
46 private(set) var isSubmittingEditedBuild = false
47 var error: String?
48
49 init(jobId: Int, client: SRHTClient) {
50 self.jobId = jobId
51 self.client = client
52 }
53
54 // MARK: - Queries
55
56 private static let detailQuery = """
57 query job($id: Int!) {
58 job(id: $id) {
59 id
60 created
61 updated
62 status
63 note
64 tags
65 visibility
66 image
67 manifest
68 tasks { name status log { fullURL } }
69 log { fullURL }
70 owner { canonicalName }
71 }
72 }
73 """
74
75 private static let cancelMutation = """
76 mutation cancel($id: Int!) {
77 cancel(jobId: $id) {
78 id
79 }
80 }
81 """
82
83 private static let submitMutation = """
84 mutation submit($manifest: String!, $tags: [String!], $note: String, $visibility: Visibility) {
85 submit(manifest: $manifest, tags: $tags, note: $note, visibility: $visibility) {
86 id
87 }
88 }
89 """
90
91 private static let editableSubmitMutation = """
92 mutation submit($manifest: String!, $tags: [String!], $note: String, $secrets: Boolean, $execute: Boolean, $visibility: Visibility) {
93 submit(manifest: $manifest, tags: $tags, note: $note, secrets: $secrets, execute: $execute, visibility: $visibility) {
94 id
95 }
96 }
97 """
98
99 // MARK: - Public API
100
101 func loadJob() async {
102 guard !isLoading else { return }
103 isLoading = true
104 error = nil
105
106 do {
107 let result = try await client.execute(
108 service: .builds,
109 query: Self.detailQuery,
110 variables: ["id": jobId],
111 responseType: JobDetailResponse.self
112 )
113 var loadedJob = result.job
114 loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in
115 task.withOrdinal(index)
116 }
117 if job != loadedJob {
118 job = loadedJob
119 }
120
121 if loadedJob.status.isTerminal {
122 stopAutoRefresh()
123 }
124 } catch {
125 self.error = error.userFacingMessage
126 }
127
128 isLoading = false
129 }
130
131 func loadTaskLog(task: BuildTask) async {
132 let cacheKey = task.logCacheKey
133 let jobIsTerminal = job?.status.isTerminal ?? false
134
135 // Task-specific logs are only fetched after the job reaches a terminal
136 // state. While the build is active, the UI shows the shared live build log.
137 guard let log = task.log,
138 let logURL = URL(string: log.fullURL),
139 !loadingTaskLogs.contains(cacheKey) else { return }
140 guard jobIsTerminal else { return }
141 if jobIsTerminal, taskLogs[cacheKey] != nil { return }
142 failedTaskLogs.remove(cacheKey)
143 loadingTaskLogs.insert(cacheKey)
144
145 do {
146 taskLogs[cacheKey] = try await client.fetchText(url: logURL)
147 failedTaskLogs.remove(cacheKey)
148 } catch {
149 failedTaskLogs.insert(cacheKey)
150 self.error = error.userFacingMessage
151 }
152
153 loadingTaskLogs.remove(cacheKey)
154 }
155
156 func loadBuildLog() async {
157 guard let log = job?.log,
158 let logURL = URL(string: log.fullURL),
159 !isLoadingBuildLog else { return }
160
161 let jobIsTerminal = job?.status.isTerminal ?? false
162 if jobIsTerminal, buildLogText != nil { return }
163
164 isLoadingBuildLog = true
165
166 do {
167 buildLogText = try await client.fetchText(url: logURL)
168 } catch {
169 self.error = error.userFacingMessage
170 }
171
172 isLoadingBuildLog = false
173 }
174
175 func retryTaskLog(task: BuildTask) async {
176 let cacheKey = task.logCacheKey
177 failedTaskLogs.remove(cacheKey)
178 taskLogRetryCounts[cacheKey, default: 0] += 1
179 await loadTaskLog(task: task)
180 }
181
182 func displayedLogText(for task: BuildTask?) -> String? {
183 guard let task else { return nil }
184 guard let job else { return nil }
185
186 if !job.status.isTerminal {
187 return buildLogText
188 }
189
190 return taskLogs[task.logCacheKey] ?? buildLogText
191 }
192
193 func isShowingBuildLogFallback(for task: BuildTask?) -> Bool {
194 guard let task, let job else { return false }
195 if !job.status.isTerminal {
196 return buildLogText != nil
197 }
198
199 return taskLogs[task.logCacheKey] == nil && buildLogText != nil
200 }
201
202 func taskLogTrigger(for task: BuildTask?) -> String? {
203 guard let task, let logURL = task.log?.fullURL else { return nil }
204 let retryCount = taskLogRetryCounts[task.logCacheKey, default: 0]
205 let isTerminal = job?.status.isTerminal ?? false
206 return "\(logURL)#\(retryCount)#\(isTerminal)"
207 }
208
209 func cancelJob() async {
210 guard let job, job.status.isCancellable, !isCancelling else { return }
211 isCancelling = true
212 error = nil
213
214 do {
215 _ = try await client.execute(
216 service: .builds,
217 query: Self.cancelMutation,
218 variables: ["id": jobId],
219 responseType: CancelResponse.self
220 )
221 // Reload job to get updated status.
222 await loadJob()
223 } catch {
224 self.error = error.userFacingMessage
225 }
226
227 isCancelling = false
228 }
229
230 func rebuildJob() async -> Int? {
231 guard let job, let manifest = job.manifest, !manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !isRebuilding else {
232 return nil
233 }
234
235 isRebuilding = true
236 error = nil
237 defer { isRebuilding = false }
238
239 var variables: [String: any Sendable] = [
240 "manifest": manifest.trimmingCharacters(in: .whitespacesAndNewlines)
241 ]
242 if !job.tags.isEmpty {
243 variables["tags"] = job.tags
244 }
245 if let note = job.note, !note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
246 variables["note"] = note.trimmingCharacters(in: .whitespacesAndNewlines)
247 }
248 if let visibility = job.visibility {
249 variables["visibility"] = visibility.rawValue
250 }
251
252 do {
253 let result = try await client.execute(
254 service: .builds,
255 query: Self.submitMutation,
256 variables: variables,
257 responseType: SubmitJobResponse.self
258 )
259 return result.submit.id
260 } catch {
261 self.error = error.userFacingMessage
262 return nil
263 }
264 }
265
266 func submitBuild(
267 manifest: String,
268 tags: [String],
269 note: String,
270 secrets: Bool,
271 execute: Bool,
272 visibility: Visibility
273 ) async -> Int? {
274 guard !isSubmittingEditedBuild else { return nil }
275
276 let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines)
277 guard !trimmedManifest.isEmpty else {
278 error = "Paste a build manifest."
279 return nil
280 }
281
282 isSubmittingEditedBuild = true
283 error = nil
284 defer { isSubmittingEditedBuild = false }
285
286 var variables: [String: any Sendable] = [
287 "manifest": trimmedManifest,
288 "secrets": secrets,
289 "execute": execute,
290 "visibility": visibility.rawValue
291 ]
292 if !tags.isEmpty {
293 variables["tags"] = tags
294 }
295 let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines)
296 if !trimmedNote.isEmpty {
297 variables["note"] = trimmedNote
298 }
299
300 do {
301 let result = try await client.execute(
302 service: .builds,
303 query: Self.editableSubmitMutation,
304 variables: variables,
305 responseType: SubmitJobResponse.self
306 )
307 return result.submit.id
308 } catch {
309 self.error = "Couldn’t submit the build. \(error.userFacingMessage)"
310 return nil
311 }
312 }
313
314 func startAutoRefresh() {
315 guard autoRefreshTask == nil else { return }
316 guard shouldAutoRefresh else { return }
317
318 print("Build auto-refresh started")
319
320 autoRefreshTask = Task { [weak self] in
321 while !Task.isCancelled {
322 do {
323 try await Task.sleep(for: Self.autoRefreshInterval)
324 } catch {
325 break
326 }
327
328 guard let self else { return }
329 await self.performAutoRefreshTick()
330 }
331 }
332 }
333
334 func stopAutoRefresh() {
335 guard let autoRefreshTask else { return }
336
337 autoRefreshTask.cancel()
338 self.autoRefreshTask = nil
339 print("Build auto-refresh stopped")
340 }
341
342 private var shouldAutoRefresh: Bool {
343 guard let job else { return true }
344 return !job.status.isTerminal
345 }
346
347 private func performAutoRefreshTick() async {
348 guard !Task.isCancelled, shouldAutoRefresh, !isLoading else {
349 if !shouldAutoRefresh {
350 stopAutoRefresh()
351 }
352 return
353 }
354
355 print("Build refresh tick")
356 await loadJob()
357 await loadBuildLog()
358 }
359}