Review comments on diff lines, and suggested changes !156
8 files changed, +556 −4
Layout: unified · split
gitbay/MRs/DiffCommentDraft.swift added +73
| @@ -0,0 +1,73 @@ | ||
| 1 | import 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. | |
| 7 | nonisolated 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 { | ||
| 169 | 169 | ) |
| 170 | 170 | } |
| 171 | 171 | |
| 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 | ||
| 172 | 185 | func setDraft(_ draft: Bool) async { |
| 173 | 186 | await perform(["mr", draft ? "draft" : "ready"] + ref) |
| 174 | 187 | } |
gitbay/MRs/MRModels.swift +12 −1
| @@ -302,16 +302,27 @@ nonisolated struct ReviewThread: Decodable, Sendable, Hashable, Identifiable { | ||
| 302 | 302 | let path: String |
| 303 | 303 | let side: String // new | old |
| 304 | 304 | let line: Int64 |
| 305 | /// The first line of a range; absent for a one-line thread. | |
| 306 | let startLine: Int64? | |
| 305 | 307 | /// Anchored to an older head; the diff has moved under it. |
| 306 | 308 | let stale: Bool |
| 307 | 309 | let resolvedBy: String? |
| 310 | /// What the thread's ```suggestion block proposes (v1.39.0). | |
| 311 | let suggestion: Suggestion? | |
| 308 | 312 | let comments: [Comment] |
| 309 | 313 | |
| 310 | 314 | 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" | |
| 312 | 317 | case resolvedBy = "resolved_by" |
| 313 | 318 | } |
| 314 | 319 | |
| 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 | ||
| 315 | 326 | var isResolved: Bool { resolvedBy != nil } |
| 316 | 327 | |
| 317 | 328 | nonisolated struct Comment: Decodable, Sendable, Hashable, Identifiable { |
gitbay/MRs/Suggestion.swift added +46
| @@ -0,0 +1,46 @@ | ||
| 1 | import 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. | |
| 7 | nonisolated 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 @@ | ||
| 1 | import 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. | |
| 6 | struct 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. | |
| 108 | struct 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 { | ||
| 100 | 100 | let file: UnifiedDiff.File |
| 101 | 101 | let hunk: UnifiedDiff.Hunk |
| 102 | 102 | 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 | } | |
| 103 | 113 | |
| 104 | 114 | var body: some View { |
| 105 | 115 | ScrollView(.horizontal) { |
| @@ -119,7 +129,19 @@ private struct HunkView: View { | ||
| 119 | 129 | Rectangle().fill(Color.gbLine).frame(height: 1) |
| 120 | 130 | } |
| 121 | 131 | 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 | } | |
| 123 | 145 | if let model { |
| 124 | 146 | ForEach(model.threads.filter { line.anchors($0, in: file) }) { thread in |
| 125 | 147 | ReviewThreadView(thread: thread, model: model) |
| @@ -132,6 +154,27 @@ private struct HunkView: View { | ||
| 132 | 154 | } |
| 133 | 155 | } |
| 134 | 156 | .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) | |
| 135 | 178 | } |
| 136 | 179 | } |
| 137 | 180 | |
| @@ -159,6 +202,7 @@ private struct LineView: View { | ||
| 159 | 202 | .background(background) |
| 160 | 203 | .accessibilityElement(children: .ignore) |
| 161 | 204 | .accessibilityLabel(line.accessibilityLabel) |
| 205 | .accessibilityIdentifier("diff-line-\(line.oldNumber ?? 0)-\(line.newNumber ?? 0)") | |
| 162 | 206 | } |
| 163 | 207 | |
| 164 | 208 | private var marker: String { |
gitbay/Views/MRs/MRView.swift +8 −2
| @@ -679,7 +679,7 @@ struct ReviewThreadView: View { | ||
| 679 | 679 | ? "checkmark.bubble" : "bubble.left.and.exclamationmark.bubble.right") |
| 680 | 680 | .font(.gbSans(.caption)) |
| 681 | 681 | .foregroundStyle(thread.isResolved ? Color.gbOK : Color.gbWarn) |
| 682 | Text("\(thread.path):\(thread.line)") | |
| 682 | Text(thread.anchor) | |
| 683 | 683 | .font(.gbMono(.caption)) |
| 684 | 684 | .lineLimit(1) |
| 685 | 685 | if thread.stale { |
| @@ -693,6 +693,9 @@ struct ReviewThreadView: View { | ||
| 693 | 693 | .buttonStyle(.bordered) |
| 694 | 694 | .disabled(model.working) |
| 695 | 695 | } |
| 696 | if let suggestion = thread.suggestion { | |
| 697 | SuggestionView(suggestion: suggestion, thread: thread, model: model) | |
| 698 | } | |
| 696 | 699 | ForEach(thread.comments) { comment in |
| 697 | 700 | VStack(alignment: .leading, spacing: 2) { |
| 698 | 701 | HStack { |
| @@ -703,7 +706,10 @@ struct ReviewThreadView: View { | ||
| 703 | 706 | .foregroundStyle(.tertiary) |
| 704 | 707 | } |
| 705 | 708 | // 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) | |
| 707 | 713 | .font(.gbSans(.subheadline)) |
| 708 | 714 | } |
| 709 | 715 | } |
gitbayTests/SuggestionTests.swift added +187
| @@ -0,0 +1,187 @@ | ||
| 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 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 | ||
| 21 | private 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 | ||
| 33 | private 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 | ||
| 39 | private 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 | ||
| 49 | private let ok = #"{"protocol_version":1,"data":{},"exit_code":0}"# | |
| 50 | ||
| 51 | private 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 | ||
| 56 | struct 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 | ||
| 95 | struct 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 | |
| 137 | struct 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 | } | |