| 1 | # frozen_string_literal: true |
| 2 | |
| 3 | # Index of the commentable lines in a pull request's diff, built from the |
| 4 | # GitHub "list pull request files" response. GitHub 422s an *entire* review |
| 5 | # when any single comment targets a line that isn't part of the diff, so the |
| 6 | # review job validates every model finding here before posting. |
| 7 | # |
| 8 | # v1 comments only on the RIGHT side (the new file): a line is commentable |
| 9 | # when it appears in a patch hunk as an added (`+`) or context (` `) line, |
| 10 | # keyed by its new-file line number. Files without a `patch` (binaries, very |
| 11 | # large files) have no commentable lines. |
| 12 | class GithubPatchIndex |
| 13 | HUNK_HEADER = /\A@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/ |
| 14 | |
| 15 | # How far a finding's line may be snapped to the nearest commentable line |
| 16 | # before it is dropped (models are often off by a line or two). |
| 17 | CLAMP_DISTANCE = 2 |
| 18 | |
| 19 | def initialize(files) |
| 20 | @lines_by_path = {} |
| 21 | Array(files).each do |file| |
| 22 | path = file["filename"] |
| 23 | next if path.blank? |
| 24 | |
| 25 | @lines_by_path[path] = commentable_lines(file["patch"]) |
| 26 | end |
| 27 | end |
| 28 | |
| 29 | def valid_line?(path, line) |
| 30 | @lines_by_path[path]&.include?(line) || false |
| 31 | end |
| 32 | |
| 33 | # The finding's line if valid, else the nearest commentable line within |
| 34 | # CLAMP_DISTANCE, else nil (caller drops the finding to the summary). |
| 35 | def clamp(path, line) |
| 36 | lines = @lines_by_path[path] |
| 37 | return nil if lines.blank? || !line.is_a?(Integer) |
| 38 | return line if lines.include?(line) |
| 39 | |
| 40 | nearest = lines.min_by { |candidate| (candidate - line).abs } |
| 41 | (nearest - line).abs <= CLAMP_DISTANCE ? nearest : nil |
| 42 | end |
| 43 | |
| 44 | # The patch text with every added/context line prefixed by its new-file line |
| 45 | # number. Feeding the model pre-computed line numbers is what makes the |
| 46 | # `line` values it returns land on commentable lines. |
| 47 | def self.annotate_patch(patch) |
| 48 | return "" if patch.blank? |
| 49 | |
| 50 | new_lineno = nil |
| 51 | patch.each_line.map do |line| |
| 52 | line = line.chomp |
| 53 | if (match = HUNK_HEADER.match(line)) |
| 54 | new_lineno = Integer(match[1], 10) |
| 55 | line |
| 56 | elsif new_lineno.nil? || line.start_with?("-") |
| 57 | line |
| 58 | elsif line.start_with?("+", " ") || line.empty? |
| 59 | annotated = format("%5d %s", new_lineno, line) |
| 60 | new_lineno += 1 |
| 61 | annotated |
| 62 | else |
| 63 | line # "\ No newline at end of file" and other markers |
| 64 | end |
| 65 | end.join("\n") |
| 66 | end |
| 67 | |
| 68 | private |
| 69 | |
| 70 | def commentable_lines(patch) |
| 71 | lines = Set.new |
| 72 | return lines if patch.blank? |
| 73 | |
| 74 | new_lineno = nil |
| 75 | patch.each_line do |line| |
| 76 | line = line.chomp |
| 77 | if (match = HUNK_HEADER.match(line)) |
| 78 | new_lineno = Integer(match[1], 10) |
| 79 | elsif new_lineno.nil? || line.start_with?("-") |
| 80 | # before the first hunk, or an old-side line: no new-file number |
| 81 | elsif line.start_with?("+", " ") || line.empty? |
| 82 | lines << new_lineno |
| 83 | new_lineno += 1 |
| 84 | end |
| 85 | end |
| 86 | lines |
| 87 | end |
| 88 | end |