summaryrefslogtreecommitdiff
path: root/tooling/lib/tooling/find_codeowners.rb
blob: e542ab9967ca2c32f8996c0fdfdbaa40873ace02 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
# frozen_string_literal: true

require 'yaml'

module Tooling
  class FindCodeowners
    def execute
      load_definitions.each do |section, group_defintions|
        puts section

        group_defintions.each do |group, list|
          print_entries(group, list[:entries]) if list[:entries]
          print_expanded_entries(group, list) if list[:allow]

          puts
        end
      end
    end

    def load_definitions
      result = load_config

      result.each do |section, group_defintions|
        group_defintions.each do |group, definitions|
          definitions.transform_values! do |rules|
            case rules
            when Hash
              case rules[:keywords]
              when Array
                rules[:keywords].flat_map do |keyword|
                  rules[:patterns].map do |pattern|
                    pattern % { keyword: keyword }
                  end
                end
              else
                rules[:patterns]
              end
            when Array
              rules
            end
          end
        end
      end

      result
    end

    def load_config
      config_path = "#{__dir__}/../../config/CODEOWNERS.yml"

      YAML.safe_load_file(config_path, symbolize_names: true)
    end

    # Copied and modified from ee/lib/gitlab/code_owners/file.rb
    def path_matches?(pattern, path)
      # `FNM_DOTMATCH` makes sure we also match files starting with a `.`
      # `FNM_PATHNAME` makes sure ** matches path separators
      flags = ::File::FNM_DOTMATCH | ::File::FNM_PATHNAME

      # BEGIN extension
      flags |= ::File::FNM_EXTGLOB
      # END extension

      ::File.fnmatch?(normalize_pattern(pattern), path, flags)
    end

    # Copied from ee/lib/gitlab/code_owners/file.rb
    def normalize_pattern(pattern)
      # Remove `\` when escaping `\#`
      pattern = pattern.sub(/\A\\#/, '#')
      # Replace all whitespace preceded by a \ with a regular whitespace
      pattern = pattern.gsub(/\\\s+/, ' ')

      return '/**/*' if pattern == '*'

      unless pattern.start_with?('/')
        pattern = "/**/#{pattern}"
      end

      if pattern.end_with?('/')
        pattern = "#{pattern}**/*"
      end

      pattern
    end

    def consolidate_paths(matched_files)
      matched_files.group_by { |file| File.dirname(file) }.flat_map do |dir, files|
        # First line is the dir itself
        if find_dir_maxdepth_1(dir).lines.drop(1).sort == files.sort
          "#{dir}\n"
        else
          files
        end
      end.sort
    end

    private

    def print_entries(group, entries)
      entries.each do |entry|
        puts "#{entry} #{group}"
      end
    end

    def print_expanded_entries(group, list)
      matched_files = git_ls_files.each_line.select do |line|
        list[:allow].find do |pattern|
          path = "/#{line.chomp}"

          path_matches?(pattern, path) &&
            (
              list[:deny].nil? ||
              list[:deny].none? { |pattern| path_matches?(pattern, path) }
            )
        end
      end

      consolidated = consolidate_paths(matched_files)
      consolidated_again = consolidate_paths(consolidated)

      # Consider the directory structure is a tree structure:
      # https://en.wikipedia.org/wiki/Tree_(data_structure)
      # After we consolidated the leaf entries, it could be possible that
      # we can consolidate further for the new leaves. Repeat this
      # process until we see no improvements.
      while consolidated_again.size < consolidated.size
        consolidated = consolidated_again
        consolidated_again = consolidate_paths(consolidated)
      end

      consolidated.each do |line|
        path = line.chomp

        if File.directory?(path)
          puts "/#{path}/ #{group}"
        else
          puts "/#{path} #{group}"
        end
      end
    end

    def find_dir_maxdepth_1(dir)
      `find #{dir} -maxdepth 1`
    end

    def git_ls_files
      @git_ls_files ||= `git ls-files`
    end
  end
end