[xbuild] Add missing api for Target.
[mono.git] / scripts / commits-to-changelog.py
1 #!/usr/bin/python
2
3 from optparse import OptionParser
4 import subprocess
5 import re
6 import os.path
7 import fnmatch
8 import os
9 import sys
10
11 # subtract 8 for the leading tabstop
12 fill_column = 74 - 8
13
14 path_to_root = None
15
16 all_changelogs = {}
17
18 def git (command, *args):
19     popen = subprocess.Popen (["git", command] + list (args), stdout = subprocess.PIPE)
20     output = popen.communicate () [0]
21     if popen.returncode != 0:
22         print >> sys.stderr, "Error: git failed"
23         exit (1)
24     return output
25
26 def changelog_path (changelog):
27     global path_to_root
28     if not path_to_root:
29         path_to_root = git ("rev-parse", "--show-cdup").strip ()
30     (pathname, filename) = changelog
31     return path_to_root + "./" + pathname + "/" + filename
32
33 def changelog_for_file (filename):
34     while filename != "":
35         dirname = os.path.dirname (filename)
36         if dirname in all_changelogs:
37             return (dirname, all_changelogs [dirname])
38         filename = dirname
39     assert False
40
41 def changelogs_for_file_pattern (pattern, changed_files):
42     changelogs = set ()
43     for filename in changed_files:
44         suffix = filename
45         while suffix != "":
46             # FIXME: fnmatch doesn't support the {x,y} pattern
47             if fnmatch.fnmatch (suffix, pattern):
48                 changelogs.add (changelog_for_file (filename))
49             (_, _, suffix) = suffix.partition ("/")
50     return changelogs
51
52 def format_paragraph (paragraph):
53     lines = []
54     words = paragraph.split ()
55     current = words [0]
56     for word in words [1:]:
57         if len (current) + 1 + len (word) <= fill_column:
58             current += " " + word
59         else:
60             lines.append ("\t" + current)
61             current = word
62     lines.append ("\t" + current)
63     return lines
64
65 def format_changelog_paragraph (files, paragraph):
66     files_string = ""
67     for (filename, entity) in files:
68         if len (files_string) > 0:
69             files_string += ", "
70         files_string += filename
71         if entity:
72             files_string += " (" + entity + ")"
73     return format_paragraph ("* " + files_string + ": " + paragraph)
74
75 def append_paragraph (lines, paragraph):
76     if len (lines):
77         lines.append ("")
78     lines += paragraph
79
80 def format_changelog_entries (commit, changed_files, prefix, file_entries, all_paragraphs):
81     changelogs = set ()
82     for f in changed_files:
83         changelogs.add (changelog_for_file (f))
84     marked_changelogs = set ()
85
86     author_line = git ("log", "-n1", "--date=short", "--format=%ad  %an  <%ae>", commit).strip ()
87
88     paragraphs = {}
89     for changelog in changelogs:
90         paragraphs [changelog] = [author_line]
91
92     for (files, comments) in file_entries:
93         changelog_entries = {}
94         for (filename, entity) in files:
95             entry_changelogs = changelogs_for_file_pattern (filename, changed_files)
96             if len (entry_changelogs) == 0:
97                 print "Warning: could not match file %s in commit %s" % (filename, commit)
98             for changelog in entry_changelogs:
99                 if changelog not in changelog_entries:
100                     changelog_entries [changelog] = []
101                 changelog_entries [changelog].append ((filename, entity))
102                 marked_changelogs.add (changelog)
103
104         for (changelog, files) in changelog_entries.items ():
105             append_paragraph (paragraphs [changelog], format_changelog_paragraph (files, comments [0]))
106             for paragraph in comments [1:]:
107                 append_paragraph (paragraphs [changelog], format_paragraph (paragraph))
108
109     unmarked_changelogs = changelogs - marked_changelogs
110     for changelog in unmarked_changelogs:
111         if len (prefix) == 0:
112             print "Warning: empty entry in %s for commit %s" % (changelog_path (changelog), commit)
113             insert_paragraphs = all_paragraphs
114         else:
115             insert_paragraphs = prefix
116         for paragraph in insert_paragraphs:
117             append_paragraph (paragraphs [changelog], format_paragraph (paragraph))
118
119     return paragraphs
120
121 def debug_print_commit (commit, raw_message, prefix, file_entries, changed_files, changelog_entries):
122     print "===================== Commit"
123     print commit
124     print "--------------------- RAW"
125     print raw_message
126     print "--------------------- Prefix"
127     for line in prefix:
128         print line
129     print "--------------------- File entries"
130     for (files, comments) in file_entries:
131         files_str = ""
132         for (filename, entity) in files:
133             if len (files_str):
134                 files_str = files_str + ", "
135             files_str = files_str + filename
136             if entity:
137                 files_str = files_str + " (" + entity + ")"
138         print files_str
139         for line in comments:
140             print "  " + line
141     print "--------------------- Files touched"
142     for f in changed_files:
143         print f
144     print "--------------------- ChangeLog entries"
145     for ((dirname, filename), lines) in changelog_entries.items ():
146         print "%s/%s:" % (dirname, filename)
147         for line in lines:
148             print line
149
150 def process_commit (commit):
151     changed_files = map (lambda l: l.split () [2], git ("diff-tree", "--numstat", commit).splitlines () [1:])
152     if len (filter (lambda f: re.search ("(^|/)Change[Ll]og$", f), changed_files)):
153         return None
154     raw_message = git ("log", "-n1", "--format=%B", commit)
155     # filter SVN migration message
156     message = re.sub ("(^|\n)svn path=[^\n]+revision=\d+(?=$|\n)", "", raw_message)
157     # filter ChangeLog headers
158     message = re.sub ("(^|\n)\d+-\d+-\d+[ \t]+((\w|[.-])+[ \t]+)+<[^\n>]+>(?=$|\n)", "", message)
159     # filter leading whitespace
160     message = re.sub ("^\s+", "", message)
161     # filter trailing whitespace
162     message = re.sub ("\s+$", "", message)
163     # paragraphize - first remove whitespace at beginnings and ends of lines
164     message = re.sub ("[ \t]*\n[ \t]*", "\n", message)
165     # paragraphize - now replace three or more consecutive newlines with two
166     message = re.sub ("\n\n\n+", "\n\n", message)
167     # paragraphize - replace single newlines with a space
168     message = re.sub ("(?<!\n)\n(?!\n)", " ", message)
169     # paragraphize - finally, replace double newlines with single ones
170     message = re.sub ("\n\n", "\n", message)
171
172     # A list of paragraphs (strings without newlines) that occur
173     # before the first file comments
174     prefix = []
175
176     # A list of tuples of the form ([(filename, entity), ...], [paragraph, ...]).
177     #
178     # Each describes a file comment, containing multiple paragraphs.
179     # Those paragraphs belong to a list of files, each with an
180     # optional entity (usually a function name).
181     file_entries = []
182
183     current_files = None
184     current_files_comments = None
185
186     message_lines = message.splitlines ()
187     for line in message_lines:
188         if re.match ("\*\s[^:]+:", line):
189             if current_files:
190                 file_entries.append ((current_files, current_files_comments))
191
192             (files, _, comments) = line.partition (":")
193
194             current_files_comments = [comments.strip ()]
195
196             current_files = []
197             for f in re.split ("\s*,\s*", files [1:].strip ()):
198                 m = re.search ("\(([^()]+)\)$", f)
199                 if m:
200                     filename = f [:m.start (0)].strip ()
201                     entity = m.group (1).strip ()
202                 else:
203                     filename = f
204                     entity = None
205                 current_files.append ((filename, entity))
206         else:
207             if current_files:
208                 current_files_comments.append (line)
209             else:
210                 prefix.append (line)
211     if current_files:
212         file_entries.append ((current_files, current_files_comments))
213
214     changelog_entries = format_changelog_entries (commit, changed_files, prefix, file_entries, message_lines)
215
216     #debug_print_commit (commit, raw_message, prefix, file_entries, changed_files, changelog_entries)
217
218     return changelog_entries
219
220 def start_changelog (changelog):
221     full_path = changelog_path (changelog)
222     old_name = full_path + ".old"
223     os.rename (full_path, old_name)
224     return open (full_path, "w")
225
226 def finish_changelog (changelog, file):
227     old_file = open (changelog_path (changelog) + ".old")
228     file.write (old_file.read ())
229     old_file.close ()
230     file.close ()
231
232 def append_lines (file, lines):
233     for line in lines:
234         file.write (line + "\n")
235     file.write ("\n")
236
237 def main ():
238     usage = "usage: %prog [options] <start-commit>"
239     parser = OptionParser (usage)
240     parser.add_option ("-r", "--root", dest = "root", help = "Root directory of the working tree to be changed")
241     (options, args) = parser.parse_args ()
242     if len (args) != 1:
243         parser.error ("incorrect number of arguments")
244     start_commit = args [0]
245
246     if options.root:
247         global path_to_root
248         path_to_root = options.root + "/"
249
250     # MonkeyWrench uses a shared git repo but sets BUILD_REVISION,
251     # if present we use it instead of HEAD
252     HEAD = "HEAD"
253     if 'BUILD_REVISION' in os.environ:
254         HEAD = os.environ['BUILD_REVISION']
255
256     #see if git supports %B in --format
257     output = git ("log", "-n1", "--format=%B", HEAD)
258     if output.startswith ("%B"):
259         print >> sys.stderr, "Error: git doesn't support %B in --format - install version 1.7.2 or newer"
260         exit (1)
261
262     for filename in git ("ls-tree", "-r", "--name-only", HEAD).splitlines ():
263         if re.search ("(^|/)Change[Ll]og$", filename):
264             (path, name) = os.path.split (filename)
265             all_changelogs [path] = name
266
267     commits = git ("rev-list", "--no-merges", HEAD, "^%s" % start_commit).splitlines ()
268
269     touched_changelogs = {}
270     for commit in commits:
271         entries = process_commit (commit)
272         if entries == None:
273             continue
274         for (changelog, lines) in entries.items ():
275             if not os.path.exists (changelog_path (changelog)):
276                 continue
277             if changelog not in touched_changelogs:
278                 touched_changelogs [changelog] = start_changelog (changelog)
279             append_lines (touched_changelogs [changelog], lines)
280     for (changelog, file) in touched_changelogs.items ():
281         finish_changelog (changelog, file)
282
283 if __name__ == "__main__":
284     main ()