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}