import Foundation import Observation /// One merge request: `mr show`, the parsed diff, the review threads, and /// every write the review cycle needs. Writes reload — the server's state /// is the state. @Observable @MainActor final class MRDetailViewModel { private(set) var state: LoadState = .loading private(set) var diff: UnifiedDiff? private(set) var threads: [ReviewThread] = [] private(set) var revisions: [MRRevision] = [] /// A write failed; the server's sentence, shown until the next action. private(set) var actionError: String? private(set) var working = false private let client: GitbayClient let repoPath: String let number: Int64 init(client: GitbayClient, repoPath: String, number: Int64) { self.client = client self.repoPath = repoPath self.number = number } private var ref: [String] { [repoPath, String(number)] } func load() async { do { let detail = try await client.read(["mr", "show"] + ref, as: MRDetail.self) state = .loaded(detail) } catch { state = .from(error) return } // The diff and threads are secondary: their failure leaves the // header usable rather than sinking the screen. async let diffText = try? client.readText(["mr", "diff"] + ref) async let threadList = try? client.readList(["mr", "threads"] + ref, of: ReviewThread.self) async let revisionList = try? client.readList(["mr", "revisions"] + ref, of: MRRevision.self) diff = (await diffText).map(UnifiedDiff.parse) threads = await threadList ?? [] revisions = await revisionList ?? [] } var unresolvedCount: Int { threads.count { !$0.isResolved } } // MARK: - Writes enum Verdict: String, Sendable { case approve = "--approve" case requestChanges = "--request-changes" } func review(_ verdict: Verdict) async { await perform(["mr", "review"] + ref + [verdict.rawValue]) } /// Both fields are always sent; an empty body clears it. `format` is /// appended only when given; omitting it lets the server default. func edit(title: String, body: String, format: String? = nil) async { var argv = ["mr", "edit"] + ref + ["--title", title, "--file", "-"] if let format { argv.append(contentsOf: ["--format", format]) } await perform(argv, stdin: body) } /// Open milestones for the picker; fetched on first use. private(set) var availableMilestones: [Milestone]? func loadMilestones() async { guard availableMilestones == nil else { return } availableMilestones = (try? await client.readList( ["milestone", "list", repoPath, "--state", "open"], of: Milestone.self )) ?? [] } /// nil clears it; the command spells that "none". func setMilestone(_ title: String?) async { await perform(["mr", "milestone"] + ref + [title ?? "none"]) } /// The repository's labels, for chip colours. Read on first use from /// the Labels section, not in `load()`: colours are secondary, and the /// detail screen's tests count `load()`'s four reads. private(set) var colors = LabelColors() private var labelsLoaded = false func loadLabels() async { guard !labelsLoaded else { return } labelsLoaded = true colors = LabelColors( (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? []) } /// A label that does not exist yet is created on the repository. func addLabel(_ label: String) async { await perform(["mr", "label"] + ref + ["--add", label]) } func removeLabel(_ label: String) async { await perform(["mr", "label"] + ref + ["--remove", label]) } func comment(_ text: String, format: String? = nil) async { // Long text travels in stdin, but the server only reads it when // argv says so: --file - is required, not implied. var argv = ["mr", "comment"] + ref + ["--file", "-"] if let format { argv.append(contentsOf: ["--format", format]) } await perform(argv, stdin: text) } func merge(strategy: String? = nil) async { var argv = ["mr", "merge"] + ref if let strategy { argv.append(contentsOf: ["--strategy", strategy]) } await perform(argv) } /// `--by` records which merge request replaces this one; the server /// refuses a closed, merged or self reference. func close(inFavourOf successor: Int64? = nil) async { var argv = ["mr", "close"] + ref if let successor { argv.append(contentsOf: ["--by", String(successor)]) } await perform(argv) } /// The repository's other open merge requests, for the close-in- /// favour picker. Fetched on demand and cached like `loadBranches()`; /// this merge request is dropped since it cannot supersede itself. private(set) var openMergeRequests: [MergeRequest]? private(set) var openMergeRequestsError: String? func loadOpenMergeRequests() async { guard openMergeRequests == nil else { return } openMergeRequestsError = nil do { let rows = try await client.readList( ["mr", "list", repoPath, "--state", "open"], of: MergeRequest.self) openMergeRequests = rows.filter { $0.number != number } } catch let error as GitbayError { openMergeRequestsError = error.userFacingMessage } catch { openMergeRequestsError = GitbayError.transport(error).userFacingMessage } } func setResolved(_ thread: ReviewThread, _ resolved: Bool) async { await perform(["mr", resolved ? "resolve" : "unresolve"] + ref + [String(thread.id)]) } func reply(to thread: ReviewThread, _ text: String) async { await perform( ["mr", "diff-comment"] + ref + ["--reply", String(thread.id), "--file", "-"], stdin: text ) } func setDraft(_ draft: Bool) async { await perform(["mr", draft ? "draft" : "ready"] + ref) } func retarget(to branch: String) async { await perform(["mr", "retarget"] + ref + [branch]) } func requestReview(from user: String) async { await perform(["mr", "review", "request"] + ref + ["--add", user]) } func removeReviewRequest(_ user: String) async { await perform(["mr", "review", "request"] + ref + ["--remove", user]) } /// Branches for the retarget picker, fetched on demand from `repo refs`. /// `nil` means "not yet loaded" so the picker can tell that apart from /// a repository that genuinely has no other branches; a failed fetch /// leaves `branches` nil and sets `branchesError` instead of silently /// showing an empty list. private(set) var branches: [String]? private(set) var branchesError: String? /// Cached for the life of this view model, same as `loadMilestones()`: /// branches can go stale after a push, but re-opening the retarget /// picker should not re-fetch every time. A pull-to-refresh of the /// whole screen doesn't clear this cache either — retarget is a /// secondary action, not the primary content `load()` refreshes. func loadBranches() async { guard branches == nil else { return } branchesError = nil do { let refs = try await client.read(["repo", "refs", repoPath], as: RepoRefs.self) branches = refs.branches.map(\.name) } catch let error as GitbayError { branchesError = error.userFacingMessage } catch { branchesError = GitbayError.transport(error).userFacingMessage } } private func perform(_ argv: [String], stdin: String? = nil) async { working = true actionError = nil defer { working = false } do { try await client.run(argv, stdin: stdin) await load() } catch let error as GitbayError { // A refusal explains the rule (approvals missing, threads // unresolved, checks red). Surface it verbatim; never route // around it. actionError = error.userFacingMessage } catch { actionError = GitbayError.transport(error).userFacingMessage } } }