checkteamtags.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. #!/usr/bin/env python3
  2. # Copyright (c) 2017 The Chromium Authors. All rights reserved.
  3. # Use of this source code is governed by a BSD-style license that can be
  4. # found in the LICENSE file.
  5. """Makes sure OWNERS files have consistent TEAM and COMPONENT tags."""
  6. from __future__ import print_function
  7. import json
  8. import logging
  9. import argparse
  10. import os
  11. import re
  12. import sys
  13. import urllib.request
  14. from collections import defaultdict
  15. from owners_file_tags import parse, uniform_path_format
  16. DEFAULT_MAPPING_URL = \
  17. 'https://storage.googleapis.com/chromium-owners/component_map.json'
  18. # Represent tags that may be found in OWNERS files.
  19. _COMPONENT = 'COMPONENT'
  20. _TEAM = 'TEAM'
  21. # Represents the pattern of a line that contains a TEAM or COMPONENT tag. Note
  22. # that matching lines may not have valid formatting. For example, if {} is
  23. # replaced with TEAM, the line ' #TEAMS :some-team@chromium.org' matches the
  24. # pattern; however, the correct formatting is '# TEAM: some-team@chromium.org'.
  25. LINE_PATTERN = '.*#\s*{}.*:.*'
  26. def _get_entries(lines, tag):
  27. """Returns a line for the given tag, e.g. '# TEAM: some-team@google.com'.
  28. Also, validates the tag's prefix format.
  29. Args:
  30. lines: A list of lines from which to extract information, e.g.
  31. ['liz@google.com', '# COMPONENT: UI', '# TEAM: ui-team@chromium.org'].
  32. tag: The string tag denoting the entry for which to extract information,
  33. e.g. 'COMPONENT'.
  34. Raises:
  35. ValueError: Raised if (A) a line matches the specified line pattern, but
  36. does not match the the prefix pattern. This can happen, e.g., if a line is
  37. '#TEAM: some-team@google.com', which is missing a space between # and TEAM.
  38. Also, raised if (B) there are multiple lines matching the line pattern.
  39. This can happen if there are, e.g. two team lines.
  40. """
  41. line_pattern = re.compile(LINE_PATTERN.format(tag))
  42. entries = [line for line in lines if line_pattern.match(line)]
  43. if not entries:
  44. return None
  45. if len(entries) == 1:
  46. if re.match('^# {}: .*$'.format(tag), entries[0]):
  47. return entries[0]
  48. raise ValueError(
  49. '{} has an invalid prefix. Use \'# {}: \''.format(entries[0], tag))
  50. raise ValueError('Contains more than one {} per directory'.format(tag))
  51. def rel_and_full_paths(root, owners_path):
  52. if root:
  53. full_path = os.path.join(root, owners_path)
  54. rel_path = owners_path
  55. else:
  56. full_path = os.path.abspath(owners_path)
  57. rel_path = os.path.relpath(owners_path)
  58. return rel_path, full_path
  59. def validate_mappings(arguments, owners_files):
  60. """Ensure team/component mapping remains consistent after patch.
  61. The main purpose of this check is to notify the user if any edited (or added)
  62. team tag makes a component map to multiple teams.
  63. Args:
  64. arguments: Command line arguments from argparse
  65. owners_files: List of paths to affected OWNERS files
  66. Returns:
  67. A string containing the details of any multi-team per component.
  68. """
  69. mappings_file = json.load(
  70. urllib.request.urlopen(arguments.current_mapping_url))
  71. new_dir_to_component = mappings_file.get('dir-to-component', {})
  72. new_dir_to_team = mappings_file.get('dir-to-team', {})
  73. affected = {}
  74. deleted = []
  75. affected_components = set()
  76. # Parse affected OWNERS files
  77. for f in owners_files:
  78. rel, full = rel_and_full_paths(arguments.root, f)
  79. if os.path.exists(full):
  80. affected[os.path.dirname(rel)] = parse(full)
  81. else:
  82. deleted.append(os.path.dirname(rel))
  83. # Update component mapping with current changes.
  84. for rel_path_native, tags in affected.items():
  85. # Make the path use forward slashes always.
  86. rel_path = uniform_path_format(rel_path_native)
  87. component = tags.get('component')
  88. team = tags.get('team')
  89. os_tag = tags.get('os')
  90. if component:
  91. if os_tag:
  92. component = '%s(%s)' % (component, os_tag)
  93. new_dir_to_component[rel_path] = component
  94. affected_components.add(component)
  95. elif rel_path in new_dir_to_component:
  96. del new_dir_to_component[rel_path]
  97. if team:
  98. new_dir_to_team[rel_path] = team
  99. elif rel_path in new_dir_to_team:
  100. del new_dir_to_team[rel_path]
  101. for deleted_dir in deleted:
  102. if deleted_dir in new_dir_to_component:
  103. del new_dir_to_component[deleted_dir]
  104. if deleted_dir in new_dir_to_team:
  105. del new_dir_to_team[deleted_dir]
  106. # For the components affected by this patch, compute the directories that map
  107. # to it.
  108. affected_component_to_dirs = defaultdict(list)
  109. for d, component in new_dir_to_component.items():
  110. if component in affected_components:
  111. affected_component_to_dirs[component].append(d)
  112. # Convert component->[dirs], dir->team to component->[teams].
  113. affected_component_to_teams = {
  114. component:
  115. list({new_dir_to_team[d]
  116. for d in dirs if d in new_dir_to_team})
  117. for component, dirs in affected_component_to_dirs.items()
  118. }
  119. # Perform cardinality check.
  120. warnings = ''
  121. for component, teams in affected_component_to_teams.items():
  122. if len(teams) > 1:
  123. warnings += ('\nThe set of all OWNERS files with COMPONENT: %s list '
  124. "multiple TEAM's: %s") % (component, ', '.join(teams))
  125. if warnings:
  126. warnings = ('Are you sure these are correct? After landing this patch:%s'
  127. % warnings)
  128. return warnings
  129. def check_owners(rel_path, full_path):
  130. """Component and Team check in OWNERS files. crbug.com/667954"""
  131. def result_dict(error):
  132. return {
  133. 'error': error,
  134. 'full_path': full_path,
  135. 'rel_path': rel_path,
  136. }
  137. if not os.path.exists(full_path):
  138. return None
  139. with open(full_path) as f:
  140. owners_file_lines = f.readlines()
  141. try:
  142. component_line = _get_entries(owners_file_lines, _COMPONENT)
  143. team_line = _get_entries(owners_file_lines, _TEAM)
  144. except ValueError as error:
  145. return result_dict(str(error))
  146. if component_line:
  147. component = component_line.split(':')[1]
  148. if not component:
  149. return result_dict('Has COMPONENT line but no component name')
  150. # Check for either of the following formats:
  151. # component1, component2, ...
  152. # component1,component2,...
  153. # component1 component2 ...
  154. component_count = max(
  155. len(component.strip().split()),
  156. len(component.strip().split(',')))
  157. if component_count > 1:
  158. return result_dict('Has more than one component name')
  159. # TODO(robertocn): Check against a static list of valid components,
  160. # perhaps obtained from monorail at the beginning of presubmit.
  161. if team_line:
  162. team_entry_parts = team_line.split('@')
  163. if len(team_entry_parts) != 2:
  164. return result_dict('Has TEAM line, but not exactly 1 team email')
  165. # TODO(robertocn): Raise a warning if only one of (COMPONENT, TEAM) is
  166. # present.
  167. def main():
  168. usage = """Usage: python %prog [--root <dir>] <owners_file1> <owners_file2>...
  169. owners_fileX specifies the path to the file to check, these are expected
  170. to be relative to the root directory if --root is used.
  171. Examples:
  172. python %prog --root /home/<user>/chromium/src/ tools/OWNERS v8/OWNERS
  173. python %prog /home/<user>/chromium/src/tools/OWNERS
  174. python %prog ./OWNERS
  175. """
  176. parser = argparse.ArgumentParser(usage=usage)
  177. parser.add_argument('--root', help='Specifies the repository root.')
  178. parser.add_argument('-v',
  179. '--verbose',
  180. action='count',
  181. default=0,
  182. help='Print debug logging')
  183. parser.add_argument('--bare',
  184. action='store_true',
  185. default=False,
  186. help='Prints the bare filename triggering the checks')
  187. parser.add_argument(
  188. '--current_mapping_url',
  189. default=DEFAULT_MAPPING_URL,
  190. help='URL for existing dir/component and component/team mapping')
  191. parser.add_argument('--json', help='Path to JSON output file')
  192. args, owners_files = parser.parse_known_args()
  193. levels = [logging.ERROR, logging.INFO, logging.DEBUG]
  194. logging.basicConfig(level=levels[min(len(levels) - 1, args.verbose)])
  195. errors = list(
  196. filter(None, (check_owners(*rel_and_full_paths(args.root, f))
  197. for f in owners_files)))
  198. warnings = None
  199. if not errors:
  200. warnings = validate_mappings(args, owners_files)
  201. if args.json:
  202. with open(args.json, 'w') as f:
  203. json.dump(errors, f)
  204. if errors:
  205. if args.bare:
  206. print('\n'.join(e['full_path'] for e in errors))
  207. else:
  208. print('\nFAILED\n')
  209. print('\n'.join('%s: %s' % (e['full_path'], e['error']) for e in errors))
  210. return 1
  211. if not args.bare:
  212. if warnings:
  213. print(warnings)
  214. return 0
  215. if '__main__' == __name__:
  216. sys.exit(main())