blob: a41cdb59dee5084dce6096a9038ea476f5c6359f [file] [log] [blame]
luqui@chromium.org0b887622014-09-03 02:31:03 +00001#!/usr/bin/env python
2# Copyright 2014 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6import argparse
martiniss@chromium.org456ca7f2016-05-23 21:33:28 +00007import json
luqui@chromium.org0b887622014-09-03 02:31:03 +00008import re
9import sys
10
11from collections import defaultdict
12
13import git_common as git
14
agable@chromium.orgd629fb42014-10-01 09:40:10 +000015
Andrii Shyshkalov80cae422017-04-27 01:01:42 +020016FOOTER_PATTERN = re.compile(r'^\s*([\w-]+): *(.*)$')
Andrii Shyshkalov49fe9222016-12-15 11:05:06 +010017CHROME_COMMIT_POSITION_PATTERN = re.compile(r'^([\w/\-\.]+)@{#(\d+)}$')
luqui@chromium.org0b887622014-09-03 02:31:03 +000018
agable@chromium.orgd629fb42014-10-01 09:40:10 +000019
luqui@chromium.org0b887622014-09-03 02:31:03 +000020def normalize_name(header):
21 return '-'.join([ word.title() for word in header.strip().split('-') ])
22
23
24def parse_footer(line):
tandrii@chromium.orgf2aa52b2016-06-03 12:58:20 +000025 """Returns footer's (key, value) if footer is valid, else None."""
luqui@chromium.org0b887622014-09-03 02:31:03 +000026 match = FOOTER_PATTERN.match(line)
27 if match:
28 return (match.group(1), match.group(2))
29 else:
30 return None
31
32
33def parse_footers(message):
34 """Parses a git commit message into a multimap of footers."""
tandrii@chromium.orgf2aa52b2016-06-03 12:58:20 +000035 _, _, parsed_footers = split_footers(message)
36 footer_map = defaultdict(list)
37 if parsed_footers:
38 # Read footers from bottom to top, because latter takes precedense,
39 # and we want it to be first in the multimap value.
40 for (k, v) in reversed(parsed_footers):
41 footer_map[normalize_name(k)].append(v.strip())
42 return footer_map
43
44
45def split_footers(message):
46 """Returns (non_footer_lines, footer_lines, parsed footers).
47
48 Guarantees that:
49 (non_footer_lines + footer_lines) == message.splitlines().
50 parsed_footers is parse_footer applied on each line of footer_lines.
51 """
52 message_lines = list(message.splitlines())
luqui@chromium.org0b887622014-09-03 02:31:03 +000053 footer_lines = []
tandrii@chromium.orgf2aa52b2016-06-03 12:58:20 +000054 for line in reversed(message_lines):
luqui@chromium.org0b887622014-09-03 02:31:03 +000055 if line == '' or line.isspace():
56 break
57 footer_lines.append(line)
tandrii@chromium.orgf2aa52b2016-06-03 12:58:20 +000058 else:
59 # The whole description was consisting of footers,
60 # which means those aren't footers.
61 footer_lines = []
luqui@chromium.org0b887622014-09-03 02:31:03 +000062
tandrii@chromium.orgf2aa52b2016-06-03 12:58:20 +000063 footer_lines.reverse()
luqui@chromium.org0b887622014-09-03 02:31:03 +000064 footers = map(parse_footer, footer_lines)
tandrii@chromium.orgf2aa52b2016-06-03 12:58:20 +000065 if not footer_lines or not all(footers):
66 return message_lines, [], []
67 return message_lines[:-len(footer_lines)], footer_lines, footers
luqui@chromium.org0b887622014-09-03 02:31:03 +000068
69
tandrii@chromium.org3c3c0342016-03-04 11:59:28 +000070def get_footer_change_id(message):
71 """Returns a list of Gerrit's ChangeId from given commit message."""
72 return parse_footers(message).get(normalize_name('Change-Id'), [])
73
74
75def add_footer_change_id(message, change_id):
76 """Returns message with Change-ID footer in it.
77
tandrii@chromium.orgf2aa52b2016-06-03 12:58:20 +000078 Assumes that Change-Id is not yet in footers, which is then inserted at
79 earliest footer line which is after all of these footers:
80 Bug|Issue|Test|Feature.
tandrii@chromium.org3c3c0342016-03-04 11:59:28 +000081 """
tandrii@chromium.orgf2aa52b2016-06-03 12:58:20 +000082 assert 'Change-Id' not in parse_footers(message)
83 return add_footer(message, 'Change-Id', change_id,
84 after_keys=['Bug', 'Issue', 'Test', 'Feature'])
85
Andrii Shyshkalov18975322017-01-25 16:44:13 +010086
tandrii@chromium.orgf2aa52b2016-06-03 12:58:20 +000087def add_footer(message, key, value, after_keys=None):
88 """Returns a message with given footer appended.
89
90 If after_keys is None (default), appends footer last.
91 Otherwise, after_keys must be iterable of footer keys, then the new footer
92 would be inserted at the topmost position such there would be no footer lines
93 after it with key matching one of after_keys.
94 For example, given
95 message='Header.\n\nAdded: 2016\nBug: 123\nVerified-By: CQ'
96 after_keys=['Bug', 'Issue']
97 the new footer will be inserted between Bug and Verified-By existing footers.
98 """
99 assert key == normalize_name(key), 'Use normalized key'
100 new_footer = '%s: %s' % (key, value)
101
102 top_lines, footer_lines, parsed_footers = split_footers(message)
103 if not footer_lines:
104 if not top_lines or top_lines[-1] != '':
105 top_lines.append('')
106 footer_lines = [new_footer]
107 elif not after_keys:
108 footer_lines.append(new_footer)
tandrii@chromium.org9fc50db2016-03-17 12:38:55 +0000109 else:
tandrii@chromium.orgf2aa52b2016-06-03 12:58:20 +0000110 after_keys = set(map(normalize_name, after_keys))
111 # Iterate from last to first footer till we find the footer keys above.
112 for i, (key, _) in reversed(list(enumerate(parsed_footers))):
113 if normalize_name(key) in after_keys:
114 footer_lines.insert(i + 1, new_footer)
tandrii@chromium.org3c3c0342016-03-04 11:59:28 +0000115 break
116 else:
tandrii@chromium.orgf2aa52b2016-06-03 12:58:20 +0000117 footer_lines.insert(0, new_footer)
118 return '\n'.join(top_lines + footer_lines)
tandrii@chromium.org3c3c0342016-03-04 11:59:28 +0000119
120
luqui@chromium.org0b887622014-09-03 02:31:03 +0000121def get_unique(footers, key):
122 key = normalize_name(key)
123 values = footers[key]
124 assert len(values) <= 1, 'Multiple %s footers' % key
125 if values:
126 return values[0]
127 else:
128 return None
129
130
131def get_position(footers):
iannucci@chromium.org74c44f62014-09-09 22:35:03 +0000132 """Get the commit position from the footers multimap using a heuristic.
luqui@chromium.org0b887622014-09-03 02:31:03 +0000133
134 Returns:
135 A tuple of the branch and the position on that branch. For example,
136
137 Cr-Commit-Position: refs/heads/master@{#292272}
138
agable814b1ca2016-12-21 13:05:59 -0800139 would give the return value ('refs/heads/master', 292272).
luqui@chromium.org0b887622014-09-03 02:31:03 +0000140 """
141
142 position = get_unique(footers, 'Cr-Commit-Position')
143 if position:
144 match = CHROME_COMMIT_POSITION_PATTERN.match(position)
145 assert match, 'Invalid Cr-Commit-Position value: %s' % position
146 return (match.group(1), match.group(2))
147
luqui@chromium.org0b887622014-09-03 02:31:03 +0000148 raise ValueError('Unable to infer commit position from footers')
149
150
151def main(args):
152 parser = argparse.ArgumentParser(
153 formatter_class=argparse.ArgumentDefaultsHelpFormatter
154 )
martiniss@chromium.org456ca7f2016-05-23 21:33:28 +0000155 parser.add_argument('ref', nargs='?', help="Git ref to retrieve footers from."
156 " Omit to parse stdin.")
luqui@chromium.org0b887622014-09-03 02:31:03 +0000157
158 g = parser.add_mutually_exclusive_group()
159 g.add_argument('--key', metavar='KEY',
160 help='Get all values for the given footer name, one per '
161 'line (case insensitive)')
162 g.add_argument('--position', action='store_true')
163 g.add_argument('--position-ref', action='store_true')
164 g.add_argument('--position-num', action='store_true')
martiniss@chromium.org456ca7f2016-05-23 21:33:28 +0000165 g.add_argument('--json', help="filename to dump JSON serialized headers to.")
luqui@chromium.org0b887622014-09-03 02:31:03 +0000166
luqui@chromium.org0b887622014-09-03 02:31:03 +0000167 opts = parser.parse_args(args)
168
martiniss@chromium.org456ca7f2016-05-23 21:33:28 +0000169 if opts.ref:
170 message = git.run('log', '-1', '--format=%B', opts.ref)
171 else:
172 message = '\n'.join(l for l in sys.stdin)
173
luqui@chromium.org0b887622014-09-03 02:31:03 +0000174 footers = parse_footers(message)
175
176 if opts.key:
177 for v in footers.get(normalize_name(opts.key), []):
178 print v
179 elif opts.position:
180 pos = get_position(footers)
181 print '%s@{#%s}' % (pos[0], pos[1] or '?')
182 elif opts.position_ref:
183 print get_position(footers)[0]
184 elif opts.position_num:
185 pos = get_position(footers)
186 assert pos[1], 'No valid position for commit'
187 print pos[1]
martiniss@chromium.org456ca7f2016-05-23 21:33:28 +0000188 elif opts.json:
189 with open(opts.json, 'w') as f:
190 json.dump(footers, f)
luqui@chromium.org0b887622014-09-03 02:31:03 +0000191 else:
192 for k in footers.keys():
193 for v in footers[k]:
194 print '%s: %s' % (k, v)
sbc@chromium.org013731e2015-02-26 18:28:43 +0000195 return 0
luqui@chromium.org0b887622014-09-03 02:31:03 +0000196
197
198if __name__ == '__main__':
sbc@chromium.org013731e2015-02-26 18:28:43 +0000199 try:
200 sys.exit(main(sys.argv[1:]))
201 except KeyboardInterrupt:
202 sys.stderr.write('interrupted\n')
203 sys.exit(1)