Commit 944674b560
Verified · cmc
gitbay/ContentView.swift +15
| @@ -12,6 +12,9 @@ struct ContentView: View { | ||
| 12 | 12 | .navigationDestination(for: RepoRoute.self) { route in |
| 13 | 13 | destination(route, client: client) |
| 14 | 14 | } |
| 15 | .navigationDestination(for: MRRoute.self) { route in | |
| 16 | destination(route, client: client) | |
| 17 | } | |
| 15 | 18 | } else { |
| 16 | 19 | SignInView() |
| 17 | 20 | } |
| @@ -33,6 +36,18 @@ struct ContentView: View { | ||
| 33 | 36 | SignInView() |
| 34 | 37 | } |
| 35 | 38 | } |
| 39 | ||
| 40 | @ViewBuilder | |
| 41 | private func destination(_ route: MRRoute, client: GitbayClient) -> some View { | |
| 42 | switch route { | |
| 43 | case .list(let repo): | |
| 44 | MRListView(client: client, repo: repo) | |
| 45 | case .mr(let repo, let number): | |
| 46 | MRView(client: client, repo: repo, number: number) | |
| 47 | case .diff(let repo, let number): | |
| 48 | DiffView(client: client, repo: repo, number: number) | |
| 49 | } | |
| 50 | } | |
| 36 | 51 | } |
| 37 | 52 | |
| 38 | 53 | #Preview { |
gitbay/MRs/MRDetailViewModel.swift added +103
| @@ -0,0 +1,103 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | ||
| 4 | /// One merge request: `mr show`, the parsed diff, the review threads, and | |
| 5 | /// every write the review cycle needs. Writes reload — the server's state | |
| 6 | /// is the state. | |
| 7 | @Observable | |
| 8 | @MainActor | |
| 9 | final class MRDetailViewModel { | |
| 10 | ||
| 11 | private(set) var state: LoadState<MRDetail> = .loading | |
| 12 | private(set) var diff: UnifiedDiff? | |
| 13 | private(set) var threads: [ReviewThread] = [] | |
| 14 | /// A write failed; the server's sentence, shown until the next action. | |
| 15 | private(set) var actionError: String? | |
| 16 | private(set) var working = false | |
| 17 | ||
| 18 | private let client: GitbayClient | |
| 19 | let repoPath: String | |
| 20 | let number: Int64 | |
| 21 | ||
| 22 | init(client: GitbayClient, repoPath: String, number: Int64) { | |
| 23 | self.client = client | |
| 24 | self.repoPath = repoPath | |
| 25 | self.number = number | |
| 26 | } | |
| 27 | ||
| 28 | private var ref: [String] { [repoPath, String(number)] } | |
| 29 | ||
| 30 | func load() async { | |
| 31 | do { | |
| 32 | let detail = try await client.read(["mr", "show"] + ref, as: MRDetail.self) | |
| 33 | state = .loaded(detail) | |
| 34 | } catch { | |
| 35 | state = .from(error) | |
| 36 | return | |
| 37 | } | |
| 38 | // The diff and threads are secondary: their failure leaves the | |
| 39 | // header usable rather than sinking the screen. | |
| 40 | async let diffText = try? client.readText(["mr", "diff"] + ref) | |
| 41 | async let threadList = try? client.readList(["mr", "threads"] + ref, of: ReviewThread.self) | |
| 42 | diff = (await diffText).map(UnifiedDiff.parse) | |
| 43 | threads = await threadList ?? [] | |
| 44 | } | |
| 45 | ||
| 46 | var unresolvedCount: Int { | |
| 47 | threads.count { !$0.isResolved } | |
| 48 | } | |
| 49 | ||
| 50 | // MARK: - Writes | |
| 51 | ||
| 52 | enum Verdict: String, Sendable { | |
| 53 | case approve = "--approve" | |
| 54 | case requestChanges = "--request-changes" | |
| 55 | } | |
| 56 | ||
| 57 | func review(_ verdict: Verdict) async { | |
| 58 | await perform(["mr", "review"] + ref + [verdict.rawValue]) | |
| 59 | } | |
| 60 | ||
| 61 | func comment(_ text: String) async { | |
| 62 | // Long text goes in stdin, the CLI's --file - discipline. | |
| 63 | await perform(["mr", "comment"] + ref, stdin: text) | |
| 64 | } | |
| 65 | ||
| 66 | func merge(strategy: String? = nil) async { | |
| 67 | var argv = ["mr", "merge"] + ref | |
| 68 | if let strategy { argv.append(contentsOf: ["--strategy", strategy]) } | |
| 69 | await perform(argv) | |
| 70 | } | |
| 71 | ||
| 72 | func close() async { | |
| 73 | await perform(["mr", "close"] + ref) | |
| 74 | } | |
| 75 | ||
| 76 | func setResolved(_ thread: ReviewThread, _ resolved: Bool) async { | |
| 77 | await perform(["mr", resolved ? "resolve" : "unresolve"] + ref + [String(thread.id)]) | |
| 78 | } | |
| 79 | ||
| 80 | func reply(to thread: ReviewThread, _ text: String) async { | |
| 81 | await perform( | |
| 82 | ["mr", "diff-comment"] + ref + ["--reply", String(thread.id)], | |
| 83 | stdin: text | |
| 84 | ) | |
| 85 | } | |
| 86 | ||
| 87 | private func perform(_ argv: [String], stdin: String? = nil) async { | |
| 88 | working = true | |
| 89 | actionError = nil | |
| 90 | defer { working = false } | |
| 91 | do { | |
| 92 | try await client.run(argv, stdin: stdin) | |
| 93 | await load() | |
| 94 | } catch let error as GitbayError { | |
| 95 | // A refusal explains the rule (approvals missing, threads | |
| 96 | // unresolved, checks red). Surface it verbatim; never route | |
| 97 | // around it. | |
| 98 | actionError = error.userFacingMessage | |
| 99 | } catch { | |
| 100 | actionError = GitbayError.transport(error).userFacingMessage | |
| 101 | } | |
| 102 | } | |
| 103 | } | |
gitbay/MRs/MRListViewModel.swift added +40
| @@ -0,0 +1,40 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | ||
| 4 | /// `mr list <repo> --state <s>`. | |
| 5 | @Observable | |
| 6 | @MainActor | |
| 7 | final class MRListViewModel { | |
| 8 | ||
| 9 | enum StateFilter: String, CaseIterable, Identifiable, Sendable { | |
| 10 | case open, merged, closed, all | |
| 11 | var id: String { rawValue } | |
| 12 | } | |
| 13 | ||
| 14 | private(set) var state: LoadState<[MergeRequest]> = .loading | |
| 15 | var filter: StateFilter = .open { | |
| 16 | didSet { if filter != oldValue { Task { await load() } } } | |
| 17 | } | |
| 18 | ||
| 19 | private let client: GitbayClient | |
| 20 | let repoPath: String | |
| 21 | ||
| 22 | init(client: GitbayClient, repoPath: String) { | |
| 23 | self.client = client | |
| 24 | self.repoPath = repoPath | |
| 25 | } | |
| 26 | ||
| 27 | func load() async { | |
| 28 | state = .loading | |
| 29 | do { | |
| 30 | let mrs = try await client.readList( | |
| 31 | ["mr", "list", repoPath, "--state", filter.rawValue], of: MergeRequest.self | |
| 32 | ) | |
| 33 | state = mrs.isEmpty | |
| 34 | ? .empty("No \(filter == .all ? "" : filter.rawValue + " ")merge requests.") | |
| 35 | : .loaded(mrs.sorted { $0.number > $1.number }) | |
| 36 | } catch { | |
| 37 | state = .from(error) | |
| 38 | } | |
| 39 | } | |
| 40 | } | |
gitbay/MRs/MRModels.swift added +121
| @@ -0,0 +1,121 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// One row of `mr list`, and the header half of `mr show`. | |
| 4 | nonisolated struct MergeRequest: Decodable, Sendable, Hashable, Identifiable { | |
| 5 | let number: Int64 | |
| 6 | let title: String | |
| 7 | let state: String | |
| 8 | let author: String | |
| 9 | /// "branch" for a same-repo MR, "owner/name:branch" cross-repo, | |
| 10 | /// "" when the source is gone. | |
| 11 | let source: String | |
| 12 | let targetRef: String | |
| 13 | let headSHA: String | |
| 14 | let body: String? | |
| 15 | let createdAt: Date | |
| 16 | ||
| 17 | enum CodingKeys: String, CodingKey { | |
| 18 | case number, title, state, author, source, body | |
| 19 | case targetRef = "target_ref" | |
| 20 | case headSHA = "head_sha" | |
| 21 | case createdAt = "created_at" | |
| 22 | } | |
| 23 | ||
| 24 | var id: Int64 { number } | |
| 25 | var isOpen: Bool { state == "open" } | |
| 26 | } | |
| 27 | ||
| 28 | /// The whole of `mr show`. | |
| 29 | nonisolated struct MRDetail: Decodable, Sendable, Hashable { | |
| 30 | let number: Int64 | |
| 31 | let title: String | |
| 32 | let state: String | |
| 33 | let author: String | |
| 34 | let source: String | |
| 35 | let targetRef: String | |
| 36 | let headSHA: String | |
| 37 | let body: String? | |
| 38 | let createdAt: Date | |
| 39 | let checks: [Check]? | |
| 40 | let checksCombined: String? | |
| 41 | let unresolvedThreads: Int? | |
| 42 | let commits: [MRCommit]? | |
| 43 | let comments: [MRComment]? | |
| 44 | let reviews: [Review]? | |
| 45 | ||
| 46 | enum CodingKeys: String, CodingKey { | |
| 47 | case number, title, state, author, source, body, checks, commits, comments, reviews | |
| 48 | case targetRef = "target_ref" | |
| 49 | case headSHA = "head_sha" | |
| 50 | case createdAt = "created_at" | |
| 51 | case checksCombined = "checks_combined" | |
| 52 | case unresolvedThreads = "unresolved_threads" | |
| 53 | } | |
| 54 | ||
| 55 | var isOpen: Bool { state == "open" } | |
| 56 | ||
| 57 | nonisolated struct Check: Decodable, Sendable, Hashable { | |
| 58 | let context: String | |
| 59 | let state: String | |
| 60 | let url: String? | |
| 61 | } | |
| 62 | ||
| 63 | nonisolated struct MRCommit: Decodable, Sendable, Hashable, Identifiable { | |
| 64 | let sha: String | |
| 65 | let subject: String | |
| 66 | var id: String { sha } | |
| 67 | var shortSHA: String { String(sha.prefix(10)) } | |
| 68 | } | |
| 69 | ||
| 70 | nonisolated struct MRComment: Decodable, Sendable, Hashable, Identifiable { | |
| 71 | let author: String | |
| 72 | let body: String | |
| 73 | let createdAt: Date | |
| 74 | ||
| 75 | enum CodingKeys: String, CodingKey { | |
| 76 | case author, body | |
| 77 | case createdAt = "created_at" | |
| 78 | } | |
| 79 | ||
| 80 | var id: String { author + createdAt.timeIntervalSince1970.description + body } | |
| 81 | } | |
| 82 | ||
| 83 | nonisolated struct Review: Decodable, Sendable, Hashable, Identifiable { | |
| 84 | let reviewer: String | |
| 85 | let verdict: String | |
| 86 | /// The review predates the current head — it approved older code. | |
| 87 | let stale: Bool | |
| 88 | var id: String { reviewer } | |
| 89 | } | |
| 90 | } | |
| 91 | ||
| 92 | /// One review thread of `mr threads`. | |
| 93 | nonisolated struct ReviewThread: Decodable, Sendable, Hashable, Identifiable { | |
| 94 | let id: Int64 | |
| 95 | let path: String | |
| 96 | let side: String // new | old | |
| 97 | let line: Int64 | |
| 98 | /// Anchored to an older head; the diff has moved under it. | |
| 99 | let stale: Bool | |
| 100 | let resolvedBy: String? | |
| 101 | let comments: [Comment] | |
| 102 | ||
| 103 | enum CodingKeys: String, CodingKey { | |
| 104 | case id, path, side, line, stale, comments | |
| 105 | case resolvedBy = "resolved_by" | |
| 106 | } | |
| 107 | ||
| 108 | var isResolved: Bool { resolvedBy != nil } | |
| 109 | ||
| 110 | nonisolated struct Comment: Decodable, Sendable, Hashable, Identifiable { | |
| 111 | let id: Int64 | |
| 112 | let author: String | |
| 113 | let body: String | |
| 114 | let createdAt: Date | |
| 115 | ||
| 116 | enum CodingKeys: String, CodingKey { | |
| 117 | case id, author, body | |
| 118 | case createdAt = "created_at" | |
| 119 | } | |
| 120 | } | |
| 121 | } | |
gitbay/MRs/UnifiedDiff.swift added +148
| @@ -0,0 +1,148 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// `mr diff` returns one unified diff as text (a recorded gap, #40-family: | |
| 4 | /// the web parses it server-side). This parses it client-side into files, | |
| 5 | /// hunks and lines for rendering. | |
| 6 | nonisolated struct UnifiedDiff: Sendable, Hashable { | |
| 7 | ||
| 8 | let files: [File] | |
| 9 | ||
| 10 | nonisolated struct File: Sendable, Hashable, Identifiable { | |
| 11 | let oldPath: String | |
| 12 | let newPath: String | |
| 13 | let hunks: [Hunk] | |
| 14 | let isBinary: Bool | |
| 15 | ||
| 16 | var id: String { oldPath + "→" + newPath } | |
| 17 | ||
| 18 | /// The path to show: the new one, unless the file was deleted. | |
| 19 | var displayPath: String { | |
| 20 | newPath == "/dev/null" ? oldPath : newPath | |
| 21 | } | |
| 22 | var isNew: Bool { oldPath == "/dev/null" } | |
| 23 | var isDeleted: Bool { newPath == "/dev/null" } | |
| 24 | ||
| 25 | var additions: Int { | |
| 26 | hunks.reduce(0) { $0 + $1.lines.count { $0.kind == .addition } } | |
| 27 | } | |
| 28 | var deletions: Int { | |
| 29 | hunks.reduce(0) { $0 + $1.lines.count { $0.kind == .deletion } } | |
| 30 | } | |
| 31 | } | |
| 32 | ||
| 33 | nonisolated struct Hunk: Sendable, Hashable, Identifiable { | |
| 34 | let header: String // "@@ -l,c +l,c @@ context" | |
| 35 | let lines: [Line] | |
| 36 | var id: String { header } | |
| 37 | } | |
| 38 | ||
| 39 | nonisolated struct Line: Sendable, Hashable, Identifiable { | |
| 40 | enum Kind: Sendable, Hashable { | |
| 41 | case context, addition, deletion | |
| 42 | } | |
| 43 | ||
| 44 | let kind: Kind | |
| 45 | /// Line numbers in the old and new file; nil on the side the line | |
| 46 | /// does not exist on. | |
| 47 | let oldNumber: Int? | |
| 48 | let newNumber: Int? | |
| 49 | let text: String | |
| 50 | ||
| 51 | var id: String { "\(oldNumber ?? 0):\(newNumber ?? 0):\(text)" } | |
| 52 | } | |
| 53 | ||
| 54 | /// Total across files, for the summary row. | |
| 55 | var additions: Int { files.reduce(0) { $0 + $1.additions } } | |
| 56 | var deletions: Int { files.reduce(0) { $0 + $1.deletions } } | |
| 57 | ||
| 58 | // MARK: - Parsing | |
| 59 | ||
| 60 | static func parse(_ text: String) -> UnifiedDiff { | |
| 61 | var files: [File] = [] | |
| 62 | var lines = text.components(separatedBy: "\n")[...] | |
| 63 | ||
| 64 | while let line = lines.first { | |
| 65 | guard line.hasPrefix("diff --git ") else { | |
| 66 | lines = lines.dropFirst() | |
| 67 | continue | |
| 68 | } | |
| 69 | lines = lines.dropFirst() | |
| 70 | ||
| 71 | var oldPath = "" | |
| 72 | var newPath = "" | |
| 73 | var isBinary = false | |
| 74 | var hunks: [Hunk] = [] | |
| 75 | ||
| 76 | // Header lines up to the first hunk or the next file. | |
| 77 | while let header = lines.first, !header.hasPrefix("diff --git ") { | |
| 78 | if header.hasPrefix("--- ") { | |
| 79 | oldPath = Self.stripPrefix(String(header.dropFirst(4))) | |
| 80 | } else if header.hasPrefix("+++ ") { | |
| 81 | newPath = Self.stripPrefix(String(header.dropFirst(4))) | |
| 82 | } else if header.hasPrefix("Binary files ") || header.hasPrefix("GIT binary patch") { | |
| 83 | isBinary = true | |
| 84 | } else if header.hasPrefix("@@") { | |
| 85 | break | |
| 86 | } | |
| 87 | lines = lines.dropFirst() | |
| 88 | } | |
| 89 | ||
| 90 | // Hunks. | |
| 91 | while let hunkHeader = lines.first, hunkHeader.hasPrefix("@@") { | |
| 92 | lines = lines.dropFirst() | |
| 93 | var (oldNumber, newNumber) = Self.startNumbers(hunkHeader) | |
| 94 | var hunkLines: [Line] = [] | |
| 95 | loop: while let bodyLine = lines.first { | |
| 96 | switch bodyLine.first { | |
| 97 | case "+": | |
| 98 | hunkLines.append(Line(kind: .addition, oldNumber: nil, | |
| 99 | newNumber: newNumber, text: String(bodyLine.dropFirst()))) | |
| 100 | newNumber += 1 | |
| 101 | case "-": | |
| 102 | hunkLines.append(Line(kind: .deletion, oldNumber: oldNumber, | |
| 103 | newNumber: nil, text: String(bodyLine.dropFirst()))) | |
| 104 | oldNumber += 1 | |
| 105 | case " ": | |
| 106 | hunkLines.append(Line(kind: .context, oldNumber: oldNumber, | |
| 107 | newNumber: newNumber, text: String(bodyLine.dropFirst()))) | |
| 108 | oldNumber += 1 | |
| 109 | newNumber += 1 | |
| 110 | case "\\": // "\ No newline at end of file" | |
| 111 | break | |
| 112 | default: | |
| 113 | break loop | |
| 114 | } | |
| 115 | lines = lines.dropFirst() | |
| 116 | } | |
| 117 | hunks.append(Hunk(header: hunkHeader, lines: hunkLines)) | |
| 118 | } | |
| 119 | ||
| 120 | files.append(File(oldPath: oldPath, newPath: newPath, hunks: hunks, isBinary: isBinary)) | |
| 121 | } | |
| 122 | return UnifiedDiff(files: files) | |
| 123 | } | |
| 124 | ||
| 125 | /// "a/path" → "path"; "/dev/null" stays. | |
| 126 | private static func stripPrefix(_ path: String) -> String { | |
| 127 | if path == "/dev/null" { return path } | |
| 128 | if path.hasPrefix("a/") || path.hasPrefix("b/") { | |
| 129 | return String(path.dropFirst(2)) | |
| 130 | } | |
| 131 | return path | |
| 132 | } | |
| 133 | ||
| 134 | /// "@@ -12,5 +14,6 @@ …" → (12, 14) | |
| 135 | private static func startNumbers(_ header: String) -> (Int, Int) { | |
| 136 | var old = 1 | |
| 137 | var new = 1 | |
| 138 | let parts = header.split(separator: " ") | |
| 139 | for part in parts { | |
| 140 | if part.hasPrefix("-"), let n = Int(part.dropFirst().split(separator: ",")[0]) { | |
| 141 | old = n | |
| 142 | } else if part.hasPrefix("+"), let n = Int(part.dropFirst().split(separator: ",")[0]) { | |
| 143 | new = n | |
| 144 | } | |
| 145 | } | |
| 146 | return (old, new) | |
| 147 | } | |
| 148 | } | |
gitbay/Networking/GitbayClient.swift +16 −2
| @@ -207,10 +207,24 @@ nonisolated final class GitbayClient: Sendable { | ||
| 207 | 207 | |
| 208 | 208 | private enum Surface { case read, command } |
| 209 | 209 | |
| 210 | /// Dates on the wire are RFC 3339 (`repo log`, `mr show`, …). | |
| 210 | /// Dates on the wire are RFC 3339, some with fractional seconds | |
| 211 | /// (SQLite's `%Y-%m-%dT%H:%M:%fZ` default), some without (`repo log`). | |
| 211 | 212 | private static func decoder() -> JSONDecoder { |
| 212 | 213 | let decoder = JSONDecoder() |
| 213 | decoder.dateDecodingStrategy = .iso8601 | |
| 214 | decoder.dateDecodingStrategy = .custom { decoder in | |
| 215 | let text = try decoder.singleValueContainer().decode(String.self) | |
| 216 | if let date = try? Date(text, strategy: .iso8601) { | |
| 217 | return date | |
| 218 | } | |
| 219 | if let date = try? Date(text, strategy: .iso8601.year().month().day() | |
| 220 | .dateTimeSeparator(.standard).time(includingFractionalSeconds: true)) { | |
| 221 | return date | |
| 222 | } | |
| 223 | throw DecodingError.dataCorrupted(.init( | |
| 224 | codingPath: decoder.codingPath, | |
| 225 | debugDescription: "unrecognized date: \(text)" | |
| 226 | )) | |
| 227 | } | |
| 214 | 228 | return decoder |
| 215 | 229 | } |
| 216 | 230 | |
gitbay/Views/MRs/DiffView.swift added +163
| @@ -0,0 +1,163 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | /// The parsed unified diff, file by file, hunk by hunk. Loads its own | |
| 4 | /// text so the screen is reachable by value from anywhere. | |
| 5 | struct DiffView: View { | |
| 6 | ||
| 7 | private let client: GitbayClient | |
| 8 | private let repo: String | |
| 9 | private let number: Int64 | |
| 10 | @State private var state: LoadState<UnifiedDiff> = .loading | |
| 11 | ||
| 12 | init(client: GitbayClient, repo: String, number: Int64) { | |
| 13 | self.client = client | |
| 14 | self.repo = repo | |
| 15 | self.number = number | |
| 16 | } | |
| 17 | ||
| 18 | var body: some View { | |
| 19 | ZStack { | |
| 20 | Color.clear | |
| 21 | if let diff = state.value, !diff.files.isEmpty { | |
| 22 | List { | |
| 23 | ForEach(diff.files) { file in | |
| 24 | FileDiffSection(file: file) | |
| 25 | } | |
| 26 | } | |
| 27 | .listStyle(.plain) | |
| 28 | } else if case .loaded = state { | |
| 29 | ContentUnavailableView { | |
| 30 | Label("No changes", systemImage: "plus.forwardslash.minus") | |
| 31 | } | |
| 32 | } | |
| 33 | } | |
| 34 | .overlay { LoadStateOverlay(state: state) } | |
| 35 | .navigationTitle("Diff") | |
| 36 | .navigationBarTitleDisplayMode(.inline) | |
| 37 | .task { | |
| 38 | do { | |
| 39 | let text = try await client.readText(["mr", "diff", repo, String(number)]) | |
| 40 | state = .loaded(UnifiedDiff.parse(text)) | |
| 41 | } catch { | |
| 42 | state = .from(error) | |
| 43 | } | |
| 44 | } | |
| 45 | } | |
| 46 | } | |
| 47 | ||
| 48 | private struct FileDiffSection: View { | |
| 49 | ||
| 50 | let file: UnifiedDiff.File | |
| 51 | @State private var collapsed = false | |
| 52 | ||
| 53 | var body: some View { | |
| 54 | Section { | |
| 55 | if !collapsed { | |
| 56 | if file.isBinary { | |
| 57 | Text("Binary file") | |
| 58 | .font(.caption) | |
| 59 | .foregroundStyle(.secondary) | |
| 60 | } else { | |
| 61 | ForEach(file.hunks) { hunk in | |
| 62 | HunkView(hunk: hunk) | |
| 63 | } | |
| 64 | } | |
| 65 | } | |
| 66 | } header: { | |
| 67 | Button { | |
| 68 | collapsed.toggle() | |
| 69 | } label: { | |
| 70 | HStack(spacing: 6) { | |
| 71 | Image(systemName: collapsed ? "chevron.right" : "chevron.down") | |
| 72 | .font(.caption2) | |
| 73 | Text(file.displayPath) | |
| 74 | .font(.caption.monospaced().weight(.semibold)) | |
| 75 | .lineLimit(1) | |
| 76 | .truncationMode(.head) | |
| 77 | if file.isNew { | |
| 78 | Text("new").font(.caption2).foregroundStyle(.green) | |
| 79 | } | |
| 80 | if file.isDeleted { | |
| 81 | Text("deleted").font(.caption2).foregroundStyle(.red) | |
| 82 | } | |
| 83 | Spacer() | |
| 84 | Text("+\(file.additions)").foregroundStyle(.green).font(.caption2) | |
| 85 | Text("−\(file.deletions)").foregroundStyle(.red).font(.caption2) | |
| 86 | } | |
| 87 | } | |
| 88 | .buttonStyle(.plain) | |
| 89 | .textCase(nil) | |
| 90 | } | |
| 91 | } | |
| 92 | } | |
| 93 | ||
| 94 | private struct HunkView: View { | |
| 95 | ||
| 96 | let hunk: UnifiedDiff.Hunk | |
| 97 | ||
| 98 | var body: some View { | |
| 99 | ScrollView(.horizontal) { | |
| 100 | VStack(alignment: .leading, spacing: 0) { | |
| 101 | Text(hunk.header) | |
| 102 | .font(.caption2.monospaced()) | |
| 103 | .foregroundStyle(.blue) | |
| 104 | .padding(.vertical, 2) | |
| 105 | ForEach(hunk.lines) { line in | |
| 106 | LineView(line: line) | |
| 107 | } | |
| 108 | } | |
| 109 | } | |
| 110 | .listRowInsets(EdgeInsets(top: 2, leading: 8, bottom: 2, trailing: 8)) | |
| 111 | } | |
| 112 | } | |
| 113 | ||
| 114 | private struct LineView: View { | |
| 115 | ||
| 116 | let line: UnifiedDiff.Line | |
| 117 | ||
| 118 | var body: some View { | |
| 119 | HStack(spacing: 0) { | |
| 120 | Text(line.oldNumber.map(String.init) ?? "") | |
| 121 | .frame(width: 34, alignment: .trailing) | |
| 122 | .foregroundStyle(.tertiary) | |
| 123 | Text(line.newNumber.map(String.init) ?? "") | |
| 124 | .frame(width: 34, alignment: .trailing) | |
| 125 | .foregroundStyle(.tertiary) | |
| 126 | Text(marker) | |
| 127 | .frame(width: 14) | |
| 128 | .foregroundStyle(markerColor) | |
| 129 | Text(line.text.isEmpty ? " " : line.text) | |
| 130 | .foregroundStyle(textColor) | |
| 131 | } | |
| 132 | .font(.caption2.monospaced()) | |
| 133 | .background(background) | |
| 134 | } | |
| 135 | ||
| 136 | private var marker: String { | |
| 137 | switch line.kind { | |
| 138 | case .addition: "+" | |
| 139 | case .deletion: "−" | |
| 140 | case .context: " " | |
| 141 | } | |
| 142 | } | |
| 143 | ||
| 144 | private var markerColor: Color { | |
| 145 | switch line.kind { | |
| 146 | case .addition: .green | |
| 147 | case .deletion: .red | |
| 148 | case .context: .clear | |
| 149 | } | |
| 150 | } | |
| 151 | ||
| 152 | private var textColor: Color { | |
| 153 | .primary | |
| 154 | } | |
| 155 | ||
| 156 | private var background: Color { | |
| 157 | switch line.kind { | |
| 158 | case .addition: .green.opacity(0.12) | |
| 159 | case .deletion: .red.opacity(0.12) | |
| 160 | case .context: .clear | |
| 161 | } | |
| 162 | } | |
| 163 | } | |
gitbay/Views/MRs/MRListView.swift added +94
| @@ -0,0 +1,94 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct MRListView: View { | |
| 4 | ||
| 5 | @State private var model: MRListViewModel | |
| 6 | ||
| 7 | init(client: GitbayClient, repo: String) { | |
| 8 | _model = State(initialValue: MRListViewModel(client: client, repoPath: repo)) | |
| 9 | } | |
| 10 | ||
| 11 | var body: some View { | |
| 12 | List { | |
| 13 | Picker("State", selection: Bindable(model).filter) { | |
| 14 | ForEach(MRListViewModel.StateFilter.allCases) { filter in | |
| 15 | Text(filter.rawValue.capitalized).tag(filter) | |
| 16 | } | |
| 17 | } | |
| 18 | .pickerStyle(.segmented) | |
| 19 | .listRowBackground(Color.clear) | |
| 20 | .listRowInsets(EdgeInsets()) | |
| 21 | ||
| 22 | ForEach(model.state.value ?? []) { mr in | |
| 23 | NavigationLink(value: MRRoute.mr(repo: model.repoPath, number: mr.number)) { | |
| 24 | MRRow(mr: mr) | |
| 25 | } | |
| 26 | } | |
| 27 | } | |
| 28 | .overlay { LoadStateOverlay(state: model.state) } | |
| 29 | .navigationTitle("Merge Requests") | |
| 30 | .navigationBarTitleDisplayMode(.inline) | |
| 31 | .task { await model.load() } | |
| 32 | .refreshable { await model.load() } | |
| 33 | } | |
| 34 | } | |
| 35 | ||
| 36 | struct MRRow: View { | |
| 37 | let mr: MergeRequest | |
| 38 | ||
| 39 | var body: some View { | |
| 40 | VStack(alignment: .leading, spacing: 4) { | |
| 41 | HStack(alignment: .firstTextBaseline, spacing: 6) { | |
| 42 | Text("!\(mr.number)") | |
| 43 | .font(.caption.monospaced()) | |
| 44 | .foregroundStyle(.secondary) | |
| 45 | Text(mr.title) | |
| 46 | .font(.subheadline.weight(.medium)) | |
| 47 | .lineLimit(2) | |
| 48 | } | |
| 49 | HStack(spacing: 6) { | |
| 50 | MRStateBadge(state: mr.state) | |
| 51 | Text(mr.source.isEmpty ? "(source gone)" : mr.source) | |
| 52 | .lineLimit(1) | |
| 53 | Image(systemName: "arrow.right") | |
| 54 | .font(.caption2) | |
| 55 | Text(mr.targetRef) | |
| 56 | Spacer() | |
| 57 | Text(mr.createdAt, format: .relative(presentation: .named)) | |
| 58 | .foregroundStyle(.tertiary) | |
| 59 | } | |
| 60 | .font(.caption) | |
| 61 | .foregroundStyle(.secondary) | |
| 62 | } | |
| 63 | .padding(.vertical, 2) | |
| 64 | } | |
| 65 | } | |
| 66 | ||
| 67 | struct MRStateBadge: View { | |
| 68 | let state: String | |
| 69 | ||
| 70 | var body: some View { | |
| 71 | Text(state.replacingOccurrences(of: "_", with: " ")) | |
| 72 | .font(.caption2.weight(.medium)) | |
| 73 | .padding(.horizontal, 6) | |
| 74 | .padding(.vertical, 1) | |
| 75 | .background(color.opacity(0.15), in: Capsule()) | |
| 76 | .foregroundStyle(color) | |
| 77 | } | |
| 78 | ||
| 79 | private var color: Color { | |
| 80 | switch state { | |
| 81 | case "open": .green | |
| 82 | case "merged": .purple | |
| 83 | case "closed": .red | |
| 84 | default: .secondary | |
| 85 | } | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 89 | /// MR navigation routes, separate from the repo stack's. | |
| 90 | nonisolated enum MRRoute: Hashable { | |
| 91 | case list(repo: String) | |
| 92 | case mr(repo: String, number: Int64) | |
| 93 | case diff(repo: String, number: Int64) | |
| 94 | } | |
gitbay/Views/MRs/MRView.swift added +338
| @@ -0,0 +1,338 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct MRView: View { | |
| 4 | ||
| 5 | @State private var model: MRDetailViewModel | |
| 6 | @State private var commentText = "" | |
| 7 | @State private var confirmingMerge = false | |
| 8 | @State private var confirmingClose = false | |
| 9 | ||
| 10 | init(client: GitbayClient, repo: String, number: Int64) { | |
| 11 | _model = State(initialValue: MRDetailViewModel( | |
| 12 | client: client, repoPath: repo, number: number | |
| 13 | )) | |
| 14 | } | |
| 15 | ||
| 16 | var body: some View { | |
| 17 | List { | |
| 18 | if let mr = model.state.value { | |
| 19 | header(mr) | |
| 20 | ||
| 21 | if let error = model.actionError { | |
| 22 | Section { | |
| 23 | Label(error, systemImage: "hand.raised") | |
| 24 | .foregroundStyle(.orange) | |
| 25 | .font(.subheadline) | |
| 26 | } | |
| 27 | } | |
| 28 | ||
| 29 | if let body = mr.body, !body.isEmpty { | |
| 30 | Section { | |
| 31 | MarkdownView(markdown: body) | |
| 32 | .padding(.vertical, 4) | |
| 33 | } | |
| 34 | } | |
| 35 | ||
| 36 | diffSection | |
| 37 | ||
| 38 | if let commits = mr.commits, !commits.isEmpty { | |
| 39 | commitsSection(commits) | |
| 40 | } | |
| 41 | if let checks = mr.checks, !checks.isEmpty { | |
| 42 | checksSection(checks, combined: mr.checksCombined) | |
| 43 | } | |
| 44 | if let reviews = mr.reviews, !reviews.isEmpty { | |
| 45 | reviewsSection(reviews) | |
| 46 | } | |
| 47 | if !model.threads.isEmpty { | |
| 48 | threadsSection | |
| 49 | } | |
| 50 | commentsSection(mr.comments ?? []) | |
| 51 | } | |
| 52 | } | |
| 53 | .overlay { LoadStateOverlay(state: model.state) } | |
| 54 | .navigationTitle("!\(model.number)") | |
| 55 | .navigationBarTitleDisplayMode(.inline) | |
| 56 | .toolbar { toolbar } | |
| 57 | .task { await model.load() } | |
| 58 | .refreshable { await model.load() } | |
| 59 | .confirmationDialog("Merge !\(model.number)?", isPresented: $confirmingMerge) { | |
| 60 | Button("Merge") { Task { await model.merge() } } | |
| 61 | Button("Squash") { Task { await model.merge(strategy: "squash") } } | |
| 62 | Button("Fast-forward") { Task { await model.merge(strategy: "ff") } } | |
| 63 | Button("Rebase") { Task { await model.merge(strategy: "rebase") } } | |
| 64 | Button("Cancel", role: .cancel) {} | |
| 65 | } message: { | |
| 66 | Text("The server enforces approvals, threads and checks — a refusal will say why.") | |
| 67 | } | |
| 68 | .confirmationDialog("Close !\(model.number) without merging?", isPresented: $confirmingClose) { | |
| 69 | Button("Close", role: .destructive) { Task { await model.close() } } | |
| 70 | Button("Cancel", role: .cancel) {} | |
| 71 | } | |
| 72 | } | |
| 73 | ||
| 74 | // MARK: - Sections | |
| 75 | ||
| 76 | @ViewBuilder | |
| 77 | private func header(_ mr: MRDetail) -> some View { | |
| 78 | Section { | |
| 79 | VStack(alignment: .leading, spacing: 6) { | |
| 80 | Text(mr.title) | |
| 81 | .font(.headline) | |
| 82 | HStack(spacing: 6) { | |
| 83 | MRStateBadge(state: mr.state) | |
| 84 | Text(mr.source.isEmpty ? "(source gone)" : mr.source) | |
| 85 | .lineLimit(1) | |
| 86 | Image(systemName: "arrow.right") | |
| 87 | .font(.caption2) | |
| 88 | Text(mr.targetRef) | |
| 89 | } | |
| 90 | .font(.caption) | |
| 91 | .foregroundStyle(.secondary) | |
| 92 | HStack(spacing: 6) { | |
| 93 | Text("by \(mr.author)") | |
| 94 | Text(mr.createdAt, format: .relative(presentation: .named)) | |
| 95 | .foregroundStyle(.tertiary) | |
| 96 | } | |
| 97 | .font(.caption) | |
| 98 | .foregroundStyle(.secondary) | |
| 99 | } | |
| 100 | .padding(.vertical, 2) | |
| 101 | } | |
| 102 | } | |
| 103 | ||
| 104 | private var diffSection: some View { | |
| 105 | Section { | |
| 106 | NavigationLink(value: MRRoute.diff(repo: model.repoPath, number: model.number)) { | |
| 107 | HStack { | |
| 108 | Label("Diff", systemImage: "plus.forwardslash.minus") | |
| 109 | Spacer() | |
| 110 | if let diff = model.diff { | |
| 111 | Text("+\(diff.additions)") | |
| 112 | .foregroundStyle(.green) | |
| 113 | Text("−\(diff.deletions)") | |
| 114 | .foregroundStyle(.red) | |
| 115 | } | |
| 116 | } | |
| 117 | .font(.subheadline) | |
| 118 | } | |
| 119 | } | |
| 120 | } | |
| 121 | ||
| 122 | private func commitsSection(_ commits: [MRDetail.MRCommit]) -> some View { | |
| 123 | Section("Commits") { | |
| 124 | ForEach(commits) { commit in | |
| 125 | HStack(spacing: 8) { | |
| 126 | Text(commit.shortSHA) | |
| 127 | .font(.caption.monospaced()) | |
| 128 | .foregroundStyle(.secondary) | |
| 129 | Text(commit.subject) | |
| 130 | .font(.subheadline) | |
| 131 | .lineLimit(1) | |
| 132 | } | |
| 133 | } | |
| 134 | } | |
| 135 | } | |
| 136 | ||
| 137 | private func checksSection(_ checks: [MRDetail.Check], combined: String?) -> some View { | |
| 138 | Section("Checks" + (combined.map { " — \($0)" } ?? "")) { | |
| 139 | ForEach(checks, id: \.context) { check in | |
| 140 | HStack { | |
| 141 | Image(systemName: checkIcon(check.state)) | |
| 142 | .foregroundStyle(checkColor(check.state)) | |
| 143 | Text(check.context) | |
| 144 | .font(.subheadline) | |
| 145 | Spacer() | |
| 146 | Text(check.state) | |
| 147 | .font(.caption) | |
| 148 | .foregroundStyle(.secondary) | |
| 149 | } | |
| 150 | } | |
| 151 | } | |
| 152 | } | |
| 153 | ||
| 154 | private func reviewsSection(_ reviews: [MRDetail.Review]) -> some View { | |
| 155 | Section("Reviews") { | |
| 156 | ForEach(reviews) { review in | |
| 157 | HStack { | |
| 158 | Image(systemName: review.verdict == "approve" | |
| 159 | ? "checkmark.circle.fill" : "exclamationmark.circle.fill") | |
| 160 | .foregroundStyle(review.verdict == "approve" ? .green : .orange) | |
| 161 | Text(review.reviewer) | |
| 162 | .font(.subheadline) | |
| 163 | Spacer() | |
| 164 | if review.stale { | |
| 165 | Text("stale") | |
| 166 | .font(.caption2) | |
| 167 | .padding(.horizontal, 5) | |
| 168 | .padding(.vertical, 1) | |
| 169 | .background(.quaternary, in: Capsule()) | |
| 170 | } | |
| 171 | } | |
| 172 | } | |
| 173 | } | |
| 174 | } | |
| 175 | ||
| 176 | private var threadsSection: some View { | |
| 177 | Section("Threads — \(model.unresolvedCount) unresolved") { | |
| 178 | ForEach(model.threads) { thread in | |
| 179 | ThreadView(thread: thread, model: model) | |
| 180 | } | |
| 181 | } | |
| 182 | } | |
| 183 | ||
| 184 | private func commentsSection(_ comments: [MRDetail.MRComment]) -> some View { | |
| 185 | Section("Comments") { | |
| 186 | ForEach(comments) { comment in | |
| 187 | VStack(alignment: .leading, spacing: 4) { | |
| 188 | HStack { | |
| 189 | Text(comment.author) | |
| 190 | .font(.caption.weight(.semibold)) | |
| 191 | Text(comment.createdAt, format: .relative(presentation: .named)) | |
| 192 | .font(.caption) | |
| 193 | .foregroundStyle(.tertiary) | |
| 194 | } | |
| 195 | MarkdownView(markdown: comment.body) | |
| 196 | .font(.subheadline) | |
| 197 | } | |
| 198 | .padding(.vertical, 2) | |
| 199 | } | |
| 200 | ||
| 201 | HStack { | |
| 202 | TextField("Comment", text: $commentText, axis: .vertical) | |
| 203 | .lineLimit(1...5) | |
| 204 | Button { | |
| 205 | let text = commentText | |
| 206 | commentText = "" | |
| 207 | Task { await model.comment(text) } | |
| 208 | } label: { | |
| 209 | Image(systemName: "arrow.up.circle.fill") | |
| 210 | } | |
| 211 | .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty | |
| 212 | || model.working) | |
| 213 | } | |
| 214 | } | |
| 215 | } | |
| 216 | ||
| 217 | @ToolbarContentBuilder | |
| 218 | private var toolbar: some ToolbarContent { | |
| 219 | ToolbarItem(placement: .topBarTrailing) { | |
| 220 | if let mr = model.state.value, mr.isOpen { | |
| 221 | Menu { | |
| 222 | Button { | |
| 223 | Task { await model.review(.approve) } | |
| 224 | } label: { | |
| 225 | Label("Approve", systemImage: "checkmark.circle") | |
| 226 | } | |
| 227 | Button { | |
| 228 | Task { await model.review(.requestChanges) } | |
| 229 | } label: { | |
| 230 | Label("Request Changes", systemImage: "exclamationmark.circle") | |
| 231 | } | |
| 232 | Divider() | |
| 233 | Button { | |
| 234 | confirmingMerge = true | |
| 235 | } label: { | |
| 236 | Label("Merge", systemImage: "arrow.triangle.merge") | |
| 237 | } | |
| 238 | Button(role: .destructive) { | |
| 239 | confirmingClose = true | |
| 240 | } label: { | |
| 241 | Label("Close", systemImage: "xmark.circle") | |
| 242 | } | |
| 243 | } label: { | |
| 244 | if model.working { | |
| 245 | ProgressView() | |
| 246 | } else { | |
| 247 | Image(systemName: "ellipsis.circle") | |
| 248 | } | |
| 249 | } | |
| 250 | .disabled(model.working) | |
| 251 | } | |
| 252 | } | |
| 253 | } | |
| 254 | ||
| 255 | private func checkIcon(_ state: String) -> String { | |
| 256 | switch state { | |
| 257 | case "success": "checkmark.circle.fill" | |
| 258 | case "failure", "error": "xmark.circle.fill" | |
| 259 | case "pending", "running": "circle.dotted" | |
| 260 | default: "questionmark.circle" | |
| 261 | } | |
| 262 | } | |
| 263 | ||
| 264 | private func checkColor(_ state: String) -> Color { | |
| 265 | switch state { | |
| 266 | case "success": .green | |
| 267 | case "failure", "error": .red | |
| 268 | case "pending", "running": .orange | |
| 269 | default: .secondary | |
| 270 | } | |
| 271 | } | |
| 272 | } | |
| 273 | ||
| 274 | /// One review thread: anchor, comments, reply, resolve. | |
| 275 | private struct ThreadView: View { | |
| 276 | ||
| 277 | let thread: ReviewThread | |
| 278 | let model: MRDetailViewModel | |
| 279 | @State private var replyText = "" | |
| 280 | ||
| 281 | var body: some View { | |
| 282 | VStack(alignment: .leading, spacing: 6) { | |
| 283 | HStack(spacing: 6) { | |
| 284 | Image(systemName: thread.isResolved | |
| 285 | ? "checkmark.bubble" : "bubble.left.and.exclamationmark.bubble.right") | |
| 286 | .font(.caption) | |
| 287 | .foregroundStyle(thread.isResolved ? .green : .orange) | |
| 288 | Text("\(thread.path):\(thread.line)") | |
| 289 | .font(.caption.monospaced()) | |
| 290 | .lineLimit(1) | |
| 291 | if thread.stale { | |
| 292 | Text("stale") | |
| 293 | .font(.caption2) | |
| 294 | .padding(.horizontal, 5) | |
| 295 | .padding(.vertical, 1) | |
| 296 | .background(.quaternary, in: Capsule()) | |
| 297 | } | |
| 298 | Spacer() | |
| 299 | Button(thread.isResolved ? "Reopen" : "Resolve") { | |
| 300 | Task { await model.setResolved(thread, !thread.isResolved) } | |
| 301 | } | |
| 302 | .font(.caption) | |
| 303 | .buttonStyle(.bordered) | |
| 304 | .disabled(model.working) | |
| 305 | } | |
| 306 | ForEach(thread.comments) { comment in | |
| 307 | VStack(alignment: .leading, spacing: 2) { | |
| 308 | HStack { | |
| 309 | Text(comment.author) | |
| 310 | .font(.caption.weight(.semibold)) | |
| 311 | Text(comment.createdAt, format: .relative(presentation: .named)) | |
| 312 | .font(.caption2) | |
| 313 | .foregroundStyle(.tertiary) | |
| 314 | } | |
| 315 | Text(comment.body) | |
| 316 | .font(.subheadline) | |
| 317 | } | |
| 318 | } | |
| 319 | if !thread.isResolved { | |
| 320 | HStack { | |
| 321 | TextField("Reply", text: $replyText, axis: .vertical) | |
| 322 | .font(.subheadline) | |
| 323 | .lineLimit(1...4) | |
| 324 | Button { | |
| 325 | let text = replyText | |
| 326 | replyText = "" | |
| 327 | Task { await model.reply(to: thread, text) } | |
| 328 | } label: { | |
| 329 | Image(systemName: "arrow.up.circle.fill") | |
| 330 | } | |
| 331 | .disabled(replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty | |
| 332 | || model.working) | |
| 333 | } | |
| 334 | } | |
| 335 | } | |
| 336 | .padding(.vertical, 4) | |
| 337 | } | |
| 338 | } | |
gitbay/Views/Repos/RepoView.swift +3
| @@ -22,6 +22,9 @@ struct RepoView: View { | ||
| 22 | 22 | NavigationLink(value: RepoRoute.log(repo: path)) { |
| 23 | 23 | Label("History", systemImage: "clock") |
| 24 | 24 | } |
| 25 | NavigationLink(value: MRRoute.list(repo: path)) { | |
| 26 | Label("Merge Requests", systemImage: "arrow.triangle.merge") | |
| 27 | } | |
| 25 | 28 | } |
| 26 | 29 | |
| 27 | 30 | if let readme = model.readme { |
gitbayTests/MRViewModelTests.swift added +279
| @@ -0,0 +1,279 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | |
| 6 | let box = StubProtocol.box() | |
| 7 | let client = GitbayClient( | |
| 8 | instance: try GitbayInstance(url: "https://gitbay.org"), | |
| 9 | token: "test-token", | |
| 10 | session: box.session() | |
| 11 | ) | |
| 12 | return (client, box) | |
| 13 | } | |
| 14 | ||
| 15 | private let mrListJSON = """ | |
| 16 | {"protocol_version":1,"data":[\ | |
| 17 | {"number":7,"title":"client: envelope decoding","state":"open","author":"cmc",\ | |
| 18 | "source":"client-envelope","target_ref":"main","head_sha":"aabbcc",\ | |
| 19 | "created_at":"2026-08-20T10:00:00.000Z"},\ | |
| 20 | {"number":9,"title":"auth: keychain","state":"open","author":"cmc",\ | |
| 21 | "source":"krz/fork:auth","target_ref":"main","head_sha":"ddeeff",\ | |
| 22 | "created_at":"2026-08-21T10:00:00.000Z"}\ | |
| 23 | ],"exit_code":0} | |
| 24 | """ | |
| 25 | ||
| 26 | private let mrShowJSON = """ | |
| 27 | {"protocol_version":1,"data":{"number":7,"title":"client: envelope decoding",\ | |
| 28 | "state":"open","author":"cmc","source":"client-envelope","target_ref":"main",\ | |
| 29 | "head_sha":"aabbcc","body":"Speaks both surfaces.","created_at":"2026-08-20T10:00:00.000Z",\ | |
| 30 | "checks":[{"context":"build","state":"success"}],"checks_combined":"success",\ | |
| 31 | "unresolved_threads":1,\ | |
| 32 | "commits":[{"sha":"aabbcc00000000000000","subject":"client: envelope"}],\ | |
| 33 | "comments":[{"author":"krz","body":"looks right","created_at":"2026-08-20T11:00:00.000Z"}],\ | |
| 34 | "reviews":[{"reviewer":"krz","verdict":"approve","stale":false}]},"exit_code":0} | |
| 35 | """ | |
| 36 | ||
| 37 | private let threadsJSON = """ | |
| 38 | {"protocol_version":1,"data":[\ | |
| 39 | {"id":3,"path":"gitbay/Client.swift","side":"new","line":42,"stale":false,\ | |
| 40 | "comments":[{"id":3,"author":"krz","body":"why retry twice?","created_at":"2026-08-20T10:30:00.000Z"}]},\ | |
| 41 | {"id":5,"path":"gitbay/Client.swift","side":"new","line":90,"stale":true,"resolved_by":"cmc",\ | |
| 42 | "comments":[{"id":5,"author":"krz","body":"naming","created_at":"2026-08-20T10:31:00.000Z"},\ | |
| 43 | {"id":6,"author":"cmc","body":"renamed","created_at":"2026-08-20T10:35:00.000Z"}]}\ | |
| 44 | ],"exit_code":0} | |
| 45 | """ | |
| 46 | ||
| 47 | private let diffText = """ | |
| 48 | diff --git a/main.go b/main.go | |
| 49 | index 1234567..89abcde 100644 | |
| 50 | --- a/main.go | |
| 51 | +++ b/main.go | |
| 52 | @@ -1,4 +1,5 @@ | |
| 53 | package main | |
| 54 | -import "fmt" | |
| 55 | +import ( | |
| 56 | +\t"fmt" | |
| 57 | +) | |
| 58 | ||
| 59 | -func main() {} | |
| 60 | @@ -10,2 +11,3 @@ func helper() { | |
| 61 | \tx := 1 | |
| 62 | +\ty := 2 | |
| 63 | \t_ = x | |
| 64 | diff --git a/new.txt b/new.txt | |
| 65 | new file mode 100644 | |
| 66 | --- /dev/null | |
| 67 | +++ b/new.txt | |
| 68 | @@ -0,0 +1,2 @@ | |
| 69 | +hello | |
| 70 | +world | |
| 71 | """ | |
| 72 | ||
| 73 | private func diffEnvelope() -> String { | |
| 74 | let escaped = diffText | |
| 75 | .replacingOccurrences(of: "\\", with: "\\\\") | |
| 76 | .replacingOccurrences(of: "\"", with: "\\\"") | |
| 77 | .replacingOccurrences(of: "\n", with: "\\n") | |
| 78 | .replacingOccurrences(of: "\t", with: "\\t") | |
| 79 | return "{\"protocol_version\":1,\"output\":\"\(escaped)\",\"exit_code\":0}" | |
| 80 | } | |
| 81 | ||
| 82 | struct UnifiedDiffParserTests { | |
| 83 | ||
| 84 | @Test func parsesFilesHunksAndLineNumbers() { | |
| 85 | let diff = UnifiedDiff.parse(diffText) | |
| 86 | ||
| 87 | #expect(diff.files.count == 2) | |
| 88 | let first = diff.files[0] | |
| 89 | #expect(first.displayPath == "main.go") | |
| 90 | #expect(first.hunks.count == 2) | |
| 91 | #expect(first.additions == 4) | |
| 92 | #expect(first.deletions == 2) | |
| 93 | ||
| 94 | // Line numbering advances per side. | |
| 95 | let lines = first.hunks[0].lines | |
| 96 | #expect(lines[0].kind == .context) | |
| 97 | #expect(lines[0].oldNumber == 1) | |
| 98 | #expect(lines[0].newNumber == 1) | |
| 99 | #expect(lines[1].kind == .deletion) | |
| 100 | #expect(lines[1].oldNumber == 2) | |
| 101 | #expect(lines[1].newNumber == nil) | |
| 102 | #expect(lines[2].kind == .addition) | |
| 103 | #expect(lines[2].oldNumber == nil) | |
| 104 | #expect(lines[2].newNumber == 2) | |
| 105 | ||
| 106 | // Second hunk restarts numbering from its header. | |
| 107 | #expect(first.hunks[1].lines[0].oldNumber == 10) | |
| 108 | #expect(first.hunks[1].lines[0].newNumber == 11) | |
| 109 | } | |
| 110 | ||
| 111 | @Test func newFilesAreMarked() { | |
| 112 | let diff = UnifiedDiff.parse(diffText) | |
| 113 | let added = diff.files[1] | |
| 114 | #expect(added.isNew) | |
| 115 | #expect(!added.isDeleted) | |
| 116 | #expect(added.displayPath == "new.txt") | |
| 117 | #expect(added.additions == 2) | |
| 118 | } | |
| 119 | ||
| 120 | @Test func totalsSumAcrossFiles() { | |
| 121 | let diff = UnifiedDiff.parse(diffText) | |
| 122 | #expect(diff.additions == 6) | |
| 123 | #expect(diff.deletions == 2) | |
| 124 | } | |
| 125 | ||
| 126 | @Test func binaryFilesAreRecognised() { | |
| 127 | let diff = UnifiedDiff.parse(""" | |
| 128 | diff --git a/logo.png b/logo.png | |
| 129 | Binary files a/logo.png and b/logo.png differ | |
| 130 | """) | |
| 131 | #expect(diff.files.count == 1) | |
| 132 | #expect(diff.files[0].isBinary) | |
| 133 | } | |
| 134 | ||
| 135 | @Test func emptyDiffParsesToNoFiles() { | |
| 136 | #expect(UnifiedDiff.parse("").files.isEmpty) | |
| 137 | } | |
| 138 | } | |
| 139 | ||
| 140 | @MainActor | |
| 141 | struct MRListViewModelTests { | |
| 142 | ||
| 143 | @Test func listsNewestFirst() async throws { | |
| 144 | let (client, stub) = try makeClient() | |
| 145 | stub.enqueue(.init(status: 200, json: mrListJSON)) | |
| 146 | let model = MRListViewModel(client: client, repoPath: "krz/gitbay") | |
| 147 | ||
| 148 | await model.load() | |
| 149 | ||
| 150 | let mrs = try #require(model.state.value) | |
| 151 | #expect(mrs.map(\.number) == [9, 7]) | |
| 152 | let seen = try #require(stub.seen.first) | |
| 153 | #expect(seen.url.query() == | |
| 154 | "argv=mr&argv=list&argv=krz/gitbay&argv=--state&argv=open") | |
| 155 | } | |
| 156 | ||
| 157 | @Test func changingTheFilterReloadsWithThatState() async throws { | |
| 158 | let (client, stub) = try makeClient() | |
| 159 | stub.enqueue(.init(status: 200, json: mrListJSON)) | |
| 160 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#)) | |
| 161 | let model = MRListViewModel(client: client, repoPath: "krz/gitbay") | |
| 162 | await model.load() | |
| 163 | ||
| 164 | model.filter = .merged | |
| 165 | // The reload happens in a spawned task; give it a beat. | |
| 166 | try await Task.sleep(for: .milliseconds(300)) | |
| 167 | ||
| 168 | #expect(stub.seen.count == 2) | |
| 169 | #expect(stub.seen[1].url.query()?.contains("argv=merged") == true) | |
| 170 | guard case .empty = model.state else { | |
| 171 | Issue.record("expected .empty after filtering, got \(model.state)") | |
| 172 | return | |
| 173 | } | |
| 174 | } | |
| 175 | } | |
| 176 | ||
| 177 | @MainActor | |
| 178 | struct MRDetailViewModelTests { | |
| 179 | ||
| 180 | private func loadedModel() async throws -> (MRDetailViewModel, StubProtocol.Box) { | |
| 181 | let (client, stub) = try makeClient() | |
| 182 | stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show")) | |
| 183 | stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff")) | |
| 184 | stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads")) | |
| 185 | let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) | |
| 186 | await model.load() | |
| 187 | return (model, stub) | |
| 188 | } | |
| 189 | ||
| 190 | @Test func loadsHeaderDiffAndThreads() async throws { | |
| 191 | let (model, _) = try await loadedModel() | |
| 192 | ||
| 193 | let mr = try #require(model.state.value) | |
| 194 | #expect(mr.title == "client: envelope decoding") | |
| 195 | #expect(mr.checksCombined == "success") | |
| 196 | #expect(mr.reviews?.first?.verdict == "approve") | |
| 197 | #expect(model.diff?.files.count == 2) | |
| 198 | #expect(model.threads.count == 2) | |
| 199 | #expect(model.unresolvedCount == 1) | |
| 200 | } | |
| 201 | ||
| 202 | @Test func approveSendsTheWriteThenReloads() async throws { | |
| 203 | let (model, stub) = try await loadedModel() | |
| 204 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#)) | |
| 205 | stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show")) | |
| 206 | stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff")) | |
| 207 | stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads")) | |
| 208 | ||
| 209 | await model.review(.approve) | |
| 210 | ||
| 211 | let write = stub.seen[3] | |
| 212 | #expect(write.method == "POST") | |
| 213 | #expect(write.url.path() == "/api/v1/cmd") | |
| 214 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) | |
| 215 | #expect(body["argv"] as? [String] == | |
| 216 | ["mr", "review", "krz/gitbay", "7", "--approve"]) | |
| 217 | #expect(model.actionError == nil) | |
| 218 | } | |
| 219 | ||
| 220 | @Test func commentGoesThroughStdinNotArgv() async throws { | |
| 221 | let (model, stub) = try await loadedModel() | |
| 222 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#)) | |
| 223 | stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show")) | |
| 224 | stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff")) | |
| 225 | stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads")) | |
| 226 | ||
| 227 | await model.comment("long review text\nwith lines") | |
| 228 | ||
| 229 | let write = stub.seen[3] | |
| 230 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) | |
| 231 | #expect(body["argv"] as? [String] == ["mr", "comment", "krz/gitbay", "7"]) | |
| 232 | #expect(body["stdin"] as? String == "long review text\nwith lines") | |
| 233 | } | |
| 234 | ||
| 235 | @Test func aMergeRefusalSurfacesTheServersRuleVerbatim() async throws { | |
| 236 | let (model, stub) = try await loadedModel() | |
| 237 | stub.enqueue(.init(status: 403, json: | |
| 238 | #"{"protocol_version":1,"error":"merge blocked: 1 review thread unresolved","exit_code":4}"#)) | |
| 239 | ||
| 240 | await model.merge() | |
| 241 | ||
| 242 | #expect(model.actionError == "merge blocked: 1 review thread unresolved") | |
| 243 | // The refusal did not wipe the loaded screen. | |
| 244 | #expect(model.state.value != nil) | |
| 245 | #expect(stub.seen.count == 4) | |
| 246 | } | |
| 247 | ||
| 248 | @Test func resolveTargetsTheThreadID() async throws { | |
| 249 | let (model, stub) = try await loadedModel() | |
| 250 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#)) | |
| 251 | stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show")) | |
| 252 | stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff")) | |
| 253 | stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads")) | |
| 254 | let thread = try #require(model.threads.first { !$0.isResolved }) | |
| 255 | ||
| 256 | await model.setResolved(thread, true) | |
| 257 | ||
| 258 | let write = stub.seen[3] | |
| 259 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) | |
| 260 | #expect(body["argv"] as? [String] == ["mr", "resolve", "krz/gitbay", "7", "3"]) | |
| 261 | } | |
| 262 | ||
| 263 | @Test func replyUsesDiffCommentWithReplyFlag() async throws { | |
| 264 | let (model, stub) = try await loadedModel() | |
| 265 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"id":9,"thread":3},"exit_code":0}"#)) | |
| 266 | stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show")) | |
| 267 | stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff")) | |
| 268 | stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads")) | |
| 269 | let thread = try #require(model.threads.first { !$0.isResolved }) | |
| 270 | ||
| 271 | await model.reply(to: thread, "because 5xx is transient") | |
| 272 | ||
| 273 | let write = stub.seen[3] | |
| 274 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) | |
| 275 | #expect(body["argv"] as? [String] == | |
| 276 | ["mr", "diff-comment", "krz/gitbay", "7", "--reply", "3"]) | |
| 277 | #expect(body["stdin"] as? String == "because 5xx is transient") | |
| 278 | } | |
| 279 | } | |
gitbayTests/StubProtocol.swift +17 −5
| @@ -12,19 +12,25 @@ nonisolated final class StubProtocol: URLProtocol, @unchecked Sendable { | ||
| 12 | 12 | let status: Int |
| 13 | 13 | let headers: [String: String] |
| 14 | 14 | let body: Data |
| 15 | /// When set, this stub only answers requests whose URL contains | |
| 16 | /// it — needed when the code under test issues requests | |
| 17 | /// concurrently and arrival order is not deterministic. | |
| 18 | let match: String? | |
| 15 | 19 | |
| 16 | init(status: Int, headers: [String: String] = [:], json: String) { | |
| 20 | init(status: Int, headers: [String: String] = [:], json: String, match: String? = nil) { | |
| 17 | 21 | self.status = status |
| 18 | 22 | var headers = headers |
| 19 | 23 | headers["Content-Type"] = headers["Content-Type"] ?? "application/json" |
| 20 | 24 | self.headers = headers |
| 21 | 25 | self.body = Data(json.utf8) |
| 26 | self.match = match | |
| 22 | 27 | } |
| 23 | 28 | |
| 24 | init(status: Int, headers: [String: String] = [:], body: Data = Data()) { | |
| 29 | init(status: Int, headers: [String: String] = [:], body: Data = Data(), match: String? = nil) { | |
| 25 | 30 | self.status = status |
| 26 | 31 | self.headers = headers |
| 27 | 32 | self.body = body |
| 33 | self.match = match | |
| 28 | 34 | } |
| 29 | 35 | } |
| 30 | 36 | |
| @@ -60,8 +66,14 @@ nonisolated final class StubProtocol: URLProtocol, @unchecked Sendable { | ||
| 60 | 66 | return URLSession(configuration: configuration) |
| 61 | 67 | } |
| 62 | 68 | |
| 63 | fileprivate func next() -> Stub? { | |
| 64 | queue.withLock { $0.isEmpty ? nil : $0.removeFirst() } | |
| 69 | fileprivate func next(for url: URL) -> Stub? { | |
| 70 | queue.withLock { stubs in | |
| 71 | let index = stubs.firstIndex { | |
| 72 | $0.match.map { url.absoluteString.contains($0) } ?? true | |
| 73 | } | |
| 74 | guard let index else { return nil } | |
| 75 | return stubs.remove(at: index) | |
| 76 | } | |
| 65 | 77 | } |
| 66 | 78 | |
| 67 | 79 | fileprivate func record(_ request: Seen) { |
| @@ -114,7 +126,7 @@ nonisolated final class StubProtocol: URLProtocol, @unchecked Sendable { | ||
| 114 | 126 | body: body |
| 115 | 127 | )) |
| 116 | 128 | |
| 117 | guard let stub = box.next() else { | |
| 129 | guard let stub = box.next(for: request.url!) else { | |
| 118 | 130 | client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable)) |
| 119 | 131 | return |
| 120 | 132 | } |