Allow multiple spaces within an email address
[git-dm.git] / missingmaints
blobc55478a5ee63677a11658eedb13a2836b8c35f82
1 #!/usr/bin/python3
3 # Plow through the maintainers file and the repository to find
4 # unmaintained subsystems.
6 # Copyright 2020 Jonathan Corbet <corbet@lwn.net>
7 # distributable under the GNU General Public License v2
9 # Basic operation is as follows.  Start by creating a processed MAINTAINERS
10 # database with something like:
12 #       ./missingmaints analyze -\
13 #               r /path/to/kernel/repo \
14 #               -j 4 # Number of jobs to run simultaneously
15 #               -g /path/to/aliases/file
16 #               -o /processed/data # Where the output goes
18 # This will take a while.
20 # See a report with:
22 #       ./missingmaints dump [subystem ...] -l /path/to/processed/data
24 # Throw in:
25 #       -a to see subsystems with no listed maintainers
26 #       -H to get output in HTML (useful for clicking on commits)
27 #       --nf to include subsystems with no files
28 #       -o to sort by oldest first
29 #       --select=never to list subsystems w/no maint activity at all
30 #       --select=nomaints to see subsystems with no listed maintainer
32 # Finally, look for subsystems with no maintainer activity but a lot of patches:
34 #       ./missingmaints urgent -l /path/to/processed/data -r /path/to/repository
36 # Add:
37 #       -c <commits>    # of commits since given ref
38 #       --ref <release> # when to start counting commits
39 #       -m <months>     # minimum months since maintainer seen
42 import os, argparse, sys, re, subprocess, datetime, pickle
43 from concurrent.futures import ThreadPoolExecutor, as_completed
46 # email aliases management
48 EmailAliases = { }
50 def add_alias(addr1, addr2):
51     aliases = EmailAliases.get(addr1, [ addr1 ])
52     for alias in EmailAliases.get(addr2, [ addr2 ]):
53         if alias not in aliases:
54             aliases.append(alias)
55     for alias in aliases:
56         EmailAliases[alias] = aliases
58 def get_aliases(email):
59     return EmailAliases.get(email, [ email ])
62 # Load a gitdm-style email aliases file
64 def load_gitdm_aliases(file):
65     with open(file, 'r') as f:
66         for line in f.readlines():
67             line = line.strip()
68             if not line or line[0] == '#':
69                 continue
70             sline = line.split()
71             add_alias(sline[0], sline[1])
74 # Load a kernel mailmap file.
76 mmap_alias = re.compile(r'^[^<]+<([^>]+)>\s+<([^>]+)>$')
78 def load_mailmap(file):
79     with open(file, 'r') as f:
80         for line in f.readlines():
81             m = mmap_alias.match(line)
82             if m:
83                 add_alias(m.group(1), m.group(2))
84         
85               
86             
89 # Manage a list of subsystems.
91 def latest_act(d):
92     latest = None
93     for role in d:
94         if not d[role]:
95             continue
96         if (latest is None) or (d[role][1] > latest):
97             latest = d[role][1]
98     return latest
100 class subsystem:
101     def __init__(self, name):
102         self.name = name
103         self.maints = [ ] # Don't really need this
104         self.mdata = { }
105         self.files = [ ]
106         self.status = 'unknown'
107         self.last_activity = None
109     def format_maint(self, maint):
110         minfo = self.mdata[maint]
111         if not minfo:
112             return '  %s: (idle)' % (maint)
113         ret = ['  ' + maint + ':']
114         if minfo['author']:
115             ret.append('    Author %s %s' % minfo['author'])
116         if minfo['committer']:
117             ret.append('    Committer %s %s' % minfo['committer'])
118         if minfo['tags']:
119             ret.append('    Tags %s %s' % minfo['tags'])
120         return '\n'.join(ret)
122     def __repr__(self):
123         ret = ['Subsystem %s' % (self.name)]
124         if not self.last_activity:
125             ret.append('  (No activity)')
126         else:
127             ret.append('  Last activity: ' + self.last_activity.strftime('%Y-%m-%d'))
128             for maint in self.maints:
129                 ret.append(self.format_maint(maint))
130         return '\n'.join(ret)
132     def __str__(self):
133         return self.__repr__()
135     def add_maintainer(self, maint):
136         self.maints.append(maint)
137         self.mdata[maint] = None
138     def store_minfo(self, maint, info):
139         self.mdata[maint] = info
140         if info:
141             latest = latest_act(info)
142             if latest:
143                 if (not self.last_activity) or latest > self.last_activity:
144                     self.last_activity = latest
145     def add_file(self, file):
146         self.files.append(file)
147     def set_status(self, status):
148         self.status = status
151 # Management of the MAINTAINERS file.
153 Subsystems = { }
155 def load_maintainers():
156     with open('MAINTAINERS', 'r') as mf:
157         #
158         # We "know" that 3c59x is the first entry in the file.  That could
159         # change, but it's been that way for a long time :)
160         #
161         line = mf.readline().strip()
162         while (line is not None) and not line.startswith('3C59X'):
163             line = mf.readline().strip()
164         if not line:
165             die("Bummer, couldn't find the first MAINTAINERS section")
166         #
167         # OK, soak everything up.
168         #
169         while line:
170             ss = load_subsystem(mf, line)
171             if ss.name == 'THE REST':
172                 return # victory!
173             Subsystems[ss.name] = ss
174             line = mf.readline().strip()
175             while (line is not None) and (len(line) == 0):
176                 line = mf.readline().strip()
177         print('Loaded %d subsystems' % len(Subsystems))
179 emailpat = re.compile(r'"?([^<]+)"? +<([^>]+)>')
180 def load_subsystem(mf, name):
181     ss = subsystem(name)
182     line = mf.readline().strip()
183     while line:
184         if line[1] != ':':
185             pass # print('Funky line %s in %s' % (line, name))
186         else:
187             field = line[0]
188             value = line[2:].strip()
189             if field == 'M':
190                 # Filter out mailing-list entries
191                 m = emailpat.search(value)
192                 if m:
193                     ss.add_maintainer(value)
194             elif field == 'F':
195                 ss.add_file(value)
196             elif field == 'S':
197                 ss.set_status(value)
198         line = mf.readline().strip()
199     return ss
200                                   
202 # Get info about a subsystem.
204 def get_subsys_info(subsys):
205     for m in subsys.maints:
206         subsys.store_minfo(m, lookup_maintainer(subsys, m, subsys.files))
207     print('Done:', subsys.name)
208     return subsys
210 def get_all_subsys_info(jobs):
211     names = list(Subsystems.keys())
212     with ThreadPoolExecutor(max_workers = jobs) as tpe:
213         futures = [tpe.submit(get_subsys_info, Subsystems[name]) for name in names]
214         for future in futures:
215             ss = future.result()
216             print(ss)
219 # Look up what a maintainer has been doing.
221 def lookup_maintainer(subsys, maint, files):
222     m = emailpat.search(maint)
223     if not m:
224 #        print('Funky maintainer line:', subsys.name, maint)
225         return None
226     if not files:
227 #        print('Subsys %s has no files' % subsys.name)
228         return None
229     email = m.group(2)
230     return {
231         'author': git_search(files, alias_args('--author=%s', email)),
232         'committer': git_search(files, alias_args('--committer=%s', email), cdate = True),
233         'tags': git_search(files, alias_args('--grep=by:.*%s', email)),
234         }
236 def alias_args(arg, email):
237     return [ arg % (alias) for alias in get_aliases(email) ]
238     
240 def decode_date(date):
241     return datetime.datetime.strptime(date, '%Y-%m-%d')
243 def git_search(files, tests, cdate = False):
244     command = ['git', 'log', '-1', '--pretty=format:%h %as %cs'] + tests + ['--'] + files
245     with subprocess.Popen(command, stdout = subprocess.PIPE) as p:
246         results = p.stdout.readline().decode('utf8')
247         p.wait()
248     if not results:
249         return None
250     commit, adate, cdate = results.strip().split()
251     if cdate:
252         return (commit, decode_date(cdate))
253     else:
254         return (commit, decode_date(adate))
255     
257 def die(string):
258     sys.stderr.write(string + '\n')
259     sys.exit(1)
261 # Argparsery
263 def setupargs():
264     p = argparse.ArgumentParser()
265     subs = p.add_subparsers()
266     #
267     # analyze
268     #
269     sp = subs.add_parser('analyze')
270     sp.add_argument('-g', '--gitdm-aliases', help = 'Load gitdm-style email aliases file',
271                     default = None)
272     sp.add_argument('-j', '--jobs', help = 'Number of threads to run', type = int,
273                     default = 4)
274     sp.add_argument('-o', '--output', help = 'Name of output database file',
275                     default = 'maintainers.pickle')
276     sp.add_argument('-r', '--repository', help = 'Repository location',
277                     default = '.')
278     sp.add_argument('-s', '--subsystem', help = 'Look at this subsystem only',
279                     default = None)
280     sp.set_defaults(handler = cmd_analyze)
281     #
282     # dump
283     #
284     sp = subs.add_parser('dump')
285     sp.add_argument('subsys', nargs = '*')
286     sp.add_argument('-a', '--all', help = 'Dump maintainerless entries too',
287                     action = 'store_true', default = False)
288     sp.add_argument('-H', '--html', help = 'Dump in HTML', action = 'store_true',
289                     default = False)
290     sp.add_argument('-l', '--load', help = 'Load data from pickle file',
291                    default = 'maintainers.pickle')
292     sp.add_argument('--nf', help = 'Include subsystems with no files',
293                     action = 'store_true', default = False)
294     sp.add_argument('-o', '--oldest', help = 'Sort oldest first', action = 'store_true',
295                     default = False)
296     sp.add_argument('-s', '--select', help = 'Filter for subsys to display',
297                     choices = ['never', 'nomaints'], default = None)
298     sp.set_defaults(handler = cmd_dump)
299     #
300     # urgent - find unmaintained subsystems with activity
301     #
302     sp = subs.add_parser('urgent')
303     sp.add_argument('-c', '--commits', help = 'How many commits since ref',
304                     default = 42, type = int)
305     sp.add_argument('-H', '--html', help = 'Dump in HTML', action = 'store_true',
306                     default = False)
307     sp.add_argument('-l', '--load', help = 'Load data from pickle file',
308                    default = 'maintainers.pickle')
309     sp.add_argument('-m', '--months', type = int,
310                     help = 'months of maint inactivity', default = 12)
311     sp.add_argument('--ref', help = 'Git ref to start patch count',
312                     required = True)
313     sp.add_argument('-r', '--repository', help = 'Repository location',
314                     default = '.')
315     sp.set_defaults(handler = cmd_urgent)
316     return p.parse_args()
320 # Analyze the maintainers file.
322 def cmd_analyze(args):
323     try:
324         with open(args.output, 'wb') as f:
325             do_analyze(args)
326             f.write(pickle.dumps(Subsystems))
327     except IOError:
328         die(f'Unable to open output file {args.output}')
330 def do_analyze(args):
331     os.chdir(args.repository)
332     #
333     # Snag email alias information.
334     #
335     if args.gitdm_aliases:
336         load_gitdm_aliases(args.gitdm_aliases)
337     load_mailmap('.mailmap')
338     #
339     # Get the maintainers file, then crank.
340     #
341     load_maintainers()
342     print('Cranking all (%d subsystems, %d jobs)...go out for dinner...' %
343           (len(Subsystems), args.jobs))
344     get_all_subsys_info(args.jobs)
346 def date_key(s):
347     return Subsystems[s].last_activity or datetime.datetime(1990, 1, 1)
349 # Dump out some info.
351 dump_html_header = '''
352 <table>
353 <tr><th>Subsystem</th>
354     <th>Activity</th>
355     <th>Maintainer</th>
356     <th>Author</th>
357     <th>Commit</th>
358     <th>Tag</th></tr>
361 dump_html_footer = '</table>'
363 def dump_pdate(date):
364     if date:
365         return date.strftime("%Y-%m-%d")
366     return '——'
368 def git_url(commit):
369     return 'https://git.kernel.org/linus/' + commit
371 nameonly = re.compile(r'"?([^<"]+)"?\s+(<.*>)?')
372 def fixup_maint_name(name):
373     m = nameonly.match(name)
374     if m:
375         return m.group(1)
376     return name
378 RClass = "Odd" # XXX
379 def rowclass():
380     global RClass
381     if RClass == 'Odd':
382         RClass = 'Even'
383     else:
384         RClass = 'Odd'
385     return f'"{RClass}"'
387 def dump_subsys_html(ss):
388     span = max(1, len(ss.maints))
389     rc = rowclass()
390     print(f'''<tr class={rc}>
391         <td valign="top" rowspan={span}>{ss.name}</td>
392         <td valign="top" rowspan={span}>{dump_pdate(ss.last_activity)}</td>''')
393     if not ss.maints:
394         print('<td>(no maintainers)</td><td colspan=3></td></tr>')
395         return
396     rowstart = ''
397     for m in ss.maints:
398         mi = ss.mdata[m]
399         if not mi:
400             mi = { 'author': None, 'committer': None, 'tags': None}
401         print(f'\t{rowstart}<td valign="top">{fixup_maint_name(m)}</td>')
402         for type in ['author', 'committer', 'tags']:
403             if mi[type]:
404                 commit, date = mi[type]
405                 print(f'\t  <td valign="top"><a href="{git_url(commit)}">{dump_pdate(date)}</a></td>')
406             else:
407                 print('\t  <td valign="top">——</td>')
408         print('</tr>')
409         rowstart = f'<tr class={rc}>'
411 def load_pickle(pfile):
412     global Subsystems
414     try:
415         with open(pfile, 'rb') as f:
416             Subsystems = pickle.loads(f.read())
417     except IOError:
418         die(f'Unable to open pickle file {pfile}')
420 def cmd_dump(args):
421     load_pickle(args.load)
422     subs = args.subsys or Subsystems.keys()
423     if not args.nf:
424         subs = [sub for sub in subs if Subsystems[sub].files]
425     if args.select == 'never':
426         subs = [sub for sub in subs if Subsystems[sub].last_activity is None]
427     elif args.select == 'nomaints':
428         subs = [sub for sub in subs if not Subsystems[sub].maints]
429     if args.oldest:
430         subs = sorted(subs, key = date_key)
431     if args.html:
432         print(dump_html_header)
433     for sub in subs:
434         try:
435             s = Subsystems[sub]
436         except KeyError:
437             die("No such subsystem: %s" % (sub))
438         if not (args.all or s.maints):
439             continue
440         if args.html:
441             dump_subsys_html(s)
442         else:
443             print(s)
444     if args.html:
445         print(dump_html_footer)
448 # urgent - find unmaintained subsystems with activity
450 def unmaintained_for(subsys, delta):
451     if subsys.last_activity is None:
452         return True
453     return (datetime.datetime.now() - subsys.last_activity) >= delta
455 def get_commit_count(subsys, ref):
456     cmd = ['git', 'log', '--oneline', f'{ref}..', '--'] + subsys.files
457     with subprocess.Popen(cmd, stdout = subprocess.PIPE) as p:
458         count = 0
459         for line in p.stdout.readlines():
460             count += 1
461         p.wait()
462     return count
464 def cmd_urgent(args):
465     os.chdir(args.repository)
466     load_pickle(args.load)
467     #
468     # Get the list of unmaintained subsystems.
469     #
470     delta = datetime.timedelta(days = args.months*30)
471     subs = [sub for sub in Subsystems.keys()
472             if (Subsystems[sub].files and
473                 unmaintained_for(Subsystems[sub], delta))]
474     #
475     # Now, for each one, see how many patches exist during the ref period.
476     #
477     if args.html:
478         print('<table class="OddEven">')
479         print('<tr><th>Subsystem</th><th>Activity</th><th>Commits</th></tr>')
480     for sub in subs:
481         ss = Subsystems[sub]
482         commits = get_commit_count(ss, args.ref)
483         if commits >= args.commits:
484             if ss.last_activity:
485                 activity = ss.last_activity.strftime('%Y-%m-%d')
486             else:
487                 activity = '——'
488             if args.html:
489                 print(f'<tr><td>{ss.name}</td><td>{activity}</td><td>{commits}</td></tr>')
490             else:
491                 print(f'{ss.name}: {activity} {commits}')
492     if args.html:
493         print('</table>')
495 # Main program
497 args = setupargs()
498 args.handler(args)