1#!/usr/bin/env python 2 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 9triage-guesser reads issues from stdin (issues can be copy-pasted from the hotlist) 10""") 11 sys.exit(1) 12 13class Issue(object): 14 def __init__(self, issueId, description): 15 self.issueId = issueId 16 self.description = description 17 18class 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 29class 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 81 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): 97 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 105interestingWordChooser = InterestingWordChooser() 106 107class GitLogger(object): 108 def __init__(self): 109 return 110 111 def gitLog1Author(self, filePath): 112 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", "") 113 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): 122 interestingWords = interestingWordChooser.findInterestingWords(bug.description) 123 for word in interestingWords: 124 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])) 133 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): 141 interestingWords = interestingWordChooser.findInterestingWords(bug.description) 142 for word in interestingWords: 143 filePaths = self.fileFinder.tryToIdentifyFile(word) 144 if len(filePaths) > 0: 145 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"], 176 "animation": ["mount", "tianliu"], 177 "transition": ["mount"], 178 "theme": ["alanv"], 179 "style": ["alanv"], 180 "preferences": ["pavlis", "lpf"], 181 "ViewPager": ["jgielzak", "jellefresen"], 182 "DrawerLayout": ["sjgilbert"], 183 "RecyclerView": ["shepshapard", "ryanmentley"], 184 "Loaders": ["ilake"], 185 "VectorDrawableCompat": ["tianliu"], 186 "AppCompat": ["kirillg"], 187 "Design Library": ["material-android-firehose"], 188 "android.support.design": ["material-android-firehose"], 189 "NavigationView": ["material-android-firehose"], # not to be confused with Navigation 190 "RenderThread": ["jreck"], 191 "VectorDrawable": ["tianliu"], 192 "Vector Drawable": ["tianliu"], 193 "drawable": ["alanv"], 194 "colorstatelist": ["alanv"], 195 "multilocale": ["nona", "mnita"], 196 "TextView": ["siyamed", "clarabayarri"], 197 "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"], 205 "slice": ["madym"], 206 "checkApi": ["jeffrygaston", "aurimas"], 207 "compose": ["chuckj", "jsproch", "lelandr"], 208 "jetifier": ["pavlis", "jeffrygaston"], 209 "navigat": [components["navigation"]], # "navigation", "navigate", etc, 210 "room": ["danysantiago", "sergeyv", "yboyar"] 211 }) 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) 225 recognizedTriages = [] 226 unrecognizedTriages = [] 227 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 + ")" 237 recognizedTriages.append(("(" + issue.issueId + ") " + issue.description.replace("\t", "...."), recommendationText, )) 238 else: 239 unrecognizedTriages.append(("(" + issue.issueId + ") " + issue.description.replace("\t", "...."), recommendationText, )) 240 maxColumnWidth = 0 241 allTriages = recognizedTriages + unrecognizedTriages 242 for item in allTriages: 243 maxColumnWidth = max(maxColumnWidth, len(item[0])) 244 for item in allTriages: 245 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 != ""] 255 linesPerIssue = 5 256 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") 264 expectedNumTabComponents = 3 265 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 271 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] 278 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 296 297 298 299def main(args): 300 if len(args) != 1: 301 usage() 302 fileFinder = FileFinder(os.path.dirname(os.path.dirname(args[0]))) 303 print("Reading issues (copy-paste from the hotlist) from stdin") 304 lines = sys.stdin.readlines() 305 triager = Triager(fileFinder) 306 triager.process(lines) 307 308 309 310 311main(sys.argv) 312