blob: cdb42bd115f59a3780c3ca0bc51a3e34a5fe187a [file] [log] [blame]
Edward Lesmes91bb7502020-11-06 00:50:24 +00001# Copyright (c) 2020 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
Edward Lesmes295dd182020-11-24 23:07:26 +00005import itertools
Edward Lesmesd4e6fb62020-11-17 00:17:58 +00006import os
Edward Lesmes64e80762020-11-24 19:46:45 +00007import random
Edward Lesmes0e4e5ae2021-01-08 18:28:46 +00008import threading
Edward Lesmesd4e6fb62020-11-17 00:17:58 +00009
Edward Lesmes829ce022020-11-18 18:30:31 +000010import gerrit_util
Gavin Mak99399ca2020-12-11 20:56:03 +000011import git_common
Edward Lesmes64e80762020-11-24 19:46:45 +000012import owners as owners_db
Edward Lesmesd4e6fb62020-11-17 00:17:58 +000013import scm
14
15
Edward Lesmes295dd182020-11-24 23:07:26 +000016def _owner_combinations(owners, num_owners):
17 """Iterate owners combinations by decrasing score.
18
19 The score of an owner is its position on the owners list.
20 The score of a set of owners is the maximum score of all owners on the set.
21
22 Returns all combinations of up to `num_owners` sorted by decreasing score:
23 _owner_combinations(['0', '1', '2', '3'], 2) == [
24 # score 1
25 ('1', '0'),
26 # score 2
27 ('2', '0'),
28 ('2', '1'),
29 # score 3
30 ('3', '0'),
31 ('3', '1'),
32 ('3', '2'),
33 ]
34 """
35 return reversed(list(itertools.combinations(reversed(owners), num_owners)))
36
37
Edward Lesmes91bb7502020-11-06 00:50:24 +000038class OwnersClient(object):
39 """Interact with OWNERS files in a repository.
40
41 This class allows you to interact with OWNERS files in a repository both the
42 Gerrit Code-Owners plugin REST API, and the owners database implemented by
43 Depot Tools in owners.py:
44
Edward Lesmes0e4e5ae2021-01-08 18:28:46 +000045 - List all the owners for a group of files.
46 - Check if files have been approved.
47 - Suggest owners for a group of files.
Edward Lesmes91bb7502020-11-06 00:50:24 +000048
49 All code should use this class to interact with OWNERS files instead of the
50 owners database in owners.py
51 """
Edward Lesmes071c3b12021-01-15 19:02:59 +000052 # '*' means that everyone can approve.
53 EVERYONE = '*'
54
55 # Possible status of a file.
56 # - INSUFFICIENT_REVIEWERS: The path needs owners approval, but none of its
57 # owners is currently a reviewer of the change.
58 # - PENDING: An owner of this path has been added as reviewer, but approval
59 # has not been given yet.
60 # - APPROVED: The path has been approved by an owner.
Edward Lesmesc40b2402021-01-12 20:03:11 +000061 APPROVED = 'APPROVED'
62 PENDING = 'PENDING'
63 INSUFFICIENT_REVIEWERS = 'INSUFFICIENT_REVIEWERS'
64
Edward Lesmes0e4e5ae2021-01-08 18:28:46 +000065 def ListOwners(self, path):
Edward Lesmes64e80762020-11-24 19:46:45 +000066 """List all owners for a file.
67
68 The returned list is sorted so that better owners appear first.
69 """
Edward Lesmes91bb7502020-11-06 00:50:24 +000070 raise Exception('Not implemented')
71
Edward Lesmes0e4e5ae2021-01-08 18:28:46 +000072 def BatchListOwners(self, paths):
73 """List all owners for a group of files.
74
75 Returns a dictionary {path: [owners]}.
76 """
Gavin Mak99399ca2020-12-11 20:56:03 +000077 with git_common.ScopedPool(kind='threads') as pool:
78 return dict(pool.imap_unordered(
Edward Lesmes0e4e5ae2021-01-08 18:28:46 +000079 lambda p: (p, self.ListOwners(p)), paths))
Gavin Mak99399ca2020-12-11 20:56:03 +000080
Edward Lesmes0e4e5ae2021-01-08 18:28:46 +000081 def GetFilesApprovalStatus(self, paths, approvers, reviewers):
Edward Lesmese7d18622020-11-19 23:46:17 +000082 """Check the approval status for the given paths.
83
84 Utility method to check for approval status when a change has not yet been
85 created, given reviewers and approvers.
86
87 See GetChangeApprovalStatus for description of the returned value.
88 """
89 approvers = set(approvers)
Edward Lesmes071c3b12021-01-15 19:02:59 +000090 if approvers:
91 approvers.add(self.EVERYONE)
Edward Lesmese7d18622020-11-19 23:46:17 +000092 reviewers = set(reviewers)
Edward Lesmes071c3b12021-01-15 19:02:59 +000093 if reviewers:
94 reviewers.add(self.EVERYONE)
Edward Lesmese7d18622020-11-19 23:46:17 +000095 status = {}
Edward Lesmes0e4e5ae2021-01-08 18:28:46 +000096 owners_by_path = self.BatchListOwners(paths)
97 for path, owners in owners_by_path.items():
98 owners = set(owners)
99 if owners.intersection(approvers):
Edward Lesmesc40b2402021-01-12 20:03:11 +0000100 status[path] = self.APPROVED
Edward Lesmes0e4e5ae2021-01-08 18:28:46 +0000101 elif owners.intersection(reviewers):
Edward Lesmesc40b2402021-01-12 20:03:11 +0000102 status[path] = self.PENDING
Edward Lesmese7d18622020-11-19 23:46:17 +0000103 else:
Edward Lesmesc40b2402021-01-12 20:03:11 +0000104 status[path] = self.INSUFFICIENT_REVIEWERS
Edward Lesmese7d18622020-11-19 23:46:17 +0000105 return status
106
Edward Lesmes0e2aee72021-02-03 20:12:46 +0000107 def ScoreOwners(self, paths, exclude=None):
Gavin Makd36dbbd2021-01-25 19:34:58 +0000108 """Get sorted list of owners for the given paths."""
Edward Lesmes0e2aee72021-02-03 20:12:46 +0000109 exclude = exclude or []
Gavin Makd36dbbd2021-01-25 19:34:58 +0000110 positions_by_owner = {}
111 owners_by_path = self.BatchListOwners(paths)
112 for owners in owners_by_path.values():
113 for i, owner in enumerate(owners):
Edward Lesmes0e2aee72021-02-03 20:12:46 +0000114 if owner in exclude:
115 continue
Gavin Makd36dbbd2021-01-25 19:34:58 +0000116 # Gerrit API lists owners of a path sorted by an internal score, so
117 # owners that appear first should be prefered.
118 # We define the score of an owner based on the pair
119 # (# of files owned, minimum position on all owned files)
120 positions_by_owner.setdefault(owner, []).append(i)
121
122 # Sort owners by their score. Rank owners higher for more files owned and
123 # lower for a larger minimum position across all owned files. Randomize
124 # order for owners with same score to avoid bias.
125 return sorted(
126 positions_by_owner,
127 key=lambda o: (-len(positions_by_owner[o]),
128 min(positions_by_owner[o]) + random.random()))
129
Edward Lesmes0e2aee72021-02-03 20:12:46 +0000130 def SuggestOwners(self, paths, exclude=None):
Edward Lesmes295dd182020-11-24 23:07:26 +0000131 """Suggest a set of owners for the given paths."""
Edward Lesmes0e2aee72021-02-03 20:12:46 +0000132 exclude = exclude or []
Edward Lesmes295dd182020-11-24 23:07:26 +0000133 paths_by_owner = {}
Edward Lesmes0e4e5ae2021-01-08 18:28:46 +0000134 owners_by_path = self.BatchListOwners(paths)
135 for path, owners in owners_by_path.items():
Gavin Makd36dbbd2021-01-25 19:34:58 +0000136 for owner in owners:
Edward Lesmes0e2aee72021-02-03 20:12:46 +0000137 if owner not in exclude:
138 paths_by_owner.setdefault(owner, set()).add(path)
Edward Lesmes295dd182020-11-24 23:07:26 +0000139
140 # Select the minimum number of owners that can approve all paths.
Gavin Makd36dbbd2021-01-25 19:34:58 +0000141 # We start at 2 to avoid sending all changes that require multiple
142 # reviewers to top-level owners.
Edward Lesmes0e2aee72021-02-03 20:12:46 +0000143 owners = self.ScoreOwners(paths, exclude=exclude)
Edward Lesmesca45aff2020-12-03 23:11:01 +0000144 if len(owners) < 2:
145 return owners
146
Edward Lesmes0e2aee72021-02-03 20:12:46 +0000147 # Note that we have to iterate up to len(owners) + 1.
148 # e.g. if there are only 2 owners, we should consider num_owners = 2.
149 for num_owners in range(2, len(owners) + 1):
Gavin Makd36dbbd2021-01-25 19:34:58 +0000150 # Iterate all combinations of `num_owners` by decreasing score, and
151 # select the first one that covers all paths.
Edward Lesmes295dd182020-11-24 23:07:26 +0000152 for selected in _owner_combinations(owners, num_owners):
153 covered = set.union(*(paths_by_owner[o] for o in selected))
154 if len(covered) == len(paths):
Edward Lesmes0e4e5ae2021-01-08 18:28:46 +0000155 return list(selected)
Edward Lesmes295dd182020-11-24 23:07:26 +0000156
Edward Lesmes0e2aee72021-02-03 20:12:46 +0000157 return []
158
Edward Lesmesb4f42262020-11-10 23:41:35 +0000159
160class DepotToolsClient(OwnersClient):
161 """Implement OwnersClient using owners.py Database."""
Edward Lesmes0e4e5ae2021-01-08 18:28:46 +0000162 def __init__(self, root, branch, fopen=open, os_path=os.path):
163 super(DepotToolsClient, self).__init__()
164
Edward Lesmesb4f42262020-11-10 23:41:35 +0000165 self._root = root
Edward Lesmes0e4e5ae2021-01-08 18:28:46 +0000166 self._branch = branch
Edward Lesmesb4721682020-11-19 22:59:57 +0000167 self._fopen = fopen
168 self._os_path = os_path
Edward Lesmes82b992a2021-01-11 23:24:55 +0000169 self._db = None
Edward Lesmes0e4e5ae2021-01-08 18:28:46 +0000170 self._db_lock = threading.Lock()
Edward Lesmes829ce022020-11-18 18:30:31 +0000171
Edward Lesmes82b992a2021-01-11 23:24:55 +0000172 def _ensure_db(self):
173 if self._db is not None:
174 return
175 self._db = owners_db.Database(self._root, self._fopen, self._os_path)
176 self._db.override_files = self._GetOriginalOwnersFiles()
177
Edward Lesmes829ce022020-11-18 18:30:31 +0000178 def _GetOriginalOwnersFiles(self):
179 return {
Edward Lesmes8a791e72020-12-02 18:33:18 +0000180 f: scm.GIT.GetOldContents(self._root, f, self._branch).splitlines()
Edward Lesmesd4e6fb62020-11-17 00:17:58 +0000181 for _, f in scm.GIT.CaptureStatus(self._root, self._branch)
182 if os.path.basename(f) == 'OWNERS'
Edward Lesmes829ce022020-11-18 18:30:31 +0000183 }
Edward Lesmesb4f42262020-11-10 23:41:35 +0000184
Edward Lesmes0e4e5ae2021-01-08 18:28:46 +0000185 def ListOwners(self, path):
186 # all_possible_owners is not thread safe.
187 with self._db_lock:
Edward Lesmes82b992a2021-01-11 23:24:55 +0000188 self._ensure_db()
Edward Lesmes0e4e5ae2021-01-08 18:28:46 +0000189 # all_possible_owners returns a dict {owner: [(path, distance)]}. We want
190 # to return a list of owners sorted by increasing distance.
191 distance_by_owner = self._db.all_possible_owners([path], None)
Gavin Makd36dbbd2021-01-25 19:34:58 +0000192 # We add a small random number to the distance, so that owners at the
193 # same distance are returned in random order to avoid overloading those
194 # who would appear first.
Edward Lesmes0e4e5ae2021-01-08 18:28:46 +0000195 return sorted(
196 distance_by_owner,
197 key=lambda o: distance_by_owner[o][0][1] + random.random())
Gavin Makc94b21d2020-12-10 20:27:32 +0000198
199
200class GerritClient(OwnersClient):
201 """Implement OwnersClient using OWNERS REST API."""
Edward Lesmes0e4e5ae2021-01-08 18:28:46 +0000202 def __init__(self, host, project, branch):
203 super(GerritClient, self).__init__()
Gavin Makc94b21d2020-12-10 20:27:32 +0000204
Edward Lesmes0e4e5ae2021-01-08 18:28:46 +0000205 self._host = host
206 self._project = project
207 self._branch = branch
Edward Lesmes0d1bdb22021-02-16 21:27:04 +0000208 self._owners_cache = {}
Edward Lesmes0e4e5ae2021-01-08 18:28:46 +0000209
210 def ListOwners(self, path):
Edward Lesmes5e37f6d2021-02-17 23:32:16 +0000211 # Always use slashes as separators.
212 path = path.replace(os.sep, '/')
Edward Lesmes0d1bdb22021-02-16 21:27:04 +0000213 if path not in self._owners_cache:
214 # GetOwnersForFile returns a list of account details sorted by order of
215 # best reviewer for path. If owners have the same score, the order is
216 # random.
217 data = gerrit_util.GetOwnersForFile(
Gavin Mak7d690052021-02-25 19:14:22 +0000218 self._host, self._project, self._branch, path,
219 resolve_all_users=False)
Edward Lesmes0d1bdb22021-02-16 21:27:04 +0000220 self._owners_cache[path] = [
221 d['account']['email']
222 for d in data['code_owners']
Gavin Mak7d690052021-02-25 19:14:22 +0000223 if 'account' in d and 'email' in d['account']
Edward Lesmes0d1bdb22021-02-16 21:27:04 +0000224 ]
Gavin Mak7d690052021-02-25 19:14:22 +0000225 # If owned_by_all_users is true, add everyone as an owner at the end of
226 # the owners list.
227 if data.get('owned_by_all_users', False):
228 self._owners_cache[path].append(self.EVERYONE)
Edward Lesmes0d1bdb22021-02-16 21:27:04 +0000229 return self._owners_cache[path]
Edward Lesmes110823b2021-02-05 21:42:27 +0000230
231
232def GetCodeOwnersClient(root, host, project, branch):
233 """Get a new OwnersClient.
234
235 Defaults to GerritClient, and falls back to DepotToolsClient if code-owners
236 plugin is not available."""
Stephen Martinisfb09de22021-02-25 03:41:13 +0000237 if gerrit_util.IsCodeOwnersEnabled(host):
Edward Lesmes110823b2021-02-05 21:42:27 +0000238 return GerritClient(host, project, branch)
239 return DepotToolsClient(root, branch)