gitbay/MRs/DiffCommentDraft.swift
73 lines · 3346 bytes
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}