patman: Support collecting response tags in Patchstream
[platform/kernel/u-boot.git] / tools / patman / patchstream.py
1 # SPDX-License-Identifier: GPL-2.0+
2 # Copyright (c) 2011 The Chromium OS Authors.
3 #
4
5 import datetime
6 import math
7 import os
8 import re
9 import shutil
10 import tempfile
11
12 from patman import command
13 from patman import commit
14 from patman import gitutil
15 from patman.series import Series
16
17 # Tags that we detect and remove
18 re_remove = re.compile('^BUG=|^TEST=|^BRANCH=|^Review URL:'
19     '|Reviewed-on:|Commit-\w*:')
20
21 # Lines which are allowed after a TEST= line
22 re_allowed_after_test = re.compile('^Signed-off-by:')
23
24 # Signoffs
25 re_signoff = re.compile('^Signed-off-by: *(.*)')
26
27 # Cover letter tag
28 re_cover = re.compile('^Cover-([a-z-]*): *(.*)')
29
30 # Patch series tag
31 re_series_tag = re.compile('^Series-([a-z-]*): *(.*)')
32
33 # Change-Id will be used to generate the Message-Id and then be stripped
34 re_change_id = re.compile('^Change-Id: *(.*)')
35
36 # Commit series tag
37 re_commit_tag = re.compile('^Commit-([a-z-]*): *(.*)')
38
39 # Commit tags that we want to collect and keep
40 re_tag = re.compile('^(Tested-by|Acked-by|Reviewed-by|Patch-cc|Fixes): (.*)')
41
42 # The start of a new commit in the git log
43 re_commit = re.compile('^commit ([0-9a-f]*)$')
44
45 # We detect these since checkpatch doesn't always do it
46 re_space_before_tab = re.compile('^[+].* \t')
47
48 # Match indented lines for changes
49 re_leading_whitespace = re.compile('^\s')
50
51 # States we can be in - can we use range() and still have comments?
52 STATE_MSG_HEADER = 0        # Still in the message header
53 STATE_PATCH_SUBJECT = 1     # In patch subject (first line of log for a commit)
54 STATE_PATCH_HEADER = 2      # In patch header (after the subject)
55 STATE_DIFFS = 3             # In the diff part (past --- line)
56
57 class PatchStream:
58     """Class for detecting/injecting tags in a patch or series of patches
59
60     We support processing the output of 'git log' to read out the tags we
61     are interested in. We can also process a patch file in order to remove
62     unwanted tags or inject additional ones. These correspond to the two
63     phases of processing.
64     """
65     def __init__(self, series, name=None, is_log=False):
66         self.skip_blank = False          # True to skip a single blank line
67         self.found_test = False          # Found a TEST= line
68         self.lines_after_test = 0        # Number of lines found after TEST=
69         self.warn = []                   # List of warnings we have collected
70         self.linenum = 1                 # Output line number we are up to
71         self.in_section = None           # Name of start...END section we are in
72         self.notes = []                  # Series notes
73         self.section = []                # The current section...END section
74         self.series = series             # Info about the patch series
75         self.is_log = is_log             # True if indent like git log
76         self.in_change = None            # Name of the change list we are in
77         self.change_version = 0          # Non-zero if we are in a change list
78         self.change_lines = []           # Lines of the current change
79         self.blank_count = 0             # Number of blank lines stored up
80         self.state = STATE_MSG_HEADER    # What state are we in?
81         self.signoff = []                # Contents of signoff line
82         self.commit = None               # Current commit
83
84     def AddToSeries(self, line, name, value):
85         """Add a new Series-xxx tag.
86
87         When a Series-xxx tag is detected, we come here to record it, if we
88         are scanning a 'git log'.
89
90         Args:
91             line: Source line containing tag (useful for debug/error messages)
92             name: Tag name (part after 'Series-')
93             value: Tag value (part after 'Series-xxx: ')
94         """
95         if name == 'notes':
96             self.in_section = name
97             self.skip_blank = False
98         if self.is_log:
99             self.series.AddTag(self.commit, line, name, value)
100
101     def AddToCommit(self, line, name, value):
102         """Add a new Commit-xxx tag.
103
104         When a Commit-xxx tag is detected, we come here to record it.
105
106         Args:
107             line: Source line containing tag (useful for debug/error messages)
108             name: Tag name (part after 'Commit-')
109             value: Tag value (part after 'Commit-xxx: ')
110         """
111         if name == 'notes':
112             self.in_section = 'commit-' + name
113             self.skip_blank = False
114
115     def AddCommitRtag(self, rtag_type, who):
116         """Add a response tag to the current commit
117
118         Args:
119             key: rtag type (e.g. 'Reviewed-by')
120             who: Person who gave that rtag, e.g. 'Fred Bloggs <fred@bloggs.org>'
121         """
122         self.commit.AddRtag(rtag_type, who)
123
124     def CloseCommit(self):
125         """Save the current commit into our commit list, and reset our state"""
126         if self.commit and self.is_log:
127             self.series.AddCommit(self.commit)
128             self.commit = None
129         # If 'END' is missing in a 'Cover-letter' section, and that section
130         # happens to show up at the very end of the commit message, this is
131         # the chance for us to fix it up.
132         if self.in_section == 'cover' and self.is_log:
133             self.series.cover = self.section
134             self.in_section = None
135             self.skip_blank = True
136             self.section = []
137
138     def ParseVersion(self, value, line):
139         """Parse a version from a *-changes tag
140
141         Args:
142             value: Tag value (part after 'xxx-changes: '
143             line: Source line containing tag
144
145         Returns:
146             The version as an integer
147         """
148         try:
149             return int(value)
150         except ValueError as str:
151             raise ValueError("%s: Cannot decode version info '%s'" %
152                 (self.commit.hash, line))
153
154     def FinalizeChange(self):
155         """Finalize a (multi-line) change and add it to the series or commit"""
156         if not self.change_lines:
157             return
158         change = '\n'.join(self.change_lines)
159
160         if self.in_change == 'Series':
161             self.series.AddChange(self.change_version, self.commit, change)
162         elif self.in_change == 'Cover':
163             self.series.AddChange(self.change_version, None, change)
164         elif self.in_change == 'Commit':
165             self.commit.AddChange(self.change_version, change)
166         self.change_lines = []
167
168     def ProcessLine(self, line):
169         """Process a single line of a patch file or commit log
170
171         This process a line and returns a list of lines to output. The list
172         may be empty or may contain multiple output lines.
173
174         This is where all the complicated logic is located. The class's
175         state is used to move between different states and detect things
176         properly.
177
178         We can be in one of two modes:
179             self.is_log == True: This is 'git log' mode, where most output is
180                 indented by 4 characters and we are scanning for tags
181
182             self.is_log == False: This is 'patch' mode, where we already have
183                 all the tags, and are processing patches to remove junk we
184                 don't want, and add things we think are required.
185
186         Args:
187             line: text line to process
188
189         Returns:
190             list of output lines, or [] if nothing should be output
191         """
192         # Initially we have no output. Prepare the input line string
193         out = []
194         line = line.rstrip('\n')
195
196         commit_match = re_commit.match(line) if self.is_log else None
197
198         if self.is_log:
199             if line[:4] == '    ':
200                 line = line[4:]
201
202         # Handle state transition and skipping blank lines
203         series_tag_match = re_series_tag.match(line)
204         change_id_match = re_change_id.match(line)
205         commit_tag_match = re_commit_tag.match(line)
206         cover_match = re_cover.match(line)
207         signoff_match = re_signoff.match(line)
208         leading_whitespace_match = re_leading_whitespace.match(line)
209         tag_match = None
210         if self.state == STATE_PATCH_HEADER:
211             tag_match = re_tag.match(line)
212         is_blank = not line.strip()
213         if is_blank:
214             if (self.state == STATE_MSG_HEADER
215                     or self.state == STATE_PATCH_SUBJECT):
216                 self.state += 1
217
218             # We don't have a subject in the text stream of patch files
219             # It has its own line with a Subject: tag
220             if not self.is_log and self.state == STATE_PATCH_SUBJECT:
221                 self.state += 1
222         elif commit_match:
223             self.state = STATE_MSG_HEADER
224
225         # If a tag is detected, or a new commit starts
226         if series_tag_match or commit_tag_match or change_id_match or \
227            cover_match or signoff_match or self.state == STATE_MSG_HEADER:
228             # but we are already in a section, this means 'END' is missing
229             # for that section, fix it up.
230             if self.in_section:
231                 self.warn.append("Missing 'END' in section '%s'" % self.in_section)
232                 if self.in_section == 'cover':
233                     self.series.cover = self.section
234                 elif self.in_section == 'notes':
235                     if self.is_log:
236                         self.series.notes += self.section
237                 elif self.in_section == 'commit-notes':
238                     if self.is_log:
239                         self.commit.notes += self.section
240                 else:
241                     self.warn.append("Unknown section '%s'" % self.in_section)
242                 self.in_section = None
243                 self.skip_blank = True
244                 self.section = []
245             # but we are already in a change list, that means a blank line
246             # is missing, fix it up.
247             if self.in_change:
248                 self.warn.append("Missing 'blank line' in section '%s-changes'" % self.in_change)
249                 self.FinalizeChange()
250                 self.in_change = None
251                 self.change_version = 0
252
253         # If we are in a section, keep collecting lines until we see END
254         if self.in_section:
255             if line == 'END':
256                 if self.in_section == 'cover':
257                     self.series.cover = self.section
258                 elif self.in_section == 'notes':
259                     if self.is_log:
260                         self.series.notes += self.section
261                 elif self.in_section == 'commit-notes':
262                     if self.is_log:
263                         self.commit.notes += self.section
264                 else:
265                     self.warn.append("Unknown section '%s'" % self.in_section)
266                 self.in_section = None
267                 self.skip_blank = True
268                 self.section = []
269             else:
270                 self.section.append(line)
271
272         # Detect the commit subject
273         elif not is_blank and self.state == STATE_PATCH_SUBJECT:
274             self.commit.subject = line
275
276         # Detect the tags we want to remove, and skip blank lines
277         elif re_remove.match(line) and not commit_tag_match:
278             self.skip_blank = True
279
280             # TEST= should be the last thing in the commit, so remove
281             # everything after it
282             if line.startswith('TEST='):
283                 self.found_test = True
284         elif self.skip_blank and is_blank:
285             self.skip_blank = False
286
287         # Detect Cover-xxx tags
288         elif cover_match:
289             name = cover_match.group(1)
290             value = cover_match.group(2)
291             if name == 'letter':
292                 self.in_section = 'cover'
293                 self.skip_blank = False
294             elif name == 'letter-cc':
295                 self.AddToSeries(line, 'cover-cc', value)
296             elif name == 'changes':
297                 self.in_change = 'Cover'
298                 self.change_version = self.ParseVersion(value, line)
299
300         # If we are in a change list, key collected lines until a blank one
301         elif self.in_change:
302             if is_blank:
303                 # Blank line ends this change list
304                 self.FinalizeChange()
305                 self.in_change = None
306                 self.change_version = 0
307             elif line == '---':
308                 self.FinalizeChange()
309                 self.in_change = None
310                 self.change_version = 0
311                 out = self.ProcessLine(line)
312             elif self.is_log:
313                 if not leading_whitespace_match:
314                     self.FinalizeChange()
315                 self.change_lines.append(line)
316             self.skip_blank = False
317
318         # Detect Series-xxx tags
319         elif series_tag_match:
320             name = series_tag_match.group(1)
321             value = series_tag_match.group(2)
322             if name == 'changes':
323                 # value is the version number: e.g. 1, or 2
324                 self.in_change = 'Series'
325                 self.change_version = self.ParseVersion(value, line)
326             else:
327                 self.AddToSeries(line, name, value)
328                 self.skip_blank = True
329
330         # Detect Change-Id tags
331         elif change_id_match:
332             value = change_id_match.group(1)
333             if self.is_log:
334                 if self.commit.change_id:
335                     raise ValueError("%s: Two Change-Ids: '%s' vs. '%s'" %
336                         (self.commit.hash, self.commit.change_id, value))
337                 self.commit.change_id = value
338             self.skip_blank = True
339
340         # Detect Commit-xxx tags
341         elif commit_tag_match:
342             name = commit_tag_match.group(1)
343             value = commit_tag_match.group(2)
344             if name == 'notes':
345                 self.AddToCommit(line, name, value)
346                 self.skip_blank = True
347             elif name == 'changes':
348                 self.in_change = 'Commit'
349                 self.change_version = self.ParseVersion(value, line)
350
351         # Detect the start of a new commit
352         elif commit_match:
353             self.CloseCommit()
354             self.commit = commit.Commit(commit_match.group(1))
355
356         # Detect tags in the commit message
357         elif tag_match:
358             rtag_type, who = tag_match.groups()
359             self.AddCommitRtag(rtag_type, who)
360             # Remove Tested-by self, since few will take much notice
361             if (rtag_type == 'Tested-by' and
362                     who.find(os.getenv('USER') + '@') != -1):
363                 self.warn.append("Ignoring %s" % line)
364             elif rtag_type == 'Patch-cc':
365                 self.commit.AddCc(who.split(','))
366             else:
367                 out = [line]
368
369         # Suppress duplicate signoffs
370         elif signoff_match:
371             if (self.is_log or not self.commit or
372                 self.commit.CheckDuplicateSignoff(signoff_match.group(1))):
373                 out = [line]
374
375         # Well that means this is an ordinary line
376         else:
377             # Look for space before tab
378             m = re_space_before_tab.match(line)
379             if m:
380                 self.warn.append('Line %d/%d has space before tab' %
381                     (self.linenum, m.start()))
382
383             # OK, we have a valid non-blank line
384             out = [line]
385             self.linenum += 1
386             self.skip_blank = False
387             if self.state == STATE_DIFFS:
388                 pass
389
390             # If this is the start of the diffs section, emit our tags and
391             # change log
392             elif line == '---':
393                 self.state = STATE_DIFFS
394
395                 # Output the tags (signoff first), then change list
396                 out = []
397                 log = self.series.MakeChangeLog(self.commit)
398                 out += [line]
399                 if self.commit:
400                     out += self.commit.notes
401                 out += [''] + log
402             elif self.found_test:
403                 if not re_allowed_after_test.match(line):
404                     self.lines_after_test += 1
405
406         return out
407
408     def Finalize(self):
409         """Close out processing of this patch stream"""
410         self.FinalizeChange()
411         self.CloseCommit()
412         if self.lines_after_test:
413             self.warn.append('Found %d lines after TEST=' %
414                     self.lines_after_test)
415
416     def WriteMessageId(self, outfd):
417         """Write the Message-Id into the output.
418
419         This is based on the Change-Id in the original patch, the version,
420         and the prefix.
421
422         Args:
423             outfd: Output stream file object
424         """
425         if not self.commit.change_id:
426             return
427
428         # If the count is -1 we're testing, so use a fixed time
429         if self.commit.count == -1:
430             time_now = datetime.datetime(1999, 12, 31, 23, 59, 59)
431         else:
432             time_now = datetime.datetime.now()
433
434         # In theory there is email.utils.make_msgid() which would be nice
435         # to use, but it already produces something way too long and thus
436         # will produce ugly commit lines if someone throws this into
437         # a "Link:" tag in the final commit.  So (sigh) roll our own.
438
439         # Start with the time; presumably we wouldn't send the same series
440         # with the same Change-Id at the exact same second.
441         parts = [time_now.strftime("%Y%m%d%H%M%S")]
442
443         # These seem like they would be nice to include.
444         if 'prefix' in self.series:
445             parts.append(self.series['prefix'])
446         if 'version' in self.series:
447             parts.append("v%s" % self.series['version'])
448
449         parts.append(str(self.commit.count + 1))
450
451         # The Change-Id must be last, right before the @
452         parts.append(self.commit.change_id)
453
454         # Join parts together with "." and write it out.
455         outfd.write('Message-Id: <%s@changeid>\n' % '.'.join(parts))
456
457     def ProcessStream(self, infd, outfd):
458         """Copy a stream from infd to outfd, filtering out unwanting things.
459
460         This is used to process patch files one at a time.
461
462         Args:
463             infd: Input stream file object
464             outfd: Output stream file object
465         """
466         # Extract the filename from each diff, for nice warnings
467         fname = None
468         last_fname = None
469         re_fname = re.compile('diff --git a/(.*) b/.*')
470
471         self.WriteMessageId(outfd)
472
473         while True:
474             line = infd.readline()
475             if not line:
476                 break
477             out = self.ProcessLine(line)
478
479             # Try to detect blank lines at EOF
480             for line in out:
481                 match = re_fname.match(line)
482                 if match:
483                     last_fname = fname
484                     fname = match.group(1)
485                 if line == '+':
486                     self.blank_count += 1
487                 else:
488                     if self.blank_count and (line == '-- ' or match):
489                         self.warn.append("Found possible blank line(s) at "
490                                 "end of file '%s'" % last_fname)
491                     outfd.write('+\n' * self.blank_count)
492                     outfd.write(line + '\n')
493                     self.blank_count = 0
494         self.Finalize()
495
496
497 def GetMetaDataForList(commit_range, git_dir=None, count=None,
498                        series = None, allow_overwrite=False):
499     """Reads out patch series metadata from the commits
500
501     This does a 'git log' on the relevant commits and pulls out the tags we
502     are interested in.
503
504     Args:
505         commit_range: Range of commits to count (e.g. 'HEAD..base')
506         git_dir: Path to git repositiory (None to use default)
507         count: Number of commits to list, or None for no limit
508         series: Series object to add information into. By default a new series
509             is started.
510         allow_overwrite: Allow tags to overwrite an existing tag
511     Returns:
512         A Series object containing information about the commits.
513     """
514     if not series:
515         series = Series()
516     series.allow_overwrite = allow_overwrite
517     params = gitutil.LogCmd(commit_range, reverse=True, count=count,
518                             git_dir=git_dir)
519     stdout = command.RunPipe([params], capture=True).stdout
520     ps = PatchStream(series, is_log=True)
521     for line in stdout.splitlines():
522         ps.ProcessLine(line)
523     ps.Finalize()
524     return series
525
526 def GetMetaData(branch, start, count):
527     """Reads out patch series metadata from the commits
528
529     This does a 'git log' on the relevant commits and pulls out the tags we
530     are interested in.
531
532     Args:
533         branch: Branch to use (None for current branch)
534         start: Commit to start from: 0=branch HEAD, 1=next one, etc.
535         count: Number of commits to list
536     """
537     return GetMetaDataForList('%s~%d' % (branch if branch else 'HEAD', start),
538                               None, count)
539
540 def GetMetaDataForTest(text):
541     """Process metadata from a file containing a git log. Used for tests
542
543     Args:
544         text:
545     """
546     series = Series()
547     ps = PatchStream(series, is_log=True)
548     for line in text.splitlines():
549         ps.ProcessLine(line)
550     ps.Finalize()
551     return series
552
553 def FixPatch(backup_dir, fname, series, commit):
554     """Fix up a patch file, by adding/removing as required.
555
556     We remove our tags from the patch file, insert changes lists, etc.
557     The patch file is processed in place, and overwritten.
558
559     A backup file is put into backup_dir (if not None).
560
561     Args:
562         fname: Filename to patch file to process
563         series: Series information about this patch set
564         commit: Commit object for this patch file
565     Return:
566         A list of errors, or [] if all ok.
567     """
568     handle, tmpname = tempfile.mkstemp()
569     outfd = os.fdopen(handle, 'w', encoding='utf-8')
570     infd = open(fname, 'r', encoding='utf-8')
571     ps = PatchStream(series)
572     ps.commit = commit
573     ps.ProcessStream(infd, outfd)
574     infd.close()
575     outfd.close()
576
577     # Create a backup file if required
578     if backup_dir:
579         shutil.copy(fname, os.path.join(backup_dir, os.path.basename(fname)))
580     shutil.move(tmpname, fname)
581     return ps.warn
582
583 def FixPatches(series, fnames):
584     """Fix up a list of patches identified by filenames
585
586     The patch files are processed in place, and overwritten.
587
588     Args:
589         series: The series object
590         fnames: List of patch files to process
591     """
592     # Current workflow creates patches, so we shouldn't need a backup
593     backup_dir = None  #tempfile.mkdtemp('clean-patch')
594     count = 0
595     for fname in fnames:
596         commit = series.commits[count]
597         commit.patch = fname
598         commit.count = count
599         result = FixPatch(backup_dir, fname, series, commit)
600         if result:
601             print('%d warnings for %s:' % (len(result), fname))
602             for warn in result:
603                 print('\t', warn)
604             print
605         count += 1
606     print('Cleaned %d patches' % count)
607
608 def InsertCoverLetter(fname, series, count):
609     """Inserts a cover letter with the required info into patch 0
610
611     Args:
612         fname: Input / output filename of the cover letter file
613         series: Series object
614         count: Number of patches in the series
615     """
616     fd = open(fname, 'r')
617     lines = fd.readlines()
618     fd.close()
619
620     fd = open(fname, 'w')
621     text = series.cover
622     prefix = series.GetPatchPrefix()
623     for line in lines:
624         if line.startswith('Subject:'):
625             # if more than 10 or 100 patches, it should say 00/xx, 000/xxx, etc
626             zero_repeat = int(math.log10(count)) + 1
627             zero = '0' * zero_repeat
628             line = 'Subject: [%s %s/%d] %s\n' % (prefix, zero, count, text[0])
629
630         # Insert our cover letter
631         elif line.startswith('*** BLURB HERE ***'):
632             # First the blurb test
633             line = '\n'.join(text[1:]) + '\n'
634             if series.get('notes'):
635                 line += '\n'.join(series.notes) + '\n'
636
637             # Now the change list
638             out = series.MakeChangeLog(None)
639             line += '\n' + '\n'.join(out)
640         fd.write(line)
641     fd.close()