Added CSV dumps: per filetype and per changeset
[mining-tools:gitdm.git] / gitdm
1 #!/usr/bin/python
2 #-*- coding:utf-8 -*-
3
4 #
5 # This code is part of the LWN git data miner.
6 #
7 # Copyright 2007-9 LWN.net
8 # Copyright 2007-9 Jonathan Corbet <corbet@lwn.net>
9 # Copyright 2009 Germán Póo-Caamaño <gpoo@gnome.org>
10 #
11 # This file may be distributed under the terms of the GNU General
12 # Public License, version 2.
13
14
15 import database, csvdump, ConfigFile, reports
16 import getopt, datetime
17 import os, re, sys, rfc822, string
18 import file_types
19 import logparser
20 from patterns import patterns
21
22 Today = datetime.date.today()
23
24 #
25 # Remember author names we have griped about.
26 #
27 GripedAuthorNames = [ ]
28
29 #
30 # Control options.
31 #
32 MapUnknown = 0
33 DevReports = 1
34 DateStats = 0
35 AuthorSOBs = 1
36 FileFilter = None
37 CSVFile = None
38 CSVPrefix = None
39 AkpmOverLt = 0
40 DumpDB = 0
41 CFName = 'gitdm.config'
42 DirName = ''
43
44 #
45 # Options:
46 #
47 # -a            Andrew Morton's signoffs shadow Linus's
48 # -b dir        Specify the base directory to fetch the configuration files
49 # -c cfile      Specify a configuration file
50 # -d            Output individual developer stats
51 # -D            Output date statistics
52 # -h hfile      HTML output to hfile
53 # -l count      Maximum length for output lists
54 # -o file       File for text output
55 # -p prefix Prefix for CSV output
56 # -r pattern    Restrict to files matching pattern
57 # -s            Ignore author SOB lines
58 # -u            Map unknown employers to '(Unknown)'
59 # -x file.csv   Export raw statistics as CSV
60 # -z            Dump out the hacker database at completion
61
62 def ParseOpts ():
63     global MapUnknown, DevReports
64     global DateStats, AuthorSOBs, FileFilter, AkpmOverLt, DumpDB
65     global CFName, CSVFile, CSVPrefix, DirName
66
67     opts, rest = getopt.getopt (sys.argv[1:], 'ab:dc:Dh:l:o:p:r:sux:z')
68
69     for opt in opts:
70         if opt[0] == '-a':
71             AkpmOverLt = 1
72         elif opt[0] == '-b':
73             DirName = opt[1]
74         elif opt[0] == '-c':
75             CFName = opt[1]
76         elif opt[0] == '-d':
77             DevReports = 0
78         elif opt[0] == '-D':
79             DateStats = 1
80         elif opt[0] == '-h':
81             reports.SetHTMLOutput (open (opt[1], 'w'))
82         elif opt[0] == '-l':
83             reports.SetMaxList (int (opt[1]))
84         elif opt[0] == '-o':
85             reports.SetOutput (open (opt[1], 'w'))
86         elif opt[0] == '-p':
87             CSVPrefix = opt[1]
88         elif opt[0] == '-r':
89             print 'Filter on "%s"' % (opt[1])
90             FileFilter = re.compile (opt[1])
91         elif opt[0] == '-s':
92             AuthorSOBs = 0
93         elif opt[0] == '-u':
94             MapUnknown = 1
95         elif opt[0] == '-x':
96             CSVFile = open (opt[1], 'w')
97             print "open output file " + opt[1] + "\n"
98         elif opt[0] == '-z':
99             DumpDB = 1
100         
101
102
103 def LookupStoreHacker (name, email):
104     email = database.RemapEmail (email)
105     h = database.LookupEmail (email)
106     if h: # already there
107         return h
108     elist = database.LookupEmployer (email, MapUnknown)
109     h = database.LookupName (name)
110     if h: # new email
111         h.addemail (email, elist)
112         return h
113     return database.StoreHacker(name, elist, email)
114
115 #
116 # Date tracking.
117 #
118
119 DateMap = { }
120
121 def AddDateLines(date, lines):
122     if lines > 1000000:
123         print 'Skip big patch (%d)' % lines
124         return
125     try:
126         DateMap[date] += lines
127     except KeyError:
128         DateMap[date] = lines
129
130 def PrintDateStats():
131     dates = DateMap.keys ()
132     dates.sort ()
133     total = 0
134     datef = open ('datelc', 'w')
135     for date in dates:
136         total += DateMap[date]
137         datef.write ('%d/%02d/%02d %6d %7d\n' % (date.year, date.month, date.day,
138                                     DateMap[date], total))
139
140
141 #
142 # Let's slowly try to move some smarts into this class.
143 #
144 class patch:
145     (ADDED, REMOVED) = range (2)
146
147     def __init__ (self, commit):
148         self.commit = commit
149         self.merge = self.added = self.removed = 0
150         self.author = LookupStoreHacker('Unknown hacker', 'unknown@hacker.net')
151         self.email = 'unknown@hacker.net'
152         self.sobs = [ ]
153         self.reviews = [ ]
154         self.testers = [ ]
155         self.reports = [ ]
156         self.filetypes = {}
157
158     def addreviewer (self, reviewer):
159         self.reviews.append (reviewer)
160
161     def addtester (self, tester):
162         self.testers.append (tester)
163
164     def addreporter (self, reporter):
165         self.reports.append (reporter)
166
167     def addfiletype (self, filetype, added, removed):
168         if self.filetypes.has_key (filetype):
169             self.filetypes[filetype][self.ADDED] += added
170             self.filetypes[filetype][self.REMOVED] += removed
171         else:
172             self.filetypes[filetype] = [added, removed]
173
174 def parse_numstat(line, file_filter):
175     """
176         Receive a line of text, determine if fits a numstat line and
177         parse the added and removed lines as well as the file type.
178     """
179     m = patterns['numstat'].match (line)
180     if m:
181         filename = m.group (3)
182         # If we have a file filter, check for file lines.
183         if file_filter and not file_filter.search (filename):
184             return None, None, None, None
185
186         try:
187             added = int (m.group (1))
188             removed = int (m.group (2))
189         except ValueError:
190             # A binary file (image, etc.) is marked with '-'
191             added = removed = 0
192
193         m = patterns['rename'].match (filename)
194         if m:
195             filename = '%s%s%s' % (m.group (1), m.group (3), m.group (4))
196
197         filetype = file_types.guess_file_type (os.path.basename(filename))
198         return filename, filetype, added, removed
199     else:
200         return None, None, None, None
201
202 #
203 # The core hack for grabbing the information about a changeset.
204 #
205 def grabpatch(logpatch):
206     m = patterns['commit'].match (logpatch[0])
207     if not m:
208         return None
209
210     p = patch(m.group (1))
211
212     for Line in logpatch[1:]:
213         #
214         # Maybe it's an author line?
215         #
216         m = patterns['author'].match (Line)
217         if m:
218             p.email = database.RemapEmail (m.group (2))
219             p.author = LookupStoreHacker(m.group (1), p.email)
220             continue
221         #
222         # Could be a signed-off-by:
223         #
224         m = patterns['signed-off-by'].search (Line)
225         if m:
226             email = database.RemapEmail (m.group (2))
227             sobber = LookupStoreHacker(m.group (1), email)
228             if sobber != p.author or AuthorSOBs:
229                 p.sobs.append ((email, LookupStoreHacker(m.group (1), m.group (2))))
230             continue
231         #
232         # Various other tags of interest.
233         #
234         # Reviewed-by:
235         m = patterns['reviewed-by'].search (Line)
236         if m:
237             email = database.RemapEmail (m.group (2))
238             p.addreviewer (LookupStoreHacker(m.group (1), email))
239             continue
240         # Tested-by:
241         m = patterns['tested-by'].search (Line)
242         if m:
243             email = database.RemapEmail (m.group (2))
244             p.addtester (LookupStoreHacker (m.group (1), email))
245             p.author.testcredit (patch)
246             continue
247         # Reported-by:
248         m = patterns['reported-by'].search (Line)
249         if m:
250             email = database.RemapEmail (m.group (2))
251             p.addreporter (LookupStoreHacker (m.group (1), email))
252             p.author.reportcredit (patch)
253             continue
254         # Reported-and-tested-by:
255         m = patterns['reported-and-tested-by'].search (Line)
256         if m:
257             email = database.RemapEmail (m.group (2))
258             h = LookupStoreHacker (m.group (1), email)
259             p.addreporter (h)
260             p.addtester (h)
261             p.author.reportcredit (patch)
262             p.author.testcredit (patch)
263             continue
264         #
265         # If this one is a merge, make note of the fact.
266         #
267         m = patterns['merge'].match (Line)
268         if m:
269             p.merge = 1
270             continue
271         #
272         # See if it's the date.
273         #
274         m = patterns['date'].match (Line)
275         if m:
276             dt = rfc822.parsedate(m.group (2))
277             p.date = datetime.date (dt[0], dt[1], dt[2])
278             if p.date > Today:
279                 sys.stderr.write ('Funky date: %s\n' % p.date)
280                 p.date = Today
281             continue
282
283         # Get the statistics (lines added/removes) using numstats
284         # and without requiring a diff (--numstat instead -p)
285         (filename, filetype, added, removed) = parse_numstat (Line, FileFilter)
286         if filename:
287             p.added += added
288             p.removed += removed
289             p.addfiletype (filetype, added, removed)
290
291     if '@' in p.author.name:
292         GripeAboutAuthorName (p.author.name)
293
294     return p
295
296 def GripeAboutAuthorName (name):
297     if name in GripedAuthorNames:
298         return
299     GripedAuthorNames.append (name)
300     print '%s is an author name, probably not what you want' % (name)
301
302 #
303 # If this patch is signed off by both Andrew Morton and Linus Torvalds,
304 # remove the (redundant) Linus signoff.
305 #
306 def TrimLTSOBs (p):
307     if Linus in p.sobs and Akpm in p.sobs:
308         p.sobs.remove (Linus)
309
310
311 #
312 # Here starts the real program.
313 #
314 ParseOpts ()
315
316 #
317 # Read the config files.
318 #
319 ConfigFile.ConfigFile (CFName, DirName)
320
321 #
322 # Let's pre-seed the database with a couple of hackers
323 # we want to remember.
324 #
325 Linus = ('torvalds@linux-foundation.org',
326          LookupStoreHacker ('Linus Torvalds', 'torvalds@linux-foundation.org'))
327 Akpm = ('akpm@linux-foundation.org',
328         LookupStoreHacker ('Andrew Morton', 'akpm@linux-foundation.org'))
329
330 TotalChanged = TotalAdded = TotalRemoved = 0
331
332 #
333 # Snarf changesets.
334 #
335 print >> sys.stderr, 'Grabbing changesets...\r',
336
337 patches = logparser.LogPatchSplitter(sys.stdin)
338 printcount = CSCount = 0
339
340 for logpatch in patches:
341     if (printcount % 50) == 0:
342         print >> sys.stderr, 'Grabbing changesets...%d\r' % printcount,
343     printcount += 1
344     p = grabpatch(logpatch)
345     if not p:
346         break
347 #    if p.added > 100000 or p.removed > 100000:
348 #        print 'Skipping massive add', p.commit
349 #        continue
350     if FileFilter and p.added == 0 and p.removed == 0:
351         continue
352
353     #
354     # Record some global information - but only if this patch had
355     # stuff which wasn't ignored.
356     #
357     if ((p.added + p.removed) > 0 or not FileFilter) and not p.merge:
358         TotalAdded += p.added
359         TotalRemoved += p.removed
360         TotalChanged += max (p.added, p.removed)
361         AddDateLines (p.date, max (p.added, p.removed))
362         empl = p.author.emailemployer (p.email, p.date)
363         empl.AddCSet (p)
364         if AkpmOverLt:
365             TrimLTSOBs (p)
366         for sobemail, sobber in p.sobs:
367             empl = sobber.emailemployer (sobemail, p.date)
368             empl.AddSOB()
369
370     if not p.merge:
371         p.author.addpatch (p)
372         for sobemail, sob in p.sobs:
373             sob.addsob (p)
374         for hacker in p.reviews:
375             hacker.addreview (p)
376         for hacker in p.testers:
377             hacker.addtested (p)
378         for hacker in p.reports:
379             hacker.addreport (p)
380         CSCount += 1
381     csvdump.AccumulatePatch (p)
382     csvdump.store_patch (p)
383 print >> sys.stderr, 'Grabbing changesets...done       '
384
385 if DumpDB:
386     database.DumpDB ()
387 #
388 # Say something
389 #
390 hlist = database.AllHackers ()
391 elist = database.AllEmployers ()
392 ndev = nempl = 0
393 for h in hlist:
394     if len (h.patches) > 0:
395         ndev += 1
396 for e in elist:
397     if e.count > 0:
398         nempl += 1
399 reports.Write ('Processed %d csets from %d developers\n' % (CSCount,
400                                                             ndev))
401 reports.Write ('%d employers found\n' % (nempl))
402 reports.Write ('A total of %d lines added, %d removed (delta %d)\n' %
403                (TotalAdded, TotalRemoved, TotalAdded - TotalRemoved))
404 if TotalChanged == 0:
405     TotalChanged = 1 # HACK to avoid div by zero
406 if DateStats:
407     PrintDateStats ()
408     sys.exit(0)
409
410 if CSVPrefix:
411     csvdump.save_csv (CSVPrefix)
412
413 if CSVFile:
414     csvdump.OutputCSV (CSVFile)
415     CSVFile.close ()
416
417 if DevReports:
418     reports.DevReports (hlist, TotalChanged, CSCount, TotalRemoved)
419 reports.EmplReports (elist, TotalChanged, CSCount)