krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.9.0: 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 private static func cacheKey(for jobId: Int) -> String { APICacheKeys.buildDetail(jobId: jobId) }
32
33 let jobId: Int
34 private let client: SRHTClient
35
36 private var autoRefreshTask: Task<Void, Never>?
37 private(set) var job: JobDetail?
38 private(set) var isLoading = false
39 private(set) var buildLogText: String?
40 private(set) var isLoadingBuildLog = false
41 private(set) var taskLogs: [String: String] = [:]
42 private(set) var loadingTaskLogs: Set<String> = []
43 private(set) var failedTaskLogs: Set<String> = []
44 private var taskLogRetryCounts: [String: Int] = [:]
45 private(set) var isCancelling = false
46 private(set) var isRebuilding = false
47 private(set) var isSubmittingEditedBuild = false
48 private(set) var rawJobResponse: String?
49 private(set) var cacheMetadata: CacheEntryMetadata?
50 private(set) var isRefreshingCachedData = false
51 var error: String?
52 /// Transient error shown for action failures (cancel, rebuild, submit).
53 /// Separate from `error` so auto-refresh doesn't immediately clear it.
54 private(set) var actionError: String?
55 private var actionErrorDismissTask: Task<Void, Never>?
56
57 init(jobId: Int, client: SRHTClient) {
58 self.jobId = jobId
59 self.client = client
60 }
61
62 func dismissActionError() {
63 actionError = nil
64 actionErrorDismissTask?.cancel()
65 actionErrorDismissTask = nil
66 }
67
68 private func setActionError(_ message: String) {
69 actionError = message
70 actionErrorDismissTask?.cancel()
71 actionErrorDismissTask = Task {
72 try? await Task.sleep(for: .seconds(5))
73 guard !Task.isCancelled else { return }
74 actionError = nil
75 }
76 }
77
78 // MARK: - Queries
79
80 private static let detailQuery = """
81 query job($id: Int!) {
82 job(id: $id) {
83 id
84 created
85 updated
86 status
87 note
88 tags
89 visibility
90 image
91 manifest
92 tasks { name status log { fullURL } }
93 artifacts { id created path size url }
94 log { fullURL }
95 owner { canonicalName }
96 }
97 }
98 """
99
100 private static let cancelMutation = """
101 mutation cancel($id: Int!) {
102 cancel(jobId: $id) {
103 id
104 }
105 }
106 """
107
108 private static let submitMutation = """
109 mutation submit($manifest: String!, $tags: [String!], $note: String, $visibility: Visibility) {
110 submit(manifest: $manifest, tags: $tags, note: $note, visibility: $visibility) {
111 id
112 }
113 }
114 """
115
116 private static let editableSubmitMutation = """
117 mutation submit($manifest: String!, $tags: [String!], $note: String, $secrets: Boolean, $execute: Boolean, $visibility: Visibility) {
118 submit(manifest: $manifest, tags: $tags, note: $note, secrets: $secrets, execute: $execute, visibility: $visibility) {
119 id
120 }
121 }
122 """
123
124 // MARK: - Public API
125
126 func loadJob() async {
127 guard !isLoading else { return }
128 isLoading = true
129 error = nil
130 rawJobResponse = nil
131
132 do {
133 let result = try await client.executeCached(
134 service: .builds,
135 query: Self.detailQuery,
136 variables: ["id": jobId],
137 responseType: JobDetailResponse.self,
138 cacheKey: Self.cacheKey(for: jobId),
139 resourceType: .buildDetail,
140 ttl: job?.status.isTerminal == true ? APICacheTTLs.completedBuildDetail : APICacheTTLs.activeBuild,
141 policy: .cacheFirstThenRefresh
142 )
143 apply(result.value, metadata: result.metadata)
144 if result.isFromCache {
145 isLoading = false
146 await refreshJobInBackground()
147 return
148 }
149 } catch {
150 self.error = error.userFacingMessage
151 }
152
153 isLoading = false
154 }
155
156 func loadJobWithDebugCapture() async {
157 guard !isLoading else { return }
158 isLoading = true
159 error = nil
160
161 do {
162 let cacheKey = Self.cacheKey(for: jobId)
163 let result = try await client.executeCached(
164 service: .builds,
165 query: Self.detailQuery,
166 variables: ["id": jobId],
167 responseType: JobDetailResponse.self,
168 cacheKey: cacheKey,
169 resourceType: .buildDetail,
170 ttl: job?.status.isTerminal == true ? APICacheTTLs.completedBuildDetail : APICacheTTLs.activeBuild,
171 policy: .refreshIgnoringCache
172 )
173 rawJobResponse = await client.cachedPayload(forKey: cacheKey)
174 .flatMap { String(data: $0, encoding: .utf8) }
175 apply(result.value, metadata: result.metadata)
176 } catch {
177 self.error = error.userFacingMessage
178 }
179
180 isLoading = false
181 }
182
183 func loadTaskLog(task: BuildTask) async {
184 let cacheKey = task.logCacheKey
185 let jobIsTerminal = job?.status.isTerminal ?? false
186
187 // Task-specific logs are only fetched after the job reaches a terminal
188 // state. While the build is active, the UI shows the shared live build log.
189 guard let log = task.log,
190 let logURL = URL(string: log.fullURL),
191 !loadingTaskLogs.contains(cacheKey) else { return }
192 guard jobIsTerminal else { return }
193 if jobIsTerminal, taskLogs[cacheKey] != nil { return }
194 failedTaskLogs.remove(cacheKey)
195 loadingTaskLogs.insert(cacheKey)
196
197 do {
198 let logCacheKey = APICacheKeys.buildLog(url: logURL, jobId: jobId, task: cacheKey)
199 let result = try await client.fetchCachedText(
200 url: logURL,
201 cacheKey: logCacheKey,
202 ttl: APICacheTTLs.completedBuildLog,
203 policy: .cacheFirstThenRefresh
204 )
205 taskLogs[cacheKey] = result.value
206 failedTaskLogs.remove(cacheKey)
207 } catch {
208 failedTaskLogs.insert(cacheKey)
209 self.error = error.userFacingMessage
210 }
211
212 loadingTaskLogs.remove(cacheKey)
213 }
214
215 func loadBuildLog() async {
216 guard let log = job?.log,
217 let logURL = URL(string: log.fullURL),
218 !isLoadingBuildLog else { return }
219
220 let jobIsTerminal = job?.status.isTerminal ?? false
221 if jobIsTerminal, buildLogText != nil { return }
222
223 isLoadingBuildLog = true
224
225 do {
226 let result = try await client.fetchCachedText(
227 url: logURL,
228 cacheKey: APICacheKeys.buildLog(url: logURL, jobId: jobId),
229 ttl: jobIsTerminal ? APICacheTTLs.completedBuildLog : APICacheTTLs.activeBuild,
230 policy: jobIsTerminal ? .cacheFirstThenRefresh : .refreshIgnoringCache
231 )
232 buildLogText = result.value
233 } catch {
234 self.error = error.userFacingMessage
235 }
236
237 isLoadingBuildLog = false
238 }
239
240 func retryTaskLog(task: BuildTask) async {
241 let cacheKey = task.logCacheKey
242 failedTaskLogs.remove(cacheKey)
243 taskLogRetryCounts[cacheKey, default: 0] += 1
244 await loadTaskLog(task: task)
245 }
246
247 func displayedLogText(for task: BuildTask?) -> String? {
248 guard let task else { return nil }
249 guard let job else { return nil }
250
251 if !job.status.isTerminal {
252 return buildLogText
253 }
254
255 return taskLogs[task.logCacheKey] ?? buildLogText
256 }
257
258 func isShowingBuildLogFallback(for task: BuildTask?) -> Bool {
259 guard let task, let job else { return false }
260 if !job.status.isTerminal {
261 return buildLogText != nil
262 }
263
264 return taskLogs[task.logCacheKey] == nil && buildLogText != nil
265 }
266
267 func taskLogTrigger(for task: BuildTask?) -> String? {
268 guard let task, let logURL = task.log?.fullURL else { return nil }
269 let retryCount = taskLogRetryCounts[task.logCacheKey, default: 0]
270 let isTerminal = job?.status.isTerminal ?? false
271 return "\(logURL)#\(retryCount)#\(isTerminal)"
272 }
273
274 func cancelJob() async {
275 guard let job, job.status.isCancellable, !isCancelling else { return }
276 let originalJob = job
277 isCancelling = true
278
279 // Optimistic update: show cancelled status immediately.
280 self.job = JobDetail(
281 id: job.id, created: job.created, updated: job.updated,
282 status: .cancelled, note: job.note, tags: job.tags,
283 visibility: job.visibility, image: job.image,
284 manifest: job.manifest, tasks: job.tasks,
285 artifacts: job.artifacts,
286 log: job.log, owner: job.owner
287 )
288 stopAutoRefresh()
289
290 do {
291 _ = try await client.execute(
292 service: .builds,
293 query: Self.cancelMutation,
294 variables: ["id": jobId],
295 responseType: CancelResponse.self
296 )
297 await invalidateAfterMutation()
298 await reloadJobPreservingDebugState()
299 } catch {
300 // Revert optimistic update on failure.
301 self.job = originalJob
302 if !originalJob.status.isTerminal {
303 startAutoRefresh()
304 }
305 setActionError("Couldn't cancel build. \(error.userFacingMessage)")
306 }
307
308 isCancelling = false
309 }
310
311 func rebuildJob() async -> Int? {
312 guard let job, let manifest = job.manifest, !manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !isRebuilding else {
313 return nil
314 }
315
316 isRebuilding = true
317 dismissActionError()
318 defer { isRebuilding = false }
319
320 var variables: [String: any Sendable] = [
321 "manifest": manifest.trimmingCharacters(in: .whitespacesAndNewlines)
322 ]
323 if !job.tags.isEmpty {
324 variables["tags"] = job.tags
325 }
326 if let note = job.note, !note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
327 variables["note"] = note.trimmingCharacters(in: .whitespacesAndNewlines)
328 }
329 if let visibility = job.visibility {
330 variables["visibility"] = visibility.rawValue
331 }
332
333 do {
334 let result = try await client.execute(
335 service: .builds,
336 query: Self.submitMutation,
337 variables: variables,
338 responseType: SubmitJobResponse.self
339 )
340 await invalidateAfterMutation()
341 return result.submit.id
342 } catch {
343 setActionError("Couldn't rebuild. \(error.userFacingMessage)")
344 return nil
345 }
346 }
347
348 func submitBuild(
349 manifest: String,
350 tags: [String],
351 note: String,
352 secrets: Bool,
353 execute: Bool,
354 visibility: Visibility
355 ) async -> Int? {
356 guard !isSubmittingEditedBuild else { return nil }
357
358 let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines)
359 guard !trimmedManifest.isEmpty else {
360 setActionError("Paste a build manifest.")
361 return nil
362 }
363
364 isSubmittingEditedBuild = true
365 dismissActionError()
366 defer { isSubmittingEditedBuild = false }
367
368 var variables: [String: any Sendable] = [
369 "manifest": trimmedManifest,
370 "secrets": secrets,
371 "execute": execute,
372 "visibility": visibility.rawValue
373 ]
374 if !tags.isEmpty {
375 variables["tags"] = tags
376 }
377 let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines)
378 if !trimmedNote.isEmpty {
379 variables["note"] = trimmedNote
380 }
381
382 do {
383 let result = try await client.execute(
384 service: .builds,
385 query: Self.editableSubmitMutation,
386 variables: variables,
387 responseType: SubmitJobResponse.self
388 )
389 await invalidateAfterMutation()
390 return result.submit.id
391 } catch {
392 setActionError("Couldn’t submit the build. \(error.userFacingMessage)")
393 return nil
394 }
395 }
396
397 func startAutoRefresh() {
398 guard autoRefreshTask == nil else { return }
399 guard shouldAutoRefresh else { return }
400
401 autoRefreshTask = Task { [weak self] in
402 while !Task.isCancelled {
403 do {
404 try await Task.sleep(for: Self.autoRefreshInterval)
405 } catch {
406 break
407 }
408
409 guard let self else { return }
410 await self.performAutoRefreshTick()
411 }
412 }
413 }
414
415 func stopAutoRefresh() {
416 guard let autoRefreshTask else { return }
417
418 autoRefreshTask.cancel()
419 self.autoRefreshTask = nil
420 }
421
422 private func reloadJobPreservingDebugState() async {
423 if rawJobResponse != nil {
424 await loadJobWithDebugCapture()
425 } else {
426 await loadJob()
427 }
428 }
429
430 private func refreshJobInBackground() async {
431 guard !isRefreshingCachedData else { return }
432 isRefreshingCachedData = true
433 defer { isRefreshingCachedData = false }
434
435 do {
436 let result = try await client.executeCached(
437 service: .builds,
438 query: Self.detailQuery,
439 variables: ["id": jobId],
440 responseType: JobDetailResponse.self,
441 cacheKey: Self.cacheKey(for: jobId),
442 resourceType: .buildDetail,
443 ttl: job?.status.isTerminal == true ? APICacheTTLs.completedBuildDetail : APICacheTTLs.activeBuild,
444 policy: .refreshIgnoringCache
445 )
446 apply(result.value, metadata: result.metadata)
447 } catch {
448 if job == nil {
449 self.error = error.userFacingMessage
450 }
451 }
452 }
453
454 private func apply(_ response: JobDetailResponse, metadata: CacheEntryMetadata?) {
455 cacheMetadata = metadata
456 var loadedJob = response.job
457 loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in
458 task.withOrdinal(index)
459 }
460 if job != loadedJob {
461 job = loadedJob
462 }
463
464 if loadedJob.status.isTerminal {
465 stopAutoRefresh()
466 }
467 }
468
469 private func invalidateAfterMutation() async {
470 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.builds.rawValue, "job"))
471 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.builds.rawValue, "jobs"))
472 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.builds.rawValue, "log"))
473 await client.invalidateCache(prefix: APICacheKeys.prefix("home"))
474 }
475
476 private var shouldAutoRefresh: Bool {
477 guard let job else { return true }
478 return !job.status.isTerminal
479 }
480
481 private func performAutoRefreshTick() async {
482 guard !Task.isCancelled, shouldAutoRefresh, !isLoading else {
483 if !shouldAutoRefresh {
484 stopAutoRefresh()
485 }
486 return
487 }
488
489 await reloadJobPreservingDebugState()
490 await loadBuildLog()
491 }
492}