check_codeowners.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. #!/usr/bin/env python
  2. #
  3. # Utility script for ESP-IDF developers to work with the CODEOWNERS file.
  4. #
  5. # SPDX-FileCopyrightText: 2020-2021 Espressif Systems (Shanghai) CO LTD
  6. # SPDX-License-Identifier: Apache-2.0
  7. import argparse
  8. import os
  9. import re
  10. import subprocess
  11. import sys
  12. from typing import List, Optional
  13. from idf_ci_utils import IDF_PATH
  14. CODEOWNERS_PATH = os.path.join(IDF_PATH, '.gitlab', 'CODEOWNERS')
  15. CODEOWNER_GROUP_PREFIX = '@esp-idf-codeowners/'
  16. def get_all_files() -> List[str]:
  17. """
  18. Get list of all file paths in the repository.
  19. """
  20. # only split on newlines, since file names may contain spaces
  21. return subprocess.check_output(['git', 'ls-files'], cwd=IDF_PATH).decode('utf-8').strip().split('\n')
  22. def pattern_to_regex(pattern: str) -> str:
  23. """
  24. Convert the CODEOWNERS path pattern into a regular expression string.
  25. """
  26. orig_pattern = pattern # for printing errors later
  27. # Replicates the logic from normalize_pattern function in Gitlab ee/lib/gitlab/code_owners/file.rb:
  28. if not pattern.startswith('/'):
  29. pattern = '/**/' + pattern
  30. if pattern.endswith('/'):
  31. pattern = pattern + '**/*'
  32. # Convert the glob pattern into a regular expression:
  33. # first into intermediate tokens
  34. pattern = (pattern.replace('**/', ':REGLOB:')
  35. .replace('**', ':INVALID:')
  36. .replace('*', ':GLOB:')
  37. .replace('.', ':DOT:')
  38. .replace('?', ':ANY:'))
  39. if pattern.find(':INVALID:') >= 0:
  40. raise ValueError("Likely invalid pattern '{}': '**' should be followed by '/'".format(orig_pattern))
  41. # then into the final regex pattern:
  42. re_pattern = (pattern.replace(':REGLOB:', '(?:.*/)?')
  43. .replace(':GLOB:', '[^/]*')
  44. .replace(':DOT:', '[.]')
  45. .replace(':ANY:', '.') + '$')
  46. if re_pattern.startswith('/'):
  47. re_pattern = '^' + re_pattern
  48. return re_pattern
  49. def files_by_regex(all_files: List, regex: re.Pattern) -> List:
  50. """
  51. Return all files in the repository matching the given regular expresion.
  52. """
  53. return [file for file in all_files if regex.search('/' + file)]
  54. def files_by_pattern(all_files: list, pattern: Optional[str]=None) -> List:
  55. """
  56. Return all the files in the repository matching the given CODEOWNERS pattern.
  57. """
  58. if not pattern:
  59. return all_files
  60. return files_by_regex(all_files, re.compile(pattern_to_regex(pattern)))
  61. def action_identify(args: argparse.Namespace) -> None:
  62. best_match = []
  63. all_files = get_all_files()
  64. with open(CODEOWNERS_PATH) as f:
  65. for line in f:
  66. line = line.strip()
  67. if not line or line.startswith('#'):
  68. continue
  69. tokens = line.split()
  70. path_pattern = tokens[0]
  71. owners = tokens[1:]
  72. files = files_by_pattern(all_files, path_pattern)
  73. if args.path in files:
  74. best_match = owners
  75. for owner in best_match:
  76. print(owner)
  77. def action_test_pattern(args: argparse.Namespace) -> None:
  78. re_pattern = pattern_to_regex(args.pattern)
  79. if args.regex:
  80. print(re_pattern)
  81. return
  82. files = files_by_regex(get_all_files(), re.compile(re_pattern))
  83. for f in files:
  84. print(f)
  85. def action_ci_check(args: argparse.Namespace) -> None:
  86. errors = []
  87. def add_error(msg: str) -> None:
  88. errors.append('{}:{}: {}'.format(CODEOWNERS_PATH, line_no, msg))
  89. all_files = get_all_files()
  90. prev_path_pattern = ''
  91. with open(CODEOWNERS_PATH) as f:
  92. for line_no, line in enumerate(f, start=1):
  93. # Skip empty lines and comments
  94. line = line.strip()
  95. if line.startswith('# sort-order-reset'):
  96. prev_path_pattern = ''
  97. if (not line
  98. or line.startswith('#') # comment
  99. or line.startswith('[') # file group
  100. or line.startswith('^[')): # optional file group
  101. continue
  102. # Each line has a form of "<path> <owners>+"
  103. tokens = line.split()
  104. path_pattern = tokens[0]
  105. owners = tokens[1:]
  106. if not owners:
  107. add_error('no owners specified for {}'.format(path_pattern))
  108. # Check that the file is sorted by path patterns
  109. if not in_order(prev_path_pattern, path_pattern):
  110. add_error('file is not sorted: {} < {}'.format(path_pattern, prev_path_pattern))
  111. prev_path_pattern = path_pattern
  112. # Check that the pattern matches at least one file
  113. files = files_by_pattern(all_files, path_pattern)
  114. if not files:
  115. add_error('no files matched by pattern {}'.format(path_pattern))
  116. for o in owners:
  117. # Sanity-check the owner group name
  118. if not o.startswith(CODEOWNER_GROUP_PREFIX):
  119. add_error("owner {} doesn't start with {}".format(o, CODEOWNER_GROUP_PREFIX))
  120. if not errors:
  121. print('No errors found.')
  122. else:
  123. print('Errors found!')
  124. for e in errors:
  125. print(e)
  126. raise SystemExit(1)
  127. def in_order(prev: str, current: str) -> bool:
  128. """
  129. Return True if the ordering is correct for these two lines ('prev' should be before 'current').
  130. Codeowners should be ordered alphabetically, except that order is also significant for the codeowners
  131. syntax (the last matching line has priority).
  132. This means that wildcards are allowed in either order (if wildcard placed first, it's placed before a
  133. more specific pattern as a catch-all fallback. If wildcard placed second, it's to override the match
  134. made on a previous line i.e. '/xyz/**/*.py' to override the owner of the Python files inside /xyz/ ).
  135. """
  136. if not prev:
  137. return True # first element in file
  138. def is_separator(c: str) -> bool:
  139. return c in '-_/' # ignore differences between separators for ordering purposes
  140. def is_wildcard(c: str) -> bool:
  141. return c in '?*'
  142. # looping until we see a different character
  143. for a,b in zip(prev, current):
  144. if is_separator(a) and is_separator(b):
  145. continue
  146. if is_wildcard(a) or is_wildcard(b):
  147. return True # if the strings matched up to one of them having a wildcard, treat as in order
  148. if a != b:
  149. return b > a
  150. assert a == b
  151. # common substrings up to the common length are the same, so the longer string should be after
  152. return len(current) >= len(prev)
  153. def main() -> None:
  154. parser = argparse.ArgumentParser(
  155. sys.argv[0], description='Internal helper script for working with the CODEOWNERS file.'
  156. )
  157. subparsers = parser.add_subparsers(dest='action')
  158. identify = subparsers.add_parser(
  159. 'identify',
  160. help='List the owners of the specified path within IDF.'
  161. "This command doesn't support files inside submodules, or files not added to git repository.",
  162. )
  163. identify.add_argument('path', help='Path of the file relative to the root of the repository')
  164. subparsers.add_parser(
  165. 'ci-check',
  166. help='Check CODEOWNERS file: every line should match at least one file, sanity-check group names, '
  167. 'check that the file is sorted by paths',
  168. )
  169. test_pattern = subparsers.add_parser(
  170. 'test-pattern',
  171. help='Print files in the repository for a given CODEOWNERS pattern. Useful when adding new rules.'
  172. )
  173. test_pattern.add_argument('--regex', action='store_true', help='Print the equivalent regular expression instead of the file list.')
  174. test_pattern.add_argument('pattern', help='Path pattern to get the list of files for')
  175. args = parser.parse_args()
  176. if args.action is None:
  177. parser.print_help()
  178. parser.exit(1)
  179. action_func_name = 'action_' + args.action.replace('-', '_')
  180. action_func = globals()[action_func_name]
  181. action_func(args)
  182. if __name__ == '__main__':
  183. main()