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():
6print("""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""")
11sys.exit(1)
12
13class Issue(object):
14def __init__(self, issueId, description):
15self.issueId = issueId
16self.description = description
17
Jeff Gaston09969692020-04-27 18:44:45 -040018class IssueComponent(object):
19def __init__(self, name):
20self.name = name
21def __str__(self):
22return "Component: '" + self.name + "'"
23def __repr__(self):
24return str(self)
25
26components = {}
27components["navigation"] = IssueComponent("Navigation")
28
Jeff Gaston58935682018-08-13 11:38:08 -040029class AssigneeRecommendation(object):
30def __init__(self, usernames, justification):
31self.usernames = usernames
32self.justification = justification
33
34def intersect(self, other):
35names = []
36for name in self.usernames:
37if name in other.usernames:
38names.append(name)
39justification = self.justification + ", " + other.justification
40return AssigneeRecommendation(names, justification)
41
42class RecommenderRule(object):
43def __init__(self):
44return
45
46def recommend(self, bug):
47return
48
49class ShellRunner(object):
50def __init__(self):
51return
52
53def runAndGetOutput(self, args):
54return subprocess.check_output(args)
55shellRunner = ShellRunner()
56
57class WordRule(RecommenderRule):
58def __init__(self, word, assignees):
59super(WordRule, self).__init__()
60self.word = word
61self.assignees = assignees
62
63def recommend(self, bug):
64if self.word.lower() in bug.description.lower():
65return AssigneeRecommendation(self.assignees, '"' + self.word + '"')
66return None
67
68class FileFinder(object):
69def __init__(self, rootPath):
70self.rootPath = rootPath
71self.resultsCache = {}
72
73def findIname(self, name):
74if name not in self.resultsCache:
75text = shellRunner.runAndGetOutput(["find", self.rootPath , "-type", "f", "-iname", name])
76filePaths = [path.strip() for path in text.split("\n")]
77filePaths = [path for path in filePaths if path != ""]
78self.resultsCache[name] = filePaths
79return self.resultsCache[name]
80
Jeff Gaston31d864d2020-04-28 15:47:37 -040081def tryToIdentifyFile(self, nameComponent):
82if len(nameComponent) < 1:
83return []
84queries = [nameComponent + ".*", "nameComponent*"]
85if len(nameComponent) >= 10:
86# For a sufficiently specific query, allow it to match the middle of a filename too
87queries.append("*" + nameComponent + ".*")
88for query in queries:
89matches = self.findIname(query)
90if 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
93return matches
94return []
95
96class InterestingWordChooser(object):
Jeff Gaston58935682018-08-13 11:38:08 -040097def __init__(self):
98return
99
100def findInterestingWords(self, text):
101words = re.split("#| |\.", text)
102words = [word for word in words if len(word) >= 4]
103words.sort(key=len, reverse=True)
104return words
Jeff Gaston31d864d2020-04-28 15:47:37 -0400105interestingWordChooser = InterestingWordChooser()
Jeff Gaston58935682018-08-13 11:38:08 -0400106
107class GitLogger(object):
108def __init__(self):
109return
110
111def gitLog1Author(self, filePath):
Jeff Gaston69d52d92019-08-19 13:54:45 -0400112text = 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 -0400113return text
114gitLogger = GitLogger()
115
116class LastTouchedBy_Rule(RecommenderRule):
117def __init__(self, fileFinder):
118super(LastTouchedBy_Rule, self).__init__()
119self.fileFinder = fileFinder
120
121def recommend(self, bug):
Jeff Gaston31d864d2020-04-28 15:47:37 -0400122interestingWords = interestingWordChooser.findInterestingWords(bug.description)
Jeff Gaston58935682018-08-13 11:38:08 -0400123for word in interestingWords:
Jeff Gaston31d864d2020-04-28 15:47:37 -0400124filePaths = self.fileFinder.tryToIdentifyFile(word)
125if len(filePaths) > 0:
126candidateAuthors = []
127for path in filePaths:
128thisAuthor = gitLogger.gitLog1Author(path)
129if len(candidateAuthors) == 0 or thisAuthor != candidateAuthors[-1]:
130candidateAuthors.append(thisAuthor)
131if len(candidateAuthors) == 1:
132return AssigneeRecommendation(candidateAuthors, "last touched " + os.path.basename(filePaths[0]))
Jeff Gaston58935682018-08-13 11:38:08 -0400133return None
134
135class OwnersRule(RecommenderRule):
136def __init__(self, fileFinder):
137super(OwnersRule, self).__init__()
138self.fileFinder = fileFinder
139
140def recommend(self, bug):
Jeff Gaston31d864d2020-04-28 15:47:37 -0400141interestingWords = interestingWordChooser.findInterestingWords(bug.description)
Jeff Gaston58935682018-08-13 11:38:08 -0400142for word in interestingWords:
Jeff Gaston31d864d2020-04-28 15:47:37 -0400143filePaths = self.fileFinder.tryToIdentifyFile(word)
144if len(filePaths) > 0:
Jeff Gaston58935682018-08-13 11:38:08 -0400145commonPrefix = os.path.commonprefix(filePaths)
146dirToCheck = commonPrefix
147if len(dirToCheck) < 1:
148continue
149while True:
150if dirToCheck[-1] == "/":
151dirToCheck = dirToCheck[:-1]
152if len(dirToCheck) <= len(self.fileFinder.rootPath):
153break
154ownerFilePath = os.path.join(dirToCheck, "OWNERS")
155if os.path.isfile(ownerFilePath):
156with open(ownerFilePath) as ownerFile:
157lines = ownerFile.readlines()
158names = [line.replace("@google.com", "").strip() for line in lines]
159relOwnersPath = os.path.relpath(ownerFilePath, self.fileFinder.rootPath)
160justification = relOwnersPath + " (" + os.path.basename(filePaths[0] + ' ("' + word + '")')
161if len(filePaths) > 1:
162justification += "..."
163justification += ")"
164return AssigneeRecommendation(names, justification)
165else:
166parent = os.path.dirname(dirToCheck)
167if len(parent) >= len(dirToCheck):
168break
169dirToCheck = parent
170
171
172class Triager(object):
173def __init__(self, fileFinder):
174self.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})
212self.recommenderRules.append(OwnersRule(fileFinder))
213self.recommenderRules.append(LastTouchedBy_Rule(fileFinder))
214
215def parseKnownOwners(self, ownersDict):
216rules = []
217keywords = sorted(ownersDict.keys())
218for keyword in keywords:
219assignees = ownersDict[keyword]
220rules.append(WordRule(keyword, assignees))
221return rules
222
223def process(self, lines):
224issues = self.parseIssues(lines)
Jeff Gaston1841ac12020-04-27 18:40:41 -0400225recognizedTriages = []
226unrecognizedTriages = []
Jeff Gaston58935682018-08-13 11:38:08 -0400227print("Analyzing " + str(len(issues)) + " issues")
228for issue in issues:
229print(".")
230assigneeRecommendation = self.recommendAssignees(issue)
231recommendationText = "?"
232if assigneeRecommendation is not None:
233usernames = assigneeRecommendation.usernames
234if len(usernames) > 2:
235usernames = usernames[:2]
236recommendationText = str(usernames) + " (" + assigneeRecommendation.justification + ")"
Jeff Gaston1841ac12020-04-27 18:40:41 -0400237recognizedTriages.append(("(" + issue.issueId + ") " + issue.description.replace("\t", "...."), recommendationText, ))
238else:
239unrecognizedTriages.append(("(" + issue.issueId + ") " + issue.description.replace("\t", "...."), recommendationText, ))
Jeff Gaston58935682018-08-13 11:38:08 -0400240maxColumnWidth = 0
Jeff Gaston1841ac12020-04-27 18:40:41 -0400241allTriages = recognizedTriages + unrecognizedTriages
242for item in allTriages:
Jeff Gaston58935682018-08-13 11:38:08 -0400243maxColumnWidth = max(maxColumnWidth, len(item[0]))
Jeff Gaston1841ac12020-04-27 18:40:41 -0400244for item in allTriages:
Jeff Gaston58935682018-08-13 11:38:08 -0400245print(str(item[0]) + (" " * (maxColumnWidth - len(item[0]))) + " -> " + str(item[1]))
246
247def parseIssues(self, lines):
248priority = ""
249issueType = ""
250description = ""
251when = ""
252
253lines = [line.strip() for line in lines]
254fields = [line for line in lines if line != ""]
Jeff Gaston400a2b92020-04-27 17:43:46 -0400255linesPerIssue = 5
Jeff Gaston58935682018-08-13 11:38:08 -0400256if len(fields) % linesPerIssue != 0:
257raise Exception("Parse error, number of lines must be divisible by " + str(linesPerIssue) + ", not " + str(len(fields)) + ". Last line: " + fields[-1])
258issues = []
259while len(fields) > 0:
260priority = fields[0]
261issueType = fields[1]
262
263middle = fields[2].split("\t")
Jeff Gaston400a2b92020-04-27 17:43:46 -0400264expectedNumTabComponents = 3
Jeff Gaston58935682018-08-13 11:38:08 -0400265if len(middle) != expectedNumTabComponents:
266raise Exception("Parse error: wrong number of tabs in " + str(middle) + ", got " + str(len(middle) - 1) + ", expected " + str(expectedNumTabComponents - 1))
267description = middle[0]
268currentAssignee = middle[1]
269status = middle[2]
270
Jeff Gaston400a2b92020-04-27 17:43:46 -0400271bottom = fields[4]
272bottomSplit = bottom.split("\t")
273expectedNumTabComponents = 2
274if len(bottomSplit) != expectedNumTabComponents:
275raise Exception("Parse error: wrong number of tabs in " + str(bottomSplit) + ", got " + str(len(bottomSplit)) + ", expected " + str(expectedNumTabComponents - 1))
276issueId = bottomSplit[0]
277when = bottomSplit[1]
Jeff Gaston58935682018-08-13 11:38:08 -0400278
279issues.append(Issue(issueId, description))
280fields = fields[linesPerIssue:]
281return issues
282
283def recommendAssignees(self, issue):
284overallRecommendation = None
285for rule in self.recommenderRules:
286thisRecommendation = rule.recommend(issue)
287if thisRecommendation is not None:
288if overallRecommendation is None:
289overallRecommendation = thisRecommendation
290else:
291newRecommendation = overallRecommendation.intersect(thisRecommendation)
292count = len(newRecommendation.usernames)
293if count > 0 and count < len(overallRecommendation.usernames):
294overallRecommendation = newRecommendation
295return overallRecommendation
Jeff Gaston820c8be2019-08-19 13:45:03 -0400296
297
Jeff Gaston58935682018-08-13 11:38:08 -0400298
299def main(args):
300if len(args) != 1:
301usage()
Jeff Gaston5ab32272020-04-28 13:12:41 -0400302fileFinder = FileFinder(os.path.dirname(os.path.dirname(args[0])))
Jeff Gaston820c8be2019-08-19 13:45:03 -0400303print("Reading issues (copy-paste from the hotlist) from stdin")
Jeff Gaston58935682018-08-13 11:38:08 -0400304lines = sys.stdin.readlines()
305triager = Triager(fileFinder)
306triager.process(lines)
307
Jeff Gaston820c8be2019-08-19 13:45:03 -0400308
309
Jeff Gaston58935682018-08-13 11:38:08 -0400310
311main(sys.argv)