owners_finder.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. # Copyright 2013 The Chromium Authors. All rights reserved.
  2. # Use of this source code is governed by a BSD-style license that can be
  3. # found in the LICENSE file.
  4. """Interactive tool for finding reviewers/owners for a change."""
  5. import os
  6. import copy
  7. import gclient_utils
  8. def first(iterable):
  9. for element in iterable:
  10. return element
  11. class OwnersFinder(object):
  12. COLOR_LINK = '\033[4m'
  13. COLOR_BOLD = '\033[1;32m'
  14. COLOR_GREY = '\033[0;37m'
  15. COLOR_RESET = '\033[0m'
  16. indentation = 0
  17. def __init__(self,
  18. files,
  19. author,
  20. reviewers,
  21. owners_client,
  22. email_postfix='@chromium.org',
  23. disable_color=False,
  24. ignore_author=False):
  25. self.email_postfix = email_postfix
  26. if os.name == 'nt' or disable_color:
  27. self.COLOR_LINK = ''
  28. self.COLOR_BOLD = ''
  29. self.COLOR_GREY = ''
  30. self.COLOR_RESET = ''
  31. self.author = author
  32. filtered_files = files
  33. reviewers = list(reviewers)
  34. if author and not ignore_author:
  35. reviewers.append(author)
  36. # Eliminate files that existing reviewers can review.
  37. self.owners_client = owners_client
  38. approval_status = self.owners_client.GetFilesApprovalStatus(
  39. filtered_files, reviewers, [])
  40. filtered_files = [
  41. f for f in filtered_files
  42. if approval_status[f] != self.owners_client.APPROVED
  43. ]
  44. # If some files are eliminated.
  45. if len(filtered_files) != len(files):
  46. files = filtered_files
  47. self.files_to_owners = self.owners_client.BatchListOwners(files)
  48. self.owners_to_files = {}
  49. self._map_owners_to_files()
  50. self.original_files_to_owners = copy.deepcopy(self.files_to_owners)
  51. # This is the queue that will be shown in the interactive questions.
  52. # It is initially sorted by the score in descending order. In the
  53. # interactive questions a user can choose to "defer" its decision, then
  54. # the owner will be put to the end of the queue and shown later.
  55. self.owners_queue = []
  56. self.unreviewed_files = set()
  57. self.reviewed_by = {}
  58. self.selected_owners = set()
  59. self.deselected_owners = set()
  60. self.reset()
  61. def run(self):
  62. self.reset()
  63. while self.owners_queue and self.unreviewed_files:
  64. owner = self.owners_queue[0]
  65. if (owner in self.selected_owners) or (owner
  66. in self.deselected_owners):
  67. continue
  68. if not any((file_name in self.unreviewed_files)
  69. for file_name in self.owners_to_files[owner]):
  70. self.deselect_owner(owner)
  71. continue
  72. self.print_info(owner)
  73. while True:
  74. inp = self.input_command(owner)
  75. if inp in ('y', 'yes'):
  76. self.select_owner(owner)
  77. break
  78. if inp in ('n', 'no'):
  79. self.deselect_owner(owner)
  80. break
  81. if inp in ('', 'd', 'defer'):
  82. self.owners_queue.append(self.owners_queue.pop(0))
  83. break
  84. if inp in ('f', 'files'):
  85. self.list_files()
  86. break
  87. if inp in ('o', 'owners'):
  88. self.list_owners(self.owners_queue)
  89. break
  90. if inp in ('p', 'pick'):
  91. self.pick_owner(gclient_utils.AskForData('Pick an owner: '))
  92. break
  93. if inp.startswith('p ') or inp.startswith('pick '):
  94. self.pick_owner(inp.split(' ', 2)[1].strip())
  95. break
  96. if inp in ('r', 'restart'):
  97. self.reset()
  98. break
  99. if inp in ('q', 'quit'):
  100. # Exit with error
  101. return 1
  102. self.print_result()
  103. return 0
  104. def _map_owners_to_files(self):
  105. for file_name in self.files_to_owners:
  106. for owner in self.files_to_owners[file_name]:
  107. self.owners_to_files.setdefault(owner, set())
  108. self.owners_to_files[owner].add(file_name)
  109. def reset(self):
  110. self.files_to_owners = copy.deepcopy(self.original_files_to_owners)
  111. self.unreviewed_files = set(self.files_to_owners.keys())
  112. self.reviewed_by = {}
  113. self.selected_owners = set()
  114. self.deselected_owners = set()
  115. # Randomize owners' names so that if many reviewers have identical
  116. # scores they will be randomly ordered to avoid bias.
  117. owners = list(
  118. self.owners_client.ScoreOwners(self.files_to_owners.keys()))
  119. if self.author and self.author in owners:
  120. owners.remove(self.author)
  121. self.owners_queue = owners
  122. self.find_mandatory_owners()
  123. def select_owner(self, owner, findMandatoryOwners=True):
  124. if owner in self.selected_owners or owner in self.deselected_owners\
  125. or not (owner in self.owners_queue):
  126. return
  127. self.writeln('Selected: ' + owner)
  128. self.owners_queue.remove(owner)
  129. self.selected_owners.add(owner)
  130. for file_name in filter(
  131. lambda file_name: file_name in self.unreviewed_files,
  132. self.owners_to_files[owner]):
  133. self.unreviewed_files.remove(file_name)
  134. self.reviewed_by[file_name] = owner
  135. if findMandatoryOwners:
  136. self.find_mandatory_owners()
  137. def deselect_owner(self, owner, findMandatoryOwners=True):
  138. if owner in self.selected_owners or owner in self.deselected_owners\
  139. or not (owner in self.owners_queue):
  140. return
  141. self.writeln('Deselected: ' + owner)
  142. self.owners_queue.remove(owner)
  143. self.deselected_owners.add(owner)
  144. for file_name in self.owners_to_files[owner] & self.unreviewed_files:
  145. self.files_to_owners[file_name].remove(owner)
  146. if findMandatoryOwners:
  147. self.find_mandatory_owners()
  148. def find_mandatory_owners(self):
  149. continues = True
  150. for owner in self.owners_queue:
  151. if owner in self.selected_owners:
  152. continue
  153. if owner in self.deselected_owners:
  154. continue
  155. if len(self.owners_to_files[owner] & self.unreviewed_files) == 0:
  156. self.deselect_owner(owner, False)
  157. while continues:
  158. continues = False
  159. for file_name in filter(
  160. lambda file_name: len(self.files_to_owners[file_name]) == 1,
  161. self.unreviewed_files):
  162. owner = first(self.files_to_owners[file_name])
  163. self.select_owner(owner, False)
  164. continues = True
  165. break
  166. def print_file_info(self, file_name, except_owner=''):
  167. if file_name not in self.unreviewed_files:
  168. self.writeln(
  169. self.greyed(file_name + ' (by ' +
  170. self.bold_name(self.reviewed_by[file_name]) + ')'))
  171. else:
  172. if len(self.files_to_owners[file_name]) <= 3:
  173. other_owners = []
  174. for ow in self.files_to_owners[file_name]:
  175. if ow != except_owner:
  176. other_owners.append(self.bold_name(ow))
  177. self.writeln(file_name + ' [' + (', '.join(other_owners)) + ']')
  178. else:
  179. self.writeln(
  180. file_name + ' [' +
  181. self.bold(str(len(self.files_to_owners[file_name]))) + ']')
  182. def print_file_info_detailed(self, file_name):
  183. self.writeln(file_name)
  184. self.indent()
  185. for ow in sorted(self.files_to_owners[file_name]):
  186. if ow in self.deselected_owners:
  187. self.writeln(self.bold_name(self.greyed(ow)))
  188. elif ow in self.selected_owners:
  189. self.writeln(self.bold_name(self.greyed(ow)))
  190. else:
  191. self.writeln(self.bold_name(ow))
  192. self.unindent()
  193. def print_owned_files_for(self, owner):
  194. # Print owned files
  195. self.writeln(self.bold_name(owner))
  196. self.writeln(
  197. self.bold_name(owner) + ' owns ' +
  198. str(len(self.owners_to_files[owner])) + ' file(s):')
  199. self.indent()
  200. for file_name in sorted(self.owners_to_files[owner]):
  201. self.print_file_info(file_name, owner)
  202. self.unindent()
  203. self.writeln()
  204. def list_owners(self, owners_queue):
  205. if (len(self.owners_to_files) - len(self.deselected_owners) -
  206. len(self.selected_owners)) > 3:
  207. for ow in owners_queue:
  208. if (ow not in self.deselected_owners
  209. and ow not in self.selected_owners):
  210. self.writeln(self.bold_name(ow))
  211. else:
  212. for ow in owners_queue:
  213. if (ow not in self.deselected_owners
  214. and ow not in self.selected_owners):
  215. self.writeln()
  216. self.print_owned_files_for(ow)
  217. def list_files(self):
  218. self.indent()
  219. if len(self.unreviewed_files) > 5:
  220. for file_name in sorted(self.unreviewed_files):
  221. self.print_file_info(file_name)
  222. else:
  223. for file_name in self.unreviewed_files:
  224. self.print_file_info_detailed(file_name)
  225. self.unindent()
  226. def pick_owner(self, ow):
  227. # Allowing to omit domain suffixes
  228. if ow not in self.owners_to_files:
  229. if ow + self.email_postfix in self.owners_to_files:
  230. ow += self.email_postfix
  231. if ow not in self.owners_to_files:
  232. self.writeln(
  233. 'You cannot pick ' + self.bold_name(ow) + ' manually. ' +
  234. 'It\'s an invalid name or not related to the change list.')
  235. return False
  236. if ow in self.selected_owners:
  237. self.writeln('You cannot pick ' + self.bold_name(ow) +
  238. ' manually. ' + 'It\'s already selected.')
  239. return False
  240. if ow in self.deselected_owners:
  241. self.writeln('You cannot pick ' + self.bold_name(ow) +
  242. ' manually.' + 'It\'s already unselected.')
  243. return False
  244. self.select_owner(ow)
  245. return True
  246. def print_result(self):
  247. # Print results
  248. self.writeln()
  249. self.writeln()
  250. if len(self.selected_owners) == 0:
  251. self.writeln('This change list already has owner-reviewers for all '
  252. 'files.')
  253. self.writeln('Use --ignore-current if you want to ignore them.')
  254. else:
  255. self.writeln('** You selected these owners **')
  256. self.writeln()
  257. for owner in self.selected_owners:
  258. self.writeln(self.bold_name(owner) + ':')
  259. self.indent()
  260. for file_name in sorted(self.owners_to_files[owner]):
  261. self.writeln(file_name)
  262. self.unindent()
  263. def bold(self, text):
  264. return self.COLOR_BOLD + text + self.COLOR_RESET
  265. def bold_name(self, name):
  266. return (self.COLOR_BOLD + name.replace(self.email_postfix, '') +
  267. self.COLOR_RESET)
  268. def greyed(self, text):
  269. return self.COLOR_GREY + text + self.COLOR_RESET
  270. def indent(self):
  271. self.indentation += 1
  272. def unindent(self):
  273. self.indentation -= 1
  274. def print_indent(self):
  275. return ' ' * self.indentation
  276. def writeln(self, text=''):
  277. print(self.print_indent() + text)
  278. def hr(self):
  279. self.writeln('=====================')
  280. def print_info(self, owner):
  281. self.hr()
  282. self.writeln(
  283. self.bold(str(len(self.unreviewed_files))) + ' file(s) left.')
  284. self.print_owned_files_for(owner)
  285. def input_command(self, owner):
  286. self.writeln('Add ' + self.bold_name(owner) + ' as your reviewer? ')
  287. return gclient_utils.AskForData(
  288. '[yes/no/Defer/pick/files/owners/quit/restart]: ').lower()