main
rb 88 lines 2.81 KB
Raw
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