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