[go: nahoru, domu]

blob: d867c1bd25be43b803c6eef5a5b9fb751eda30d1 [file] [log] [blame]
Jelle Fresen2555b412021-06-11 11:27:46 +00001#!/usr/bin/env python
Jeff Gaston58935682018-08-13 11:38:08 -04002
3import sys, re, subprocess, os
4
5def usage():
6 print("""Usage: cat <issues> | triage-guesser.py
7triage-guesser.py attempts to guess the assignee based on the title of the bug
8
Jeff Gaston820c8be2019-08-19 13:45:03 -04009triage-guesser reads issues from stdin (issues can be copy-pasted from the hotlist)
Jeff Gaston58935682018-08-13 11:38:08 -040010""")
11 sys.exit(1)
12
13class Issue(object):
14 def __init__(self, issueId, description):
15 self.issueId = issueId
16 self.description = description
17
Jeff Gaston09969692020-04-27 18:44:45 -040018class IssueComponent(object):
19 def __init__(self, name):
20 self.name = name
21 def __str__(self):
22 return "Component: '" + self.name + "'"
23 def __repr__(self):
24 return str(self)
25
26components = {}
27components["navigation"] = IssueComponent("Navigation")
28
Jeff Gaston58935682018-08-13 11:38:08 -040029class AssigneeRecommendation(object):
30 def __init__(self, usernames, justification):
31 self.usernames = usernames
32 self.justification = justification
33
34 def intersect(self, other):
35 names = []
36 for name in self.usernames:
37 if name in other.usernames:
38 names.append(name)
39 justification = self.justification + ", " + other.justification
40 return AssigneeRecommendation(names, justification)
41
42class RecommenderRule(object):
43 def __init__(self):
44 return
45
46 def recommend(self, bug):
47 return
48
49class ShellRunner(object):
50 def __init__(self):
51 return
52
53 def runAndGetOutput(self, args):
54 return subprocess.check_output(args)
55shellRunner = ShellRunner()
56
57class WordRule(RecommenderRule):
58 def __init__(self, word, assignees):
59 super(WordRule, self).__init__()
60 self.word = word
61 self.assignees = assignees
62
63 def recommend(self, bug):
64 if self.word.lower() in bug.description.lower():
65 return AssigneeRecommendation(self.assignees, '"' + self.word + '"')
66 return None
67
68class FileFinder(object):
69 def __init__(self, rootPath):
70 self.rootPath = rootPath
71 self.resultsCache = {}
72
73 def findIname(self, name):
74 if name not in self.resultsCache:
75 text = shellRunner.runAndGetOutput(["find", self.rootPath , "-type", "f", "-iname", name])
76 filePaths = [path.strip() for path in text.split("\n")]
77 filePaths = [path for path in filePaths if path != ""]
78 self.resultsCache[name] = filePaths
79 return self.resultsCache[name]
80
Jeff Gaston31d864d2020-04-28 15:47:37 -040081 def tryToIdentifyFile(self, nameComponent):
82 if len(nameComponent) < 1:
83 return []
84 queries = [nameComponent + ".*", "nameComponent*"]
85 if len(nameComponent) >= 10:
86 # For a sufficiently specific query, allow it to match the middle of a filename too
87 queries.append("*" + nameComponent + ".*")
88 for query in queries:
89 matches = self.findIname(query)
90 if len(matches) > 0 and len(matches) <= 4:
91 # We found a small enough number of matches to have
92 # reasonable confidence in having found the right file
93 return matches
94 return []
95
96class InterestingWordChooser(object):
Jeff Gaston58935682018-08-13 11:38:08 -040097 def __init__(self):
98 return
99
100 def findInterestingWords(self, text):
101 words = re.split("#| |\.", text)
102 words = [word for word in words if len(word) >= 4]
103 words.sort(key=len, reverse=True)
104 return words
Jeff Gaston31d864d2020-04-28 15:47:37 -0400105interestingWordChooser = InterestingWordChooser()
Jeff Gaston58935682018-08-13 11:38:08 -0400106
107class GitLogger(object):
108 def __init__(self):
109 return
110
111 def gitLog1Author(self, filePath):
Jeff Gaston69d52d92019-08-19 13:54:45 -0400112 text = shellRunner.runAndGetOutput(["bash", "-c", "cd " + os.path.dirname(filePath) + " && git log --no-merges -1 --format='%ae' -- " + os.path.basename(filePath)]).strip().replace("@google.com", "")
Jeff Gaston58935682018-08-13 11:38:08 -0400113 return text
114gitLogger = GitLogger()
115
116class LastTouchedBy_Rule(RecommenderRule):
117 def __init__(self, fileFinder):
118 super(LastTouchedBy_Rule, self).__init__()
119 self.fileFinder = fileFinder
120
121 def recommend(self, bug):
Jeff Gaston31d864d2020-04-28 15:47:37 -0400122 interestingWords = interestingWordChooser.findInterestingWords(bug.description)
Jeff Gaston58935682018-08-13 11:38:08 -0400123 for word in interestingWords:
Jeff Gaston31d864d2020-04-28 15:47:37 -0400124 filePaths = self.fileFinder.tryToIdentifyFile(word)
125 if len(filePaths) > 0:
126 candidateAuthors = []
127 for path in filePaths:
128 thisAuthor = gitLogger.gitLog1Author(path)
129 if len(candidateAuthors) == 0 or thisAuthor != candidateAuthors[-1]:
130 candidateAuthors.append(thisAuthor)
131 if len(candidateAuthors) == 1:
132 return AssigneeRecommendation(candidateAuthors, "last touched " + os.path.basename(filePaths[0]))
Jeff Gaston58935682018-08-13 11:38:08 -0400133 return None
134
135class OwnersRule(RecommenderRule):
136 def __init__(self, fileFinder):
137 super(OwnersRule, self).__init__()
138 self.fileFinder = fileFinder
139
140 def recommend(self, bug):
Jeff Gaston31d864d2020-04-28 15:47:37 -0400141 interestingWords = interestingWordChooser.findInterestingWords(bug.description)
Jeff Gaston58935682018-08-13 11:38:08 -0400142 for word in interestingWords:
Jeff Gaston31d864d2020-04-28 15:47:37 -0400143 filePaths = self.fileFinder.tryToIdentifyFile(word)
144 if len(filePaths) > 0:
Jeff Gaston58935682018-08-13 11:38:08 -0400145 commonPrefix = os.path.commonprefix(filePaths)
146 dirToCheck = commonPrefix
147 if len(dirToCheck) < 1:
148 continue
149 while True:
150 if dirToCheck[-1] == "/":
151 dirToCheck = dirToCheck[:-1]
152 if len(dirToCheck) <= len(self.fileFinder.rootPath):
153 break
154 ownerFilePath = os.path.join(dirToCheck, "OWNERS")
155 if os.path.isfile(ownerFilePath):
156 with open(ownerFilePath) as ownerFile:
157 lines = ownerFile.readlines()
158 names = [line.replace("@google.com", "").strip() for line in lines]
159 relOwnersPath = os.path.relpath(ownerFilePath, self.fileFinder.rootPath)
160 justification = relOwnersPath + " (" + os.path.basename(filePaths[0] + ' ("' + word + '")')
161 if len(filePaths) > 1:
162 justification += "..."
163 justification += ")"
164 return AssigneeRecommendation(names, justification)
165 else:
166 parent = os.path.dirname(dirToCheck)
167 if len(parent) >= len(dirToCheck):
168 break
169 dirToCheck = parent
170
171
172class Triager(object):
173 def __init__(self, fileFinder):
174 self.recommenderRules = self.parseKnownOwners({
175 "fragment": ["ilake", "mount", "adamp"],
Jeff Gaston4e721fa2020-04-28 16:30:08 -0400176 "animation": ["mount", "tianliu"],
Jeff Gastonb90161f2019-08-19 13:54:30 -0400177 "transition": ["mount"],
Jeff Gaston58935682018-08-13 11:38:08 -0400178 "theme": ["alanv"],
179 "style": ["alanv"],
180 "preferences": ["pavlis", "lpf"],
Jeff Gastonb90161f2019-08-19 13:54:30 -0400181 "ViewPager": ["jgielzak", "jellefresen"],
182 "DrawerLayout": ["sjgilbert"],
Jeff Gaston6ddf8032020-04-28 16:28:54 -0400183 "RecyclerView": ["shepshapard", "ryanmentley"],
Jeff Gaston58935682018-08-13 11:38:08 -0400184 "Loaders": ["ilake"],
185 "VectorDrawableCompat": ["tianliu"],
186 "AppCompat": ["kirillg"],
Jeff Gaston9a598152020-04-27 18:14:47 -0400187 "Design Library": ["material-android-firehose"],
188 "android.support.design": ["material-android-firehose"],
Jeff Gaston09969692020-04-27 18:44:45 -0400189 "NavigationView": ["material-android-firehose"], # not to be confused with Navigation
Jeff Gaston58935682018-08-13 11:38:08 -0400190 "RenderThread": ["jreck"],
191 "VectorDrawable": ["tianliu"],
Jeff Gaston73122592020-04-27 19:00:13 -0400192 "Vector Drawable": ["tianliu"],
Jeff Gaston58935682018-08-13 11:38:08 -0400193 "drawable": ["alanv"],
194 "colorstatelist": ["alanv"],
195 "multilocale": ["nona", "mnita"],
196 "TextView": ["siyamed", "clarabayarri"],
Jeff Gastonc7b50102019-08-19 14:03:13 -0400197 "text": ["android-text"],
198 "emoji": ["android-text", "siyamed"],
199 "Linkify": ["android-text", "siyamed", "toki"],
200 "Spannable": ["android-text", "siyamed"],
201 "Minikin": ["android-text", "nona"],
202 "Fonts": ["android-text", "nona", "dougfelt"],
203 "freetype": ["android-text", "nona", "junkshik"],
204 "harfbuzz": ["android-text", "nona", "junkshik"],
Jeff Gastonb90161f2019-08-19 13:54:30 -0400205 "slice": ["madym"],
Jeff Gaston09a39e92019-08-19 14:03:46 -0400206 "checkApi": ["jeffrygaston", "aurimas"],
Jeff Gaston0f45e642020-04-27 18:07:55 -0400207 "compose": ["chuckj", "jsproch", "lelandr"],
Jeff Gaston09969692020-04-27 18:44:45 -0400208 "jetifier": ["pavlis", "jeffrygaston"],
Jeff Gaston990724f2020-04-28 12:36:54 -0400209 "navigat": [components["navigation"]], # "navigation", "navigate", etc,
210 "room": ["danysantiago", "sergeyv", "yboyar"]
Jeff Gaston58935682018-08-13 11:38:08 -0400211 })
212 self.recommenderRules.append(OwnersRule(fileFinder))
213 self.recommenderRules.append(LastTouchedBy_Rule(fileFinder))
214
215 def parseKnownOwners(self, ownersDict):
216 rules = []
217 keywords = sorted(ownersDict.keys())
218 for keyword in keywords:
219 assignees = ownersDict[keyword]
220 rules.append(WordRule(keyword, assignees))
221 return rules
222
223 def process(self, lines):
224 issues = self.parseIssues(lines)
Jeff Gaston1841ac12020-04-27 18:40:41 -0400225 recognizedTriages = []
226 unrecognizedTriages = []
Jeff Gaston58935682018-08-13 11:38:08 -0400227 print("Analyzing " + str(len(issues)) + " issues")
228 for issue in issues:
229 print(".")
230 assigneeRecommendation = self.recommendAssignees(issue)
231 recommendationText = "?"
232 if assigneeRecommendation is not None:
233 usernames = assigneeRecommendation.usernames
234 if len(usernames) > 2:
235 usernames = usernames[:2]
236 recommendationText = str(usernames) + " (" + assigneeRecommendation.justification + ")"
Jeff Gaston1841ac12020-04-27 18:40:41 -0400237 recognizedTriages.append(("(" + issue.issueId + ") " + issue.description.replace("\t", "...."), recommendationText, ))
238 else:
239 unrecognizedTriages.append(("(" + issue.issueId + ") " + issue.description.replace("\t", "...."), recommendationText, ))
Jeff Gaston58935682018-08-13 11:38:08 -0400240 maxColumnWidth = 0
Jeff Gaston1841ac12020-04-27 18:40:41 -0400241 allTriages = recognizedTriages + unrecognizedTriages
242 for item in allTriages:
Jeff Gaston58935682018-08-13 11:38:08 -0400243 maxColumnWidth = max(maxColumnWidth, len(item[0]))
Jeff Gaston1841ac12020-04-27 18:40:41 -0400244 for item in allTriages:
Jeff Gaston58935682018-08-13 11:38:08 -0400245 print(str(item[0]) + (" " * (maxColumnWidth - len(item[0]))) + " -> " + str(item[1]))
246
247 def parseIssues(self, lines):
248 priority = ""
249 issueType = ""
250 description = ""
251 when = ""
252
253 lines = [line.strip() for line in lines]
254 fields = [line for line in lines if line != ""]
Jeff Gaston400a2b92020-04-27 17:43:46 -0400255 linesPerIssue = 5
Jeff Gaston58935682018-08-13 11:38:08 -0400256 if len(fields) % linesPerIssue != 0:
257 raise Exception("Parse error, number of lines must be divisible by " + str(linesPerIssue) + ", not " + str(len(fields)) + ". Last line: " + fields[-1])
258 issues = []
259 while len(fields) > 0:
260 priority = fields[0]
261 issueType = fields[1]
262
263 middle = fields[2].split("\t")
Jeff Gaston400a2b92020-04-27 17:43:46 -0400264 expectedNumTabComponents = 3
Jeff Gaston58935682018-08-13 11:38:08 -0400265 if len(middle) != expectedNumTabComponents:
266 raise Exception("Parse error: wrong number of tabs in " + str(middle) + ", got " + str(len(middle) - 1) + ", expected " + str(expectedNumTabComponents - 1))
267 description = middle[0]
268 currentAssignee = middle[1]
269 status = middle[2]
270
Jeff Gaston400a2b92020-04-27 17:43:46 -0400271 bottom = fields[4]
272 bottomSplit = bottom.split("\t")
273 expectedNumTabComponents = 2
274 if len(bottomSplit) != expectedNumTabComponents:
275 raise Exception("Parse error: wrong number of tabs in " + str(bottomSplit) + ", got " + str(len(bottomSplit)) + ", expected " + str(expectedNumTabComponents - 1))
276 issueId = bottomSplit[0]
277 when = bottomSplit[1]
Jeff Gaston58935682018-08-13 11:38:08 -0400278
279 issues.append(Issue(issueId, description))
280 fields = fields[linesPerIssue:]
281 return issues
282
283 def recommendAssignees(self, issue):
284 overallRecommendation = None
285 for rule in self.recommenderRules:
286 thisRecommendation = rule.recommend(issue)
287 if thisRecommendation is not None:
288 if overallRecommendation is None:
289 overallRecommendation = thisRecommendation
290 else:
291 newRecommendation = overallRecommendation.intersect(thisRecommendation)
292 count = len(newRecommendation.usernames)
293 if count > 0 and count < len(overallRecommendation.usernames):
294 overallRecommendation = newRecommendation
295 return overallRecommendation
Jeff Gaston820c8be2019-08-19 13:45:03 -0400296
297
Jeff Gaston58935682018-08-13 11:38:08 -0400298
299def main(args):
300 if len(args) != 1:
301 usage()
Jeff Gaston5ab32272020-04-28 13:12:41 -0400302 fileFinder = FileFinder(os.path.dirname(os.path.dirname(args[0])))
Jeff Gaston820c8be2019-08-19 13:45:03 -0400303 print("Reading issues (copy-paste from the hotlist) from stdin")
Jeff Gaston58935682018-08-13 11:38:08 -0400304 lines = sys.stdin.readlines()
305 triager = Triager(fileFinder)
306 triager.process(lines)
307
Jeff Gaston820c8be2019-08-19 13:45:03 -0400308
309
Jeff Gaston58935682018-08-13 11:38:08 -0400310
311main(sys.argv)