Jeff Gaston | 5893568 | 2018-08-13 11:38:08 -0400 | [diff] [blame] | 1 | #!/usr/bin/python |
| 2 | |
| 3 | import sys, re, subprocess, os |
| 4 | |
| 5 | def usage(): |
| 6 | print("""Usage: cat <issues> | triage-guesser.py |
| 7 | triage-guesser.py attempts to guess the assignee based on the title of the bug |
| 8 | |
Jeff Gaston | 820c8be | 2019-08-19 13:45:03 -0400 | [diff] [blame] | 9 | triage-guesser reads issues from stdin (issues can be copy-pasted from the hotlist) |
Jeff Gaston | 5893568 | 2018-08-13 11:38:08 -0400 | [diff] [blame] | 10 | """) |
| 11 | sys.exit(1) |
| 12 | |
| 13 | class Issue(object): |
| 14 | def __init__(self, issueId, description): |
| 15 | self.issueId = issueId |
| 16 | self.description = description |
| 17 | |
Jeff Gaston | 0996969 | 2020-04-27 18:44:45 -0400 | [diff] [blame] | 18 | class 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 | |
| 26 | components = {} |
| 27 | components["navigation"] = IssueComponent("Navigation") |
| 28 | |
Jeff Gaston | 5893568 | 2018-08-13 11:38:08 -0400 | [diff] [blame] | 29 | class 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 | |
| 42 | class RecommenderRule(object): |
| 43 | def __init__(self): |
| 44 | return |
| 45 | |
| 46 | def recommend(self, bug): |
| 47 | return |
| 48 | |
| 49 | class ShellRunner(object): |
| 50 | def __init__(self): |
| 51 | return |
| 52 | |
| 53 | def runAndGetOutput(self, args): |
| 54 | return subprocess.check_output(args) |
| 55 | shellRunner = ShellRunner() |
| 56 | |
| 57 | class 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 | |
| 68 | class 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 Gaston | 31d864d | 2020-04-28 15:47:37 -0400 | [diff] [blame] | 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 | |
| 96 | class InterestingWordChooser(object): |
Jeff Gaston | 5893568 | 2018-08-13 11:38:08 -0400 | [diff] [blame] | 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 |
Jeff Gaston | 31d864d | 2020-04-28 15:47:37 -0400 | [diff] [blame] | 105 | interestingWordChooser = InterestingWordChooser() |
Jeff Gaston | 5893568 | 2018-08-13 11:38:08 -0400 | [diff] [blame] | 106 | |
| 107 | class GitLogger(object): |
| 108 | def __init__(self): |
| 109 | return |
| 110 | |
| 111 | def gitLog1Author(self, filePath): |
Jeff Gaston | 69d52d9 | 2019-08-19 13:54:45 -0400 | [diff] [blame] | 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", "") |
Jeff Gaston | 5893568 | 2018-08-13 11:38:08 -0400 | [diff] [blame] | 113 | return text |
| 114 | gitLogger = GitLogger() |
| 115 | |
| 116 | class 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 Gaston | 31d864d | 2020-04-28 15:47:37 -0400 | [diff] [blame] | 122 | interestingWords = interestingWordChooser.findInterestingWords(bug.description) |
Jeff Gaston | 5893568 | 2018-08-13 11:38:08 -0400 | [diff] [blame] | 123 | for word in interestingWords: |
Jeff Gaston | 31d864d | 2020-04-28 15:47:37 -0400 | [diff] [blame] | 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])) |
Jeff Gaston | 5893568 | 2018-08-13 11:38:08 -0400 | [diff] [blame] | 133 | return None |
| 134 | |
| 135 | class OwnersRule(RecommenderRule): |
| 136 | def __init__(self, fileFinder): |
| 137 | super(OwnersRule, self).__init__() |
| 138 | self.fileFinder = fileFinder |
| 139 | |
| 140 | def recommend(self, bug): |
Jeff Gaston | 31d864d | 2020-04-28 15:47:37 -0400 | [diff] [blame] | 141 | interestingWords = interestingWordChooser.findInterestingWords(bug.description) |
Jeff Gaston | 5893568 | 2018-08-13 11:38:08 -0400 | [diff] [blame] | 142 | for word in interestingWords: |
Jeff Gaston | 31d864d | 2020-04-28 15:47:37 -0400 | [diff] [blame] | 143 | filePaths = self.fileFinder.tryToIdentifyFile(word) |
| 144 | if len(filePaths) > 0: |
Jeff Gaston | 5893568 | 2018-08-13 11:38:08 -0400 | [diff] [blame] | 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 | |
| 172 | class Triager(object): |
| 173 | def __init__(self, fileFinder): |
| 174 | self.recommenderRules = self.parseKnownOwners({ |
| 175 | "fragment": ["ilake", "mount", "adamp"], |
Jeff Gaston | 4e721fa | 2020-04-28 16:30:08 -0400 | [diff] [blame^] | 176 | "animation": ["mount", "tianliu"], |
Jeff Gaston | b90161f | 2019-08-19 13:54:30 -0400 | [diff] [blame] | 177 | "transition": ["mount"], |
Jeff Gaston | 5893568 | 2018-08-13 11:38:08 -0400 | [diff] [blame] | 178 | "theme": ["alanv"], |
| 179 | "style": ["alanv"], |
| 180 | "preferences": ["pavlis", "lpf"], |
Jeff Gaston | b90161f | 2019-08-19 13:54:30 -0400 | [diff] [blame] | 181 | "ViewPager": ["jgielzak", "jellefresen"], |
| 182 | "DrawerLayout": ["sjgilbert"], |
Jeff Gaston | 6ddf803 | 2020-04-28 16:28:54 -0400 | [diff] [blame] | 183 | "RecyclerView": ["shepshapard", "ryanmentley"], |
Jeff Gaston | 5893568 | 2018-08-13 11:38:08 -0400 | [diff] [blame] | 184 | "Loaders": ["ilake"], |
| 185 | "VectorDrawableCompat": ["tianliu"], |
| 186 | "AppCompat": ["kirillg"], |
Jeff Gaston | 9a59815 | 2020-04-27 18:14:47 -0400 | [diff] [blame] | 187 | "Design Library": ["material-android-firehose"], |
| 188 | "android.support.design": ["material-android-firehose"], |
Jeff Gaston | 0996969 | 2020-04-27 18:44:45 -0400 | [diff] [blame] | 189 | "NavigationView": ["material-android-firehose"], # not to be confused with Navigation |
Jeff Gaston | 5893568 | 2018-08-13 11:38:08 -0400 | [diff] [blame] | 190 | "RenderThread": ["jreck"], |
| 191 | "VectorDrawable": ["tianliu"], |
Jeff Gaston | 7312259 | 2020-04-27 19:00:13 -0400 | [diff] [blame] | 192 | "Vector Drawable": ["tianliu"], |
Jeff Gaston | 5893568 | 2018-08-13 11:38:08 -0400 | [diff] [blame] | 193 | "drawable": ["alanv"], |
| 194 | "colorstatelist": ["alanv"], |
| 195 | "multilocale": ["nona", "mnita"], |
| 196 | "TextView": ["siyamed", "clarabayarri"], |
Jeff Gaston | c7b5010 | 2019-08-19 14:03:13 -0400 | [diff] [blame] | 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"], |
Jeff Gaston | b90161f | 2019-08-19 13:54:30 -0400 | [diff] [blame] | 205 | "slice": ["madym"], |
Jeff Gaston | 09a39e9 | 2019-08-19 14:03:46 -0400 | [diff] [blame] | 206 | "checkApi": ["jeffrygaston", "aurimas"], |
Jeff Gaston | 0f45e64 | 2020-04-27 18:07:55 -0400 | [diff] [blame] | 207 | "compose": ["chuckj", "jsproch", "lelandr"], |
Jeff Gaston | 0996969 | 2020-04-27 18:44:45 -0400 | [diff] [blame] | 208 | "jetifier": ["pavlis", "jeffrygaston"], |
Jeff Gaston | 990724f | 2020-04-28 12:36:54 -0400 | [diff] [blame] | 209 | "navigat": [components["navigation"]], # "navigation", "navigate", etc, |
| 210 | "room": ["danysantiago", "sergeyv", "yboyar"] |
Jeff Gaston | 5893568 | 2018-08-13 11:38:08 -0400 | [diff] [blame] | 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) |
Jeff Gaston | 1841ac1 | 2020-04-27 18:40:41 -0400 | [diff] [blame] | 225 | recognizedTriages = [] |
| 226 | unrecognizedTriages = [] |
Jeff Gaston | 5893568 | 2018-08-13 11:38:08 -0400 | [diff] [blame] | 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 + ")" |
Jeff Gaston | 1841ac1 | 2020-04-27 18:40:41 -0400 | [diff] [blame] | 237 | recognizedTriages.append(("(" + issue.issueId + ") " + issue.description.replace("\t", "...."), recommendationText, )) |
| 238 | else: |
| 239 | unrecognizedTriages.append(("(" + issue.issueId + ") " + issue.description.replace("\t", "...."), recommendationText, )) |
Jeff Gaston | 5893568 | 2018-08-13 11:38:08 -0400 | [diff] [blame] | 240 | maxColumnWidth = 0 |
Jeff Gaston | 1841ac1 | 2020-04-27 18:40:41 -0400 | [diff] [blame] | 241 | allTriages = recognizedTriages + unrecognizedTriages |
| 242 | for item in allTriages: |
Jeff Gaston | 5893568 | 2018-08-13 11:38:08 -0400 | [diff] [blame] | 243 | maxColumnWidth = max(maxColumnWidth, len(item[0])) |
Jeff Gaston | 1841ac1 | 2020-04-27 18:40:41 -0400 | [diff] [blame] | 244 | for item in allTriages: |
Jeff Gaston | 5893568 | 2018-08-13 11:38:08 -0400 | [diff] [blame] | 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 != ""] |
Jeff Gaston | 400a2b9 | 2020-04-27 17:43:46 -0400 | [diff] [blame] | 255 | linesPerIssue = 5 |
Jeff Gaston | 5893568 | 2018-08-13 11:38:08 -0400 | [diff] [blame] | 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") |
Jeff Gaston | 400a2b9 | 2020-04-27 17:43:46 -0400 | [diff] [blame] | 264 | expectedNumTabComponents = 3 |
Jeff Gaston | 5893568 | 2018-08-13 11:38:08 -0400 | [diff] [blame] | 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 | |
Jeff Gaston | 400a2b9 | 2020-04-27 17:43:46 -0400 | [diff] [blame] | 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] |
Jeff Gaston | 5893568 | 2018-08-13 11:38:08 -0400 | [diff] [blame] | 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 |
Jeff Gaston | 820c8be | 2019-08-19 13:45:03 -0400 | [diff] [blame] | 296 | |
| 297 | |
Jeff Gaston | 5893568 | 2018-08-13 11:38:08 -0400 | [diff] [blame] | 298 | |
| 299 | def main(args): |
| 300 | if len(args) != 1: |
| 301 | usage() |
Jeff Gaston | 5ab3227 | 2020-04-28 13:12:41 -0400 | [diff] [blame] | 302 | fileFinder = FileFinder(os.path.dirname(os.path.dirname(args[0]))) |
Jeff Gaston | 820c8be | 2019-08-19 13:45:03 -0400 | [diff] [blame] | 303 | print("Reading issues (copy-paste from the hotlist) from stdin") |
Jeff Gaston | 5893568 | 2018-08-13 11:38:08 -0400 | [diff] [blame] | 304 | lines = sys.stdin.readlines() |
| 305 | triager = Triager(fileFinder) |
| 306 | triager.process(lines) |
| 307 | |
Jeff Gaston | 820c8be | 2019-08-19 13:45:03 -0400 | [diff] [blame] | 308 | |
| 309 | |
Jeff Gaston | 5893568 | 2018-08-13 11:38:08 -0400 | [diff] [blame] | 310 | |
| 311 | main(sys.argv) |