OLD | NEW |
---|---|
(Empty) | |
1 # Copyright (c) 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 | |
5 """Interactive tool for finding reviewers/owners for a change.""" | |
6 | |
7 import os | |
8 import copy | |
9 import owners as owners_module | |
10 | |
11 | |
12 def first(iterable): | |
13 for element in iterable: | |
14 return element | |
15 | |
16 | |
17 class OwnersFinder(object): | |
18 COLOR_LINK = '\033[4m' | |
19 COLOR_BOLD = '\033[1;32m' | |
20 COLOR_GREY = '\033[0;37m' | |
21 COLOR_RESET = '\033[0m' | |
22 | |
23 indentation = 0 | |
24 | |
25 def __init__(self, files, local_root, | |
26 fopen, os_path, glob, | |
27 email_postfix='@chromium.org', | |
28 disable_color=False): | |
29 self.email_postfix = email_postfix | |
30 | |
31 if os.name == 'nt' or disable_color: | |
32 self.COLOR_LINK = '' | |
33 self.COLOR_BOLD = '' | |
34 self.COLOR_GREY = '' | |
35 self.COLOR_RESET = '' | |
36 | |
37 self.db = owners_module.Database(local_root, fopen, os_path, glob) | |
38 self.db.load_data_needed_for(files) | |
39 | |
40 self.os_path = os_path | |
41 | |
42 self.file_to_owners = {} | |
43 self._map_files_to_owners(files) | |
Dirk Pranke
2013/07/30 22:01:00
Can you use db.owned_by and db.owners_for here and
Bei Zhang
2013/08/12 22:43:12
They're for different purposes.
db.owned_by and d
| |
44 | |
45 self.owner_to_files = {} | |
46 self._map_owners_to_files() | |
47 | |
48 # The score of each owner. | |
49 # We calculated the score of each owner like this: | |
50 # 1. Each owner have a score of zero at the beginning; each file entry in | |
51 # the CL have a "base score" of 1.0; | |
52 # 2. For each entry in the CL: | |
53 # a. If there is an OWNER entry for that entry containing K owners, each | |
54 # owner will gain ((base score of that file) / K). The base score of | |
55 # that entry will be divided by 10.0. | |
56 # b. If step 2 reaches the local_root or EVERYONE is in the entry, exit | |
57 # step 2. | |
58 # c. Go to step a, and use the parent directory the current entry as the | |
59 # new entry. | |
Dirk Pranke
2013/07/30 22:01:00
I think there's a typo in this sentence.
Bei Zhang
2013/08/12 22:43:12
Done.
| |
60 # | |
61 # With this algorithm it is easy to find out these desirable properties: | |
62 # 1. A direct owner gains more score than an indirect owner of a file. | |
63 # 2. If a file can be reviewed by many people, each owner will gain less | |
64 # score from that file. | |
Dirk Pranke
2013/07/30 22:01:00
I still don't understand how you came up with this
Bei Zhang
2013/08/12 22:43:12
There is no reason.
I will try to reuse it.
On 20
| |
65 self.owners_score = {} | |
66 self._calculate_score() | |
67 | |
68 self.original_files_to_owners = copy.deepcopy(self.file_to_owners) | |
69 self.comments = self.db.comments | |
70 | |
71 # This is the queue that will be shown in the interactive questions. | |
72 # It is initially sorted by the score in descending order. In the | |
73 # interactive questions a user can choose to "defer" its decision, then the | |
74 # owner will be put to the end of the queue and shown later. | |
75 self.owners_queue = [] | |
76 | |
77 self.unreviewed_files = set() | |
78 self.reviewed_by = {} | |
79 self.selected_owners = set() | |
80 self.deselected_owners = set() | |
81 self.reset() | |
82 | |
83 def run(self): | |
84 self.reset() | |
85 while len(self.owners_queue) > 0 and len(self.unreviewed_files) > 0: | |
86 owner = self.owners_queue[0] | |
87 | |
88 if owner in self.selected_owners: | |
89 continue | |
90 | |
91 if len(self.unreviewed_files) == 0: | |
92 self.writeln('Finished.\n\n') | |
93 break | |
94 if owner in self.deselected_owners: | |
95 # If this owner is already deselected. | |
96 continue | |
97 if not any((file_name in self.unreviewed_files) | |
98 for file_name in self.owner_to_files[owner]): | |
99 self.deselect_owner(owner) | |
100 continue | |
101 | |
102 self.print_info(owner) | |
103 | |
104 while True: | |
105 inp = self.input_command(owner) | |
106 if inp == 'y' or inp == 'yes': | |
107 self.select_owner(owner) | |
108 break | |
109 elif inp == 'n' or inp == 'no': | |
110 self.deselect_owner(owner) | |
111 break | |
112 elif inp == '' or inp == 'd' or inp == 'defer': | |
113 self.owners_queue.append(self.owners_queue.pop(0)) | |
114 break | |
115 elif inp == 'f' or inp == 'files': | |
116 self.list_files() | |
117 break | |
118 elif inp == 'o' or inp == 'owners': | |
119 self.list_owners(self.owners_queue) | |
120 break | |
121 elif inp == 'p' or inp == 'pick': | |
122 self.pick_owner(raw_input('Pick an owner: ')) | |
123 break | |
124 elif inp.startswith('p ') or inp.startswith('pick '): | |
125 self.pick_owner(inp.split(' ', 2)[1].strip()) | |
126 break | |
127 elif inp == 'r' or inp == 'restart': | |
128 self.reset() | |
129 break | |
130 elif inp == 'q' or inp == 'quit': | |
131 # Exit with error | |
132 return 1 | |
133 | |
134 self.print_result() | |
135 return 0 | |
136 | |
137 def _owners_of(self, file_name): | |
138 """Iterate (owner, depth, entry)s for a file.""" | |
139 depth = 0 | |
140 db = self.db | |
141 if file_name in db.owners_for: | |
142 for owner in db.owners_for[file_name]: | |
143 yield owner, depth, file_name | |
144 while file_name != '': | |
145 depth += 1 | |
146 if file_name in db.stop_looking: | |
147 break | |
148 file_name = self.os_path.dirname(file_name) | |
149 if file_name in db.owners_for: | |
150 for owner in db.owners_for[file_name]: | |
151 yield owner, depth, file_name | |
152 | |
153 def _map_files_to_owners(self, files): | |
154 for file_name in files: | |
155 owners_set = set() | |
156 for owner, _, _ in self._owners_of(file_name): | |
157 owners_set.add(owner) | |
158 if owner == owners_module.EVERYONE: | |
159 break | |
160 # Eliminate files that EVERYONE can review | |
161 if owners_module.EVERYONE in owners_set: | |
162 continue | |
163 # raise exception is not owner can be found | |
164 if len(owners_set) == 0: | |
165 raise Exception('File "%s" has no owner' % file_name) | |
166 self.file_to_owners[file_name] = owners_set | |
167 | |
168 def _map_owners_to_files(self): | |
169 for file_name in self.file_to_owners: | |
170 for owner_name in self.file_to_owners[file_name]: | |
171 self.owner_to_files.setdefault(owner_name, set()) | |
172 self.owner_to_files[owner_name].add(file_name) | |
173 | |
174 def _calculate_score(self): | |
175 # Files that EVERYONE owns is already eliminated. | |
Dirk Pranke
2013/07/30 22:01:00
Nit: "are" already eliminated.
| |
176 for file_name in self.file_to_owners: | |
177 for owner, depth, entry_name in self._owners_of(file_name): | |
178 self.owners_score.setdefault(owner, {}) | |
179 self.owners_score[owner].setdefault(file_name, 0) | |
180 self.owners_score[owner][file_name] += \ | |
181 pow(0.1, depth) / len(self.db.owners_for[entry_name]) | |
Dirk Pranke
2013/07/30 22:01:00
Same comments as above re: scoring ...
| |
182 for owner in self.owners_score: | |
183 self.owners_score[owner] = sum(self.owners_score[owner].values()) | |
184 | |
185 def reset(self): | |
186 self.file_to_owners = copy.deepcopy(self.original_files_to_owners) | |
187 self.unreviewed_files = set(self.file_to_owners.keys()) | |
188 self.reviewed_by = {} | |
189 self.selected_owners = set() | |
190 self.deselected_owners = set() | |
191 | |
192 # Initialize owners queue, sort it by the score | |
193 self.owners_queue = list(sorted(self.owner_to_files.keys(), | |
194 key=lambda owner: self.owners_score[owner], | |
195 reverse=True)) | |
196 self.find_mandatory_owners() | |
197 | |
198 def select_owner(self, owner, findMandatoryOwners=True): | |
199 if owner in self.selected_owners: | |
200 return | |
201 if owner in self.deselected_owners: | |
202 return | |
203 if not (owner in self.owners_queue): | |
204 return | |
205 self.writeln('Selected: ' + owner) | |
206 self.owners_queue.remove(owner) | |
207 self.selected_owners.add(owner) | |
208 for file_name in filter( | |
209 lambda file_name: file_name in self.unreviewed_files, | |
210 self.owner_to_files[owner]): | |
211 self.unreviewed_files.remove(file_name) | |
212 self.reviewed_by[file_name] = owner | |
213 if findMandatoryOwners: | |
214 self.find_mandatory_owners() | |
215 | |
216 def deselect_owner(self, owner, findMandatoryOwners=True): | |
217 if owner in self.selected_owners: | |
218 return | |
219 if owner in self.deselected_owners: | |
220 return | |
221 if not (owner in self.owners_queue): | |
222 return | |
223 self.writeln('Deselected: ' + owner) | |
224 self.owners_queue.remove(owner) | |
225 self.deselected_owners.add(owner) | |
226 for file_name in self.owner_to_files[owner] & self.unreviewed_files: | |
227 self.file_to_owners[file_name].remove(owner) | |
228 if findMandatoryOwners: | |
229 self.find_mandatory_owners() | |
230 | |
231 def find_mandatory_owners(self): | |
232 continues = True | |
233 for owner in self.owners_queue: | |
234 if owner in self.selected_owners: | |
235 continue | |
236 if owner in self.deselected_owners: | |
237 continue | |
238 if len(self.owner_to_files[owner] & self.unreviewed_files) == 0: | |
239 self.deselect_owner(owner, False) | |
240 | |
241 while continues: | |
242 continues = False | |
243 for file_name in filter( | |
244 lambda file_name: len(self.file_to_owners[file_name]) == 1, | |
245 self.unreviewed_files): | |
246 owner = first(self.file_to_owners[file_name]) | |
247 self.select_owner(owner, False) | |
248 continues = True | |
249 break | |
250 | |
251 def print_comments(self, owner): | |
252 if owner not in self.comments: | |
253 self.writeln(self.bold_name(owner)) | |
254 else: | |
255 self.writeln(self.bold_name(owner) + ' is commented as:') | |
256 self.indent() | |
257 for path in self.comments[owner]: | |
258 if len(self.comments[owner][path]) > 0: | |
259 self.writeln(self.greyed(self.comments[owner][path]) + | |
260 ' (at ' + self.bold(path or '<root>') + ')') | |
261 else: | |
262 self.writeln(self.greyed('[No comment] ') + ' (at ' + | |
263 self.bold(path or '<root>') + ')') | |
264 self.unindent() | |
265 | |
266 def print_file_info(self, file_name, except_owner=''): | |
267 if file_name not in self.unreviewed_files: | |
268 self.writeln(self.greyed(file_name + | |
269 ' (by ' + | |
270 self.bold_name(self.reviewed_by[file_name]) + | |
271 ')')) | |
272 else: | |
273 if len(self.file_to_owners[file_name]) <= 3: | |
274 other_owners = [] | |
275 for ow in self.file_to_owners[file_name]: | |
276 if ow != except_owner: | |
277 other_owners.append(self.bold_name(ow)) | |
278 self.writeln(file_name + | |
279 ' [' + (', '.join(other_owners)) + ']') | |
280 else: | |
281 self.writeln(file_name + ' [' + | |
282 self.bold(str(len(self.file_to_owners[file_name]))) + | |
283 ']') | |
284 | |
285 def print_file_info_detailed(self, file_name): | |
286 self.writeln(file_name) | |
287 self.indent() | |
288 for ow in sorted(self.file_to_owners[file_name]): | |
289 if ow in self.deselected_owners: | |
290 self.writeln(self.bold_name(self.greyed(ow))) | |
291 elif ow in self.selected_owners: | |
292 self.writeln(self.bold_name(self.greyed(ow))) | |
293 else: | |
294 self.writeln(self.bold_name(ow)) | |
295 self.unindent() | |
296 | |
297 def print_owned_files_for(self, owner): | |
298 # Print owned files | |
299 self.print_comments(owner) | |
300 self.writeln(self.bold_name(owner) + ' owns ' + | |
301 str(len(self.owner_to_files[owner])) + ' file(s):') | |
302 self.indent() | |
303 for file_name in sorted(self.owner_to_files[owner]): | |
304 self.print_file_info(file_name, owner) | |
305 self.unindent() | |
306 self.writeln() | |
307 | |
308 def list_owners(self, owners_queue): | |
309 if (len(self.owner_to_files) - len(self.deselected_owners) - | |
310 len(self.selected_owners)) > 3: | |
311 for ow in owners_queue: | |
312 if ow not in self.deselected_owners and ow not in self.selected_owners: | |
313 self.print_comments(ow) | |
314 else: | |
315 for ow in owners_queue: | |
316 if ow not in self.deselected_owners and ow not in self.selected_owners: | |
317 self.writeln() | |
318 self.print_owned_files_for(ow) | |
319 | |
320 def list_files(self): | |
321 self.indent() | |
322 if len(self.unreviewed_files) > 5: | |
323 for file_name in sorted(self.unreviewed_files): | |
324 self.print_file_info(file_name) | |
325 else: | |
326 for file_name in self.unreviewed_files: | |
327 self.print_file_info_detailed(file_name) | |
328 self.unindent() | |
329 | |
330 def pick_owner(self, ow): | |
331 # Allowing to omit domain suffixes | |
332 if ow not in self.owner_to_files: | |
333 if ow + self.email_postfix in self.owner_to_files: | |
334 ow += self.email_postfix | |
335 | |
336 if ow not in self.owner_to_files: | |
337 self.writeln('You cannot pick ' + self.bold_name(ow) + ' manually. ' + | |
338 'It\'s an invalid name or not related to the change list.') | |
339 return False | |
340 elif ow in self.selected_owners: | |
341 self.writeln('You cannot pick ' + self.bold_name(ow) + ' manually. ' + | |
342 'It\'s already selected.') | |
343 return False | |
344 elif ow in self.deselected_owners: | |
345 self.writeln('You cannot pick ' + self.bold_name(ow) + ' manually.' + | |
346 'It\'s already unselected.') | |
347 return False | |
348 | |
349 self.select_owner(ow) | |
350 return True | |
351 | |
352 def print_result(self): | |
353 # Print results | |
354 self.writeln() | |
355 self.writeln() | |
356 self.writeln('** You selected these owners **') | |
357 self.writeln() | |
358 for owner in self.selected_owners: | |
359 self.writeln(self.bold_name(owner) + ':') | |
360 self.indent() | |
361 for file_name in sorted(self.owner_to_files[owner]): | |
362 self.writeln(file_name) | |
363 self.unindent() | |
364 | |
365 def bold(self, text): | |
366 return self.COLOR_BOLD + text + self.COLOR_RESET | |
367 | |
368 def bold_name(self, name): | |
369 return (self.COLOR_BOLD + | |
370 name.replace(self.email_postfix, '') + self.COLOR_RESET) | |
371 | |
372 def greyed(self, text): | |
373 return self.COLOR_GREY + text + self.COLOR_RESET | |
374 | |
375 def indent(self): | |
376 self.indentation += 1 | |
377 | |
378 def unindent(self): | |
379 self.indentation -= 1 | |
380 | |
381 def print_indent(self): | |
382 return ' ' * self.indentation | |
383 | |
384 def writeln(self, text=''): | |
385 print self.print_indent() + text | |
386 | |
387 def hr(self): | |
388 self.writeln('=====================') | |
389 | |
390 def print_info(self, owner): | |
391 self.hr() | |
392 self.writeln( | |
393 self.bold(str(len(self.unreviewed_files))) + ' file(s) left.') | |
394 self.print_owned_files_for(owner) | |
395 | |
396 def input_command(self, owner): | |
397 self.writeln('Add ' + self.bold_name(owner) + ' as your reviewer? ') | |
398 return raw_input( | |
399 '[yes/no/Defer/pick/files/owners/quit/restart]: ').lower() | |
OLD | NEW |