Review comments on diff lines, and suggested changes !156

merged merged by cmc on 2026-10-14 05:28 UTC · krz/gitbay-ios:suggestions into main

8 files changed, +556 −4

Layout: unified · split

gitbay/MRs/DiffCommentDraft.swift added +73
@@ -0,0 +1,73 @@
1import Foundation
2
3/// A new review comment on lines of a merge request's diff: where it is
4/// anchored, and the ```suggestion block that proposes replacement lines.
5/// `mr diff-comment` decides whether the anchor is in the diff; this only
6/// builds what the command is asked.
7nonisolated struct DiffCommentDraft: Sendable, Hashable, Identifiable {
8 let path: String
9 /// The old side is a removed line; everything else anchors on the new file.
10 let old: Bool
11 /// The last line of the range, the one the comment was started from.
12 let line: Int
13 /// The lines on the same side of the hunk, in order, that a range may
14 /// start from: the tapped line and those above it, back to the first
15 /// line of the hunk on that side.
16 let candidates: [(number: Int, text: String)]
17
18 var id: String { "\(path):\(old ? "old" : "new"):\(line)" }
19
20 static func == (a: Self, b: Self) -> Bool { a.id == b.id }
21 func hash(into hasher: inout Hasher) { hasher.combine(id) }
22
23 /// A suggestion replaces new-file lines; a removed line has nothing
24 /// to replace.
25 var canSuggest: Bool { !old }
26
27 /// The earliest line a range may start on.
28 var firstLine: Int { candidates.first?.number ?? line }
29
30 /// The draft for a line of the diff, or nil for a line no side can
31 /// anchor (none in a well-formed diff).
32 init?(file: UnifiedDiff.File, hunk: UnifiedDiff.Hunk, line target: UnifiedDiff.Line) {
33 let old = target.kind == .deletion
34 guard let number = old ? target.oldNumber : target.newNumber else { return nil }
35 // The server checks --path against the diff's file names, which
36 // list a rename under its new name only, on either side.
37 self.path = file.displayPath
38 self.old = old
39 self.line = number
40 var lines: [(Int, String)] = []
41 for l in hunk.lines {
42 let n = old ? (l.kind == .addition ? nil : l.oldNumber) : (l.kind == .deletion ? nil : l.newNumber)
43 guard let n else { continue }
44 if n > number { break }
45 lines.append((n, l.text))
46 }
47 self.candidates = lines
48 }
49
50 /// `mr diff-comment <owner/name> <n> --path <p> --line <l> [--start-line <s>] [--old] --file -`.
51 func argv(repo: String, number: Int64, startLine: Int) -> [String] {
52 var argv = ["mr", "diff-comment", repo, String(number), "--path", path, "--line", String(line)]
53 if startLine < line { argv += ["--start-line", String(startLine)] }
54 if old { argv.append("--old") }
55 argv += ["--file", "-"]
56 return argv
57 }
58
59 /// The ```suggestion block for lines `startLine` through `line`, with
60 /// the current text in it to edit.
61 func suggestion(from startLine: Int) -> String {
62 let text = candidates.filter { $0.number >= startLine }.map(\.text)
63 return "```suggestion\n" + text.map { $0 + "\n" }.joined() + "```\n"
64 }
65
66 /// The body after the range's start moves from `old` to `new`: an
67 /// untouched suggestion is rebuilt to cover the new range, since
68 /// sending the old one would replace the whole range with fewer
69 /// lines. Text the user changed is theirs and stays.
70 func rebuilt(_ text: String, from old: Int, to new: Int) -> String {
71 text == suggestion(from: old) ? suggestion(from: new) : text
72 }
73}
gitbay/MRs/MRDetailViewModel.swift +13
@@ -169,6 +169,19 @@ final class MRDetailViewModel {
169169 )
170170 }
171171
172 /// Start a review thread on lines of the diff. The body goes over
173 /// stdin; a ```suggestion block in it is the server's to read. Returns
174 /// whether it was accepted, so the sheet can close.
175 func diffComment(_ draft: DiffCommentDraft, startLine: Int, body: String) async -> Bool {
176 await perform(draft.argv(repo: repoPath, number: number, startLine: startLine), stdin: body)
177 return actionError == nil
178 }
179
180 /// Commit a thread's suggestion to the source branch on the server.
181 func applySuggestion(_ thread: ReviewThread) async {
182 await perform(["mr", "apply-suggestion"] + ref + [String(thread.id)])
183 }
184
172185 func setDraft(_ draft: Bool) async {
173186 await perform(["mr", draft ? "draft" : "ready"] + ref)
174187 }
gitbay/MRs/MRModels.swift +12 −1
@@ -302,16 +302,27 @@ nonisolated struct ReviewThread: Decodable, Sendable, Hashable, Identifiable {
302302 let path: String
303303 let side: String // new | old
304304 let line: Int64
305 /// The first line of a range; absent for a one-line thread.
306 let startLine: Int64?
305307 /// Anchored to an older head; the diff has moved under it.
306308 let stale: Bool
307309 let resolvedBy: String?
310 /// What the thread's ```suggestion block proposes (v1.39.0).
311 let suggestion: Suggestion?
308312 let comments: [Comment]
309313
310314 enum CodingKeys: String, CodingKey {
311 case id, path, side, line, stale, comments
315 case id, path, side, line, stale, suggestion, comments
316 case startLine = "start_line"
312317 case resolvedBy = "resolved_by"
313318 }
314319
320 /// `path:line`, or `path:start–line` for a range.
321 var anchor: String {
322 if let startLine, startLine < line { return "\(path):\(startLine)–\(line)" }
323 return "\(path):\(line)"
324 }
325
315326 var isResolved: Bool { resolvedBy != nil }
316327
317328 nonisolated struct Comment: Decodable, Sendable, Hashable, Identifiable {
gitbay/MRs/Suggestion.swift added +46
@@ -0,0 +1,46 @@
1import Foundation
2
3/// The change a review thread's ```suggestion block proposes, as
4/// `mr threads` carries it: lines `startLine` through `endLine` of `path`,
5/// `original` replaced by `replacement`. Both texts end every line with
6/// its terminator, so "" is no lines.
7nonisolated struct Suggestion: Decodable, Sendable, Hashable {
8 let path: String
9 let startLine: Int64
10 let endLine: Int64
11 let original: String
12 let replacement: String
13 /// The lines it replaces have changed at the head, or the file moved;
14 /// it cannot be applied then, and `reason` says which.
15 let outdated: Bool
16 let reason: String?
17 /// "server" where `mr apply-suggestion` commits it; "local" where the
18 /// repository requires signed commits, which only a clone can make.
19 let apply: String
20
21 enum CodingKeys: String, CodingKey {
22 case path, original, replacement, outdated, reason, apply
23 case startLine = "start_line"
24 case endLine = "end_line"
25 }
26
27 var appliesOnServer: Bool { apply == "server" }
28
29 /// The text as lines, without the terminator the last one carries.
30 static func lines(_ text: String) -> [String] {
31 guard !text.isEmpty else { return [] }
32 var lines = text.components(separatedBy: "\n")
33 if lines.last == "" { lines.removeLast() }
34 return lines
35 }
36
37 /// A comment's text without its ```suggestion block, which the thread
38 /// draws as replaced and proposed lines instead.
39 static func withoutBlock(_ body: String) -> String {
40 guard let start = body.range(of: "```suggestion") else { return body }
41 let afterOpen = body[start.upperBound...]
42 guard let close = afterOpen.range(of: "```") else { return body }
43 let rest = body[..<start.lowerBound] + body[close.upperBound...]
44 return rest.trimmingCharacters(in: .whitespacesAndNewlines)
45 }
46}
gitbay/Views/MRs/DiffCommentSheet.swift added +172
@@ -0,0 +1,172 @@
1import SwiftUI
2
3/// A new review comment on a line of the diff, or a range ending on it.
4/// On the new side the comment can carry a ```suggestion block; the
5/// server reads the block out of the body, so the sheet only writes text.
6struct DiffCommentSheet: View {
7
8 let draft: DiffCommentDraft
9 let model: MRDetailViewModel
10 let suggesting: Bool
11
12 @State private var startLine: Int
13 @State private var text: String
14 @State private var sent = false
15 @Environment(\.dismiss) private var dismiss
16
17 init(draft: DiffCommentDraft, model: MRDetailViewModel, suggesting: Bool) {
18 self.draft = draft
19 self.model = model
20 self.suggesting = suggesting
21 _startLine = State(initialValue: draft.line)
22 _text = State(initialValue: suggesting ? draft.suggestion(from: draft.line) : "")
23 }
24
25 var body: some View {
26 NavigationStack {
27 Form {
28 Section {
29 Text(anchor)
30 .font(.gbMono(.caption))
31 .accessibilityIdentifier("diff-comment-anchor")
32 if draft.firstLine < draft.line {
33 Stepper(value: $startLine, in: draft.firstLine...draft.line) {
34 Text("From line \(startLine)")
35 .font(.gbSans(.subheadline))
36 }
37 .onChange(of: startLine) { old, new in
38 text = draft.rebuilt(text, from: old, to: new)
39 }
40 .accessibilityIdentifier("diff-comment-start")
41 }
42 }
43 Section {
44 TextEditor(text: $text)
45 .font(.gbMono(.subheadline))
46 .frame(minHeight: 160)
47 .autocorrectionDisabled()
48 .textInputAutocapitalization(.never)
49 .accessibilityIdentifier("diff-comment-body")
50 if draft.canSuggest {
51 Button("Insert suggestion") {
52 if !text.isEmpty && !text.hasSuffix("\n") { text += "\n" }
53 text += draft.suggestion(from: startLine)
54 }
55 .font(.gbSans(.subheadline))
56 .accessibilityIdentifier("diff-comment-insert-suggestion")
57 }
58 MarkupPreview(text: text, format: "md", identifier: "diff-comment-preview")
59 } footer: {
60 if draft.canSuggest {
61 Text("A suggestion replaces the lines in its range with what the block holds. An empty block deletes them.")
62 }
63 }
64 // Only this sheet's own send: an error left from an earlier
65 // action on the screen is not this comment's.
66 if sent, let error = model.actionError {
67 Section { GBNotice(error) }
68 }
69 }
70 .scrollDismissesKeyboard(.interactively)
71 .navigationTitle(suggesting ? "Suggest a Change" : "Comment")
72 .navigationBarTitleDisplayMode(.inline)
73 .toolbar {
74 ToolbarItem(placement: .cancellationAction) {
75 Button("Cancel") { dismiss() }
76 }
77 ToolbarItem(placement: .confirmationAction) {
78 if model.working {
79 ProgressView()
80 } else {
81 Button("Send") {
82 sent = true
83 Task {
84 if await model.diffComment(draft, startLine: startLine, body: text) {
85 dismiss()
86 }
87 }
88 }
89 .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
90 .accessibilityIdentifier("diff-comment-send")
91 }
92 }
93 }
94 .interactiveDismissDisabled(model.working)
95 }
96 }
97
98 private var anchor: String {
99 let side = draft.old ? " (old)" : ""
100 return startLine < draft.line
101 ? "\(draft.path):\(startLine)–\(draft.line)\(side)"
102 : "\(draft.path):\(draft.line)\(side)"
103 }
104}
105
106/// What a thread's suggestion replaces and proposes, as removed and added
107/// lines, with the action the server allows.
108struct SuggestionView: View {
109
110 let suggestion: Suggestion
111 let thread: ReviewThread
112 let model: MRDetailViewModel
113 @ScaledMetric(relativeTo: .caption2) private var markerWidth: CGFloat = 14
114
115 var body: some View {
116 VStack(alignment: .leading, spacing: 6) {
117 HStack(spacing: 6) {
118 Text("Suggested change")
119 .font(.gbSans(.caption).weight(.semibold))
120 if suggestion.outdated {
121 GBChip("outdated", .gbWarn)
122 }
123 }
124 VStack(alignment: .leading, spacing: 0) {
125 ForEach(Array(Suggestion.lines(suggestion.original).enumerated()), id: \.offset) { _, line in
126 row("−", line, .gbBad, .gbDiffDel)
127 }
128 ForEach(Array(Suggestion.lines(suggestion.replacement).enumerated()), id: \.offset) { _, line in
129 row("+", line, .gbOK, .gbDiffAdd)
130 }
131 }
132 .font(.gbMono(.caption2))
133 if suggestion.outdated {
134 Text(suggestion.reason ?? "The lines it replaces have changed.")
135 .font(.gbSans(.caption))
136 .foregroundStyle(.secondary)
137 } else if !thread.isResolved {
138 if suggestion.appliesOnServer {
139 Button("Apply suggestion") {
140 Task { await model.applySuggestion(thread) }
141 }
142 .font(.gbSans(.caption))
143 .buttonStyle(.bordered)
144 .disabled(model.working)
145 .accessibilityIdentifier("apply-suggestion-\(thread.id)")
146 } else {
147 // The repository requires signed commits, and only the
148 // user's own key can sign; the CLI applies it from a clone.
149 Text("This repository requires signed commits. Apply it from a clone:")
150 .font(.gbSans(.caption))
151 .foregroundStyle(.secondary)
152 Text("gitbay mr apply-suggestion \(model.number) \(thread.id)")
153 .font(.gbMono(.caption))
154 .textSelection(.enabled)
155 }
156 }
157 }
158 }
159
160 private func row(_ marker: String, _ text: String, _ markerColor: Color, _ background: Color) -> some View {
161 HStack(spacing: 0) {
162 Text(marker)
163 .frame(width: markerWidth)
164 .foregroundStyle(markerColor)
165 Text(text.isEmpty ? " " : text)
166 Spacer(minLength: 0)
167 }
168 .background(background)
169 .accessibilityElement(children: .ignore)
170 .accessibilityLabel("\(marker == "+" ? "Proposed" : "Replaced"): \(text)")
171 }
172}
gitbay/Views/MRs/DiffView.swift +45 −1
@@ -100,6 +100,16 @@ private struct HunkView: View {
100100 let file: UnifiedDiff.File
101101 let hunk: UnifiedDiff.Hunk
102102 let model: MRDetailViewModel?
103 @State private var composing: Compose?
104 /// The line tapped, whose actions are on offer.
105 @State private var picked: DiffCommentDraft?
106
107 /// A comment being written on a line, and whether it opened as a suggestion.
108 private struct Compose: Identifiable {
109 let draft: DiffCommentDraft
110 let suggesting: Bool
111 var id: String { draft.id + (suggesting ? ":s" : "") }
112 }
103113
104114 var body: some View {
105115 ScrollView(.horizontal) {
@@ -119,7 +129,19 @@ private struct HunkView: View {
119129 Rectangle().fill(Color.gbLine).frame(height: 1)
120130 }
121131 ForEach(hunk.lines) { line in
122 LineView(line: line)
132 if model != nil {
133 // A tap rather than a context menu: in a list row SwiftUI
134 // hoists the first line's menu to the whole hunk, so every
135 // line would offer line one's anchor.
136 LineView(line: line)
137 .contentShape(Rectangle())
138 .onTapGesture { pick(line) }
139 .accessibilityAddTraits(.isButton)
140 .accessibilityHint("Comment on this line")
141 .accessibilityAction { pick(line) }
142 } else {
143 LineView(line: line)
144 }
123145 if let model {
124146 ForEach(model.threads.filter { line.anchors($0, in: file) }) { thread in
125147 ReviewThreadView(thread: thread, model: model)
@@ -132,6 +154,27 @@ private struct HunkView: View {
132154 }
133155 }
134156 .listRowInsets(EdgeInsets(top: 2, leading: 8, bottom: 2, trailing: 8))
157 .confirmationDialog(
158 picked.map { "\($0.path):\($0.line)\($0.old ? " (old)" : "")" } ?? "",
159 isPresented: Binding(get: { picked != nil }, set: { if !$0 { picked = nil } }),
160 titleVisibility: .visible,
161 presenting: picked
162 ) { draft in
163 Button("Comment") { composing = Compose(draft: draft, suggesting: false) }
164 if draft.canSuggest {
165 Button("Suggest a change") { composing = Compose(draft: draft, suggesting: true) }
166 }
167 Button("Cancel", role: .cancel) {}
168 }
169 .sheet(item: $composing) { compose in
170 if let model {
171 DiffCommentSheet(draft: compose.draft, model: model, suggesting: compose.suggesting)
172 }
173 }
174 }
175
176 private func pick(_ line: UnifiedDiff.Line) {
177 picked = DiffCommentDraft(file: file, hunk: hunk, line: line)
135178 }
136179}
137180
@@ -159,6 +202,7 @@ private struct LineView: View {
159202 .background(background)
160203 .accessibilityElement(children: .ignore)
161204 .accessibilityLabel(line.accessibilityLabel)
205 .accessibilityIdentifier("diff-line-\(line.oldNumber ?? 0)-\(line.newNumber ?? 0)")
162206 }
163207
164208 private var marker: String {
gitbay/Views/MRs/MRView.swift +8 −2
@@ -679,7 +679,7 @@ struct ReviewThreadView: View {
679679 ? "checkmark.bubble" : "bubble.left.and.exclamationmark.bubble.right")
680680 .font(.gbSans(.caption))
681681 .foregroundStyle(thread.isResolved ? Color.gbOK : Color.gbWarn)
682 Text("\(thread.path):\(thread.line)")
682 Text(thread.anchor)
683683 .font(.gbMono(.caption))
684684 .lineLimit(1)
685685 if thread.stale {
@@ -693,6 +693,9 @@ struct ReviewThreadView: View {
693693 .buttonStyle(.bordered)
694694 .disabled(model.working)
695695 }
696 if let suggestion = thread.suggestion {
697 SuggestionView(suggestion: suggestion, thread: thread, model: model)
698 }
696699 ForEach(thread.comments) { comment in
697700 VStack(alignment: .leading, spacing: 2) {
698701 HStack {
@@ -703,7 +706,10 @@ struct ReviewThreadView: View {
703706 .foregroundStyle(.tertiary)
704707 }
705708 // Review comments carry no format; the web renders them as Markdown.
706 FormattedBodyView(text: comment.body, repo: model.repoPath)
709 // The thread's first comment holds its suggestion, drawn above.
710 FormattedBodyView(text: thread.suggestion != nil && comment.id == thread.comments.first?.id
711 ? Suggestion.withoutBlock(comment.body) : comment.body,
712 repo: model.repoPath)
707713 .font(.gbSans(.subheadline))
708714 }
709715 }
gitbayTests/SuggestionTests.swift added +187
@@ -0,0 +1,187 @@
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 func posted(_ stub: StubProtocol.Box) throws -> ([String], String?) {
16 let seen = try #require(stub.seen.first { $0.method == "POST" })
17 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
18 return (try #require(body["argv"] as? [String]), body["stdin"] as? String)
19}
20
21private let diffText = """
22 diff --git a/main.go b/main.go
23 --- a/main.go
24 +++ b/main.go
25 @@ -10,4 +10,4 @@ func main() {
26 \tname := os.Args[1]
27 -\tlog.Print("starting")
28 +\tlog.Printf("starting %s", name)
29 \trun(name)
30 }
31 """
32
33private let mrShowJSON = """
34 {"protocol_version":1,"data":{"number":4,"title":"t","state":"open","author":"krz",\
35 "source":"feat","target_ref":"main","head_sha":"abc","created_at":"2026-08-20T10:00:00.000Z"},\
36 "exit_code":0}
37 """
38
39private let threadsJSON = """
40 {"protocol_version":1,"data":[{"id":9,"path":"main.go","side":"new","start_line":11,"line":12,\
41 "stale":false,"suggestion":{"path":"main.go","start_line":11,"end_line":12,"commit":"abc",\
42 "original":"\\tlog.Printf(\\"starting %s\\", name)\\n\\trun(name)\\n",\
43 "replacement":"\\tlog.Printf(\\"starting %q\\", name)\\n\\trun(name)\\n",\
44 "outdated":false,"apply":"server"},\
45 "comments":[{"id":9,"author":"cmc","body":"quote it","created_at":"2026-08-22T09:00:00.000Z"}]}],\
46 "exit_code":0}
47 """
48
49private let ok = #"{"protocol_version":1,"data":{},"exit_code":0}"#
50
51private func hunk() throws -> (UnifiedDiff.File, UnifiedDiff.Hunk) {
52 let file = try #require(UnifiedDiff.parse(diffText).files.first)
53 return (file, try #require(file.hunks.first))
54}
55
56struct DiffCommentDraftTests {
57
58 @Test func aNewSideLineAnchorsOnTheNewFileAndCanSuggest() throws {
59 let (file, hunk) = try hunk()
60 let target = try #require(hunk.lines.first { $0.newNumber == 12 && $0.kind == .context })
61 let draft = try #require(DiffCommentDraft(file: file, hunk: hunk, line: target))
62
63 #expect(draft.canSuggest)
64 #expect(draft.line == 12)
65 // The removed line has no new-side number and is not a candidate.
66 #expect(draft.candidates.map(\.number) == [10, 11, 12])
67 #expect(draft.firstLine == 10)
68 #expect(draft.argv(repo: "krz/gitbay", number: 4, startLine: 12)
69 == ["mr", "diff-comment", "krz/gitbay", "4", "--path", "main.go", "--line", "12", "--file", "-"])
70 #expect(draft.argv(repo: "krz/gitbay", number: 4, startLine: 11)
71 == ["mr", "diff-comment", "krz/gitbay", "4", "--path", "main.go", "--line", "12",
72 "--start-line", "11", "--file", "-"])
73 }
74
75 @Test func theSuggestionBlockCarriesTheRangesCurrentText() throws {
76 let (file, hunk) = try hunk()
77 let target = try #require(hunk.lines.first { $0.newNumber == 12 && $0.kind == .context })
78 let draft = try #require(DiffCommentDraft(file: file, hunk: hunk, line: target))
79
80 #expect(draft.suggestion(from: 11) == "```suggestion\n\tlog.Printf(\"starting %s\", name)\n\trun(name)\n```\n")
81 }
82
83 @Test func aRemovedLineAnchorsOnTheOldSideAndCannotSuggest() throws {
84 let (file, hunk) = try hunk()
85 let target = try #require(hunk.lines.first { $0.kind == .deletion })
86 let draft = try #require(DiffCommentDraft(file: file, hunk: hunk, line: target))
87
88 #expect(!draft.canSuggest)
89 #expect(draft.candidates.map(\.number) == [10, 11])
90 #expect(draft.argv(repo: "krz/gitbay", number: 4, startLine: 11)
91 == ["mr", "diff-comment", "krz/gitbay", "4", "--path", "main.go", "--line", "11", "--old", "--file", "-"])
92 }
93}
94
95struct SuggestionReviewFixTests {
96
97 /// The server checks --path against the diff's names, which list a
98 /// renamed file under its new name only, so both sides send it.
99 @Test func aRemovedLineInARenamedFileSendsTheNewPath() throws {
100 let diff = UnifiedDiff.parse("""
101 diff --git a/old.go b/new.go
102 --- a/old.go
103 +++ b/new.go
104 @@ -1,2 +1,2 @@
105 -a
106 +b
107 c
108 """)
109 let file = try #require(diff.files.first)
110 let hunk = try #require(file.hunks.first)
111 let removed = try #require(hunk.lines.first { $0.kind == .deletion })
112 #expect(DiffCommentDraft(file: file, hunk: hunk, line: removed)?.path == "new.go")
113 }
114
115 /// Moving the start of the range rebuilds an untouched suggestion so it
116 /// covers the range, and leaves an edited one alone.
117 @Test func movingTheRangeStartRebuildsAnUntouchedSuggestion() throws {
118 let (file, hunk) = try hunk()
119 let target = try #require(hunk.lines.first { $0.newNumber == 12 && $0.kind == .context })
120 let draft = try #require(DiffCommentDraft(file: file, hunk: hunk, line: target))
121 let untouched = draft.suggestion(from: 12)
122
123 #expect(draft.rebuilt(untouched, from: 12, to: 11) == draft.suggestion(from: 11))
124 #expect(draft.rebuilt("my own words", from: 12, to: 11) == "my own words")
125 }
126
127 /// The thread draws the suggestion as replaced and proposed lines, so
128 /// the comment shows the rest of its text without the block.
129 @Test func theBlockIsLeftOutOfTheCommentShownBesideTheSuggestion() {
130 #expect(Suggestion.withoutBlock("Say who.\n```suggestion\n return x\n```\n") == "Say who.")
131 #expect(Suggestion.withoutBlock("```suggestion\n```\n") == "")
132 #expect(Suggestion.withoutBlock("no block here") == "no block here")
133 }
134}
135
136@MainActor
137struct SuggestionViewModelTests {
138
139 private func loaded() async throws -> (MRDetailViewModel, StubProtocol.Box) {
140 let (client, stub) = try makeClient()
141 stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
142 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"output":"","exit_code":0}"#, match: "argv=diff"))
143 stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
144 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#, match: "argv=revisions"))
145 let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 4)
146 await model.load()
147 return (model, stub)
148 }
149
150 @Test func threadsDecodeTheirRangeAndSuggestion() async throws {
151 let (model, _) = try await loaded()
152
153 let thread = try #require(model.threads.first)
154 #expect(thread.startLine == 11)
155 let suggestion = try #require(thread.suggestion)
156 #expect(suggestion.appliesOnServer)
157 #expect(!suggestion.outdated)
158 #expect(Suggestion.lines(suggestion.replacement) == ["\tlog.Printf(\"starting %q\", name)", "\trun(name)"])
159 #expect(Suggestion.lines("") == [])
160 }
161
162 @Test func applyingASuggestionNamesItsThread() async throws {
163 let (model, stub) = try await loaded()
164 stub.enqueue(.init(status: 200, json: ok, match: "cmd"))
165
166 await model.applySuggestion(try #require(model.threads.first))
167
168 let (argv, _) = try posted(stub)
169 #expect(argv == ["mr", "apply-suggestion", "krz/gitbay", "4", "9"])
170 }
171
172 @Test func aNewCommentSendsItsAnchorAndBodyOnStdin() async throws {
173 let (model, stub) = try await loaded()
174 stub.enqueue(.init(status: 200, json: ok, match: "cmd"))
175 let (file, hunk) = try hunk()
176 let target = try #require(hunk.lines.first { $0.newNumber == 12 && $0.kind == .context })
177 let draft = try #require(DiffCommentDraft(file: file, hunk: hunk, line: target))
178
179 let sent = await model.diffComment(draft, startLine: 11, body: "quote it")
180
181 #expect(sent)
182 let (argv, stdin) = try posted(stub)
183 #expect(argv == ["mr", "diff-comment", "krz/gitbay", "4", "--path", "main.go", "--line", "12",
184 "--start-line", "11", "--file", "-"])
185 #expect(stdin == "quote it")
186 }
187}