a native ios client for gitbay

client ios swift

https://gitbay.org

Commit 944674b560

944674b56014b53368a05e3c8af5f706ae5ada2b

parent: d98ae629c2

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-27T05:10:09Z

mrs: list, detail, diff, threads, and the review cycle

mr list with a state filter; mr show with commits, checks, reviews and
comments; mr diff parsed client-side from the unified text (the recorded
gap — the web does this server-side) into files, hunks and numbered
lines; mr threads with resolve, reopen and reply via mr diff-comment
--reply.

Writes: approve, request changes, comment, merge (all four strategies),
close. Every write reloads from the server, and a refusal — approvals
missing, threads unresolved, checks red — is shown verbatim: the server
message explains the rule, the app never routes around it. Comment
bodies travel in stdin, the CLI's --file - discipline.

Test stubs can now match by URL substring, since the detail screen
fetches diff and threads concurrently and FIFO stubs raced.

Verified against krz/gitbay's 94 MRs on the simulator; !94's +258/−20
diff renders with correct per-side numbering.

Ref #11
gitbay/ContentView.swift +15
@@ -12,6 +12,9 @@ struct ContentView: View {
1212 .navigationDestination(for: RepoRoute.self) { route in
1313 destination(route, client: client)
1414 }
15 .navigationDestination(for: MRRoute.self) { route in
16 destination(route, client: client)
17 }
1518 } else {
1619 SignInView()
1720 }
@@ -33,6 +36,18 @@ struct ContentView: View {
3336 SignInView()
3437 }
3538 }
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 }
3651 }
3752
3853 #Preview {
gitbay/MRs/MRDetailViewModel.swift added +103
@@ -0,0 +1,103 @@
1import Foundation
2import 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
9final 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 @@
1import Foundation
2import Observation
3
4/// `mr list <repo> --state <s>`.
5@Observable
6@MainActor
7final 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 @@
1import Foundation
2
3/// One row of `mr list`, and the header half of `mr show`.
4nonisolated 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`.
29nonisolated 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`.
93nonisolated 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 @@
1import 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.
6nonisolated 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 {
207207
208208 private enum Surface { case read, command }
209209
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`).
211212 private static func decoder() -> JSONDecoder {
212213 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 }
214228 return decoder
215229 }
216230
gitbay/Views/MRs/DiffView.swift added +163
@@ -0,0 +1,163 @@
1import 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.
5struct 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
48private 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
94private 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
114private 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 @@
1import SwiftUI
2
3struct 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
36struct 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
67struct 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.
90nonisolated 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 @@
1import SwiftUI
2
3struct 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.
275private 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 {
2222 NavigationLink(value: RepoRoute.log(repo: path)) {
2323 Label("History", systemImage: "clock")
2424 }
25 NavigationLink(value: MRRoute.list(repo: path)) {
26 Label("Merge Requests", systemImage: "arrow.triangle.merge")
27 }
2528 }
2629
2730 if let readme = model.readme {
gitbayTests/MRViewModelTests.swift added +279
@@ -0,0 +1,279 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private 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
15private 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
26private 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
37private 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
47private 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
73private 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
82struct 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
141struct 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
178struct 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 {
1212 let status: Int
1313 let headers: [String: String]
1414 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?
1519
16 init(status: Int, headers: [String: String] = [:], json: String) {
20 init(status: Int, headers: [String: String] = [:], json: String, match: String? = nil) {
1721 self.status = status
1822 var headers = headers
1923 headers["Content-Type"] = headers["Content-Type"] ?? "application/json"
2024 self.headers = headers
2125 self.body = Data(json.utf8)
26 self.match = match
2227 }
2328
24 init(status: Int, headers: [String: String] = [:], body: Data = Data()) {
29 init(status: Int, headers: [String: String] = [:], body: Data = Data(), match: String? = nil) {
2530 self.status = status
2631 self.headers = headers
2732 self.body = body
33 self.match = match
2834 }
2935 }
3036
@@ -60,8 +66,14 @@ nonisolated final class StubProtocol: URLProtocol, @unchecked Sendable {
6066 return URLSession(configuration: configuration)
6167 }
6268
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 }
6577 }
6678
6779 fileprivate func record(_ request: Seen) {
@@ -114,7 +126,7 @@ nonisolated final class StubProtocol: URLProtocol, @unchecked Sendable {
114126 body: body
115127 ))
116128
117 guard let stub = box.next() else {
129 guard let stub = box.next(for: request.url!) else {
118130 client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable))
119131 return
120132 }