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