Rietveld Code Review Tool
Help | Bug tracker | Discussion group | Source code

Delta Between Two Patch Sets: sitescripts/hg/bin/update_issues.py

Issue 29339623: Issue 3681 - Add suport for "Fixes XXXX - ..." commit messages (Closed)
Left Patch Set: Split into two hooks to handle the master bookmark, move documentation to README, add tests Created April 24, 2016, 9:33 p.m.
Right Patch Set: Fix Strunk+White violations Created May 18, 2016, 8:29 a.m.
Left:
Right:
Use n/p to move between diff chunks; N/P to move between comments.
Jump to:
Left: Side by side diff | Download
Right: Side by side diff | Download
« no previous file with change/comment | « sitescripts/hg/README.md ('k') | sitescripts/hg/template/issue_commit_comment.tmpl » ('j') | no next file with change/comment »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
LEFTRIGHT
1 # This file is part of Adblock Plus <https://adblockplus.org/>, 1 # This file is part of Adblock Plus <https://adblockplus.org/>,
2 # Copyright (C) 2006-2016 Eyeo GmbH 2 # Copyright (C) 2006-2016 Eyeo GmbH
3 # 3 #
4 # Adblock Plus is free software: you can redistribute it and/or modify 4 # Adblock Plus is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License version 3 as 5 # it under the terms of the GNU General Public License version 3 as
6 # published by the Free Software Foundation. 6 # published by the Free Software Foundation.
7 # 7 #
8 # Adblock Plus is distributed in the hope that it will be useful, 8 # Adblock Plus is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # GNU General Public License for more details. 11 # GNU General Public License for more details.
12 # 12 #
13 # You should have received a copy of the GNU General Public License 13 # You should have received a copy of the GNU General Public License
14 # along with Adblock Plus. If not, see <http://www.gnu.org/licenses/>. 14 # along with Adblock Plus. If not, see <http://www.gnu.org/licenses/>.
15 15
16 """Hooks for integrating Mercurial with Trac. 16 """Hooks for integrating Mercurial with Trac.
17 17
18 Update the issues that are referenced in commit messages when the commits 18 Update the issues that are referenced in commit messages when the commits
19 are pushed and and "master" bookmark is moved. See README.md for more 19 are pushed and `master` bookmark is moved. See README.md for more
20 information on behavior and configuration. 20 information on behavior and configuration.
21 """ 21 """
22 22
23 import collections 23 import collections
24 import contextlib 24 import contextlib
25 import posixpath 25 import posixpath
26 import re 26 import re
27 import xmlrpclib 27 import xmlrpclib
28 28
29 from sitescripts.utils import get_config, get_template 29 from sitescripts.utils import get_config, get_template
30 30
31 31
32 _IssueRef = collections.namedtuple('IssueRef', 'id commits is_fixed') 32 _IssueRef = collections.namedtuple('IssueRef', 'id commits is_fixed')
33
34 ISSUE_NUMBER_REGEX = re.compile(r'\b(issue|fixes)\s+(\d+)\b', re.I)
35 NOISSUE_REGEX = re.compile(r'^noissue\b', re.I)
36 COMMIT_MESSAGE_REGEX = re.compile(r'\[\S+\ ([^\]]+)\]')
37
33 38
34 @contextlib.contextmanager 39 @contextlib.contextmanager
kzar 2016/05/02 09:29:41 I'm learning some new tricks here :)
Vasily Kuznetsov 2016/05/02 16:54:04 Yeah, this is a pretty cool way to make context ma
35 def _trac_proxy(ui, config, action_descr): 40 def _trac_proxy(ui, config, action_descr):
36 trac_url = config.get('hg', 'trac_xmlrpc_url') 41 trac_url = config.get('hg', 'trac_xmlrpc_url')
37 try: 42 try:
38 yield xmlrpclib.ServerProxy(trac_url) 43 yield xmlrpclib.ServerProxy(trac_url)
39 except Exception as exc: 44 except Exception as exc:
40 if getattr(exc, 'faultCode', 0) == 404: # Not found 45 if getattr(exc, 'faultCode', 0) == 404:
kzar 2016/05/02 09:29:41 Nit: This comment doesn't really add anything.
Vasily Kuznetsov 2016/05/02 16:54:03 Fair enough, I'll remove it.
41 ui.warn('warning: 404 (not found) while {}\n'.format(action_descr)) 46 ui.warn('warning: 404 (not found) while {}\n'.format(action_descr))
42 else: 47 else:
43 ui.warn('error: {} while {}\n'.format(exc, action_descr)) 48 ui.warn('error: {} while {}\n'.format(exc, action_descr))
44 49
45 50
46 def _update_issue(ui, config, issue_id, comment, changes): 51 def _update_issue(ui, config, issue_id, changes, comment=''):
47 issue_url_template = config.get('hg', 'issue_url_template') 52 issue_url_template = config.get('hg', 'issue_url_template')
48 issue_url = issue_url_template.format(id=issue_id) 53 issue_url = issue_url_template.format(id=issue_id)
49 54
50 updates = [] 55 updates = []
51 if comment: 56 if comment:
52 updates.append('posted comment') 57 for message in COMMIT_MESSAGE_REGEX.findall(comment):
kzar 2016/04/29 14:05:43 Supposing there's a "feature branch" bookmark that
Vasily Kuznetsov 2016/05/02 16:54:04 Unfortunately the commit messages didn't make it i
kzar 2016/05/03 06:47:35 Nice, looks good to me. Will mean I can even click
58 updates.append(' - referenced a commit: ' + message)
53 if 'milestone' in changes: 59 if 'milestone' in changes:
54 updates.append('set milestone: {}'.format(changes['milestone'])) 60 updates.append(' - set milestone to ' + changes['milestone'])
55 if changes['action'] == 'resolve': 61 if changes['action'] == 'resolve':
56 updates.append('closed') 62 updates.append(' - closed')
57 if not updates: 63 if not updates:
58 return 64 return
59 65
60 with _trac_proxy(ui, config, 'updating issue {}'.format(issue_id)) as tp: 66 with _trac_proxy(ui, config, 'updating issue {}'.format(issue_id)) as tp:
61 tp.ticket.update(issue_id, comment, changes, True) 67 tp.ticket.update(issue_id, comment, changes, True)
62 ui.status('updated {} ({})\n'.format(issue_url, ', '.join(updates))) 68 ui.status('updated {}:\n{}\n'.format(issue_url, '\n'.join(updates)))
63 69
64 70
65 def _post_comments(ui, repo, config, refs): 71 def _post_comments(ui, repo, config, refs):
66 repo_name = posixpath.split(repo.url())[1] 72 repo_name = posixpath.split(repo.url())[1]
67 template = get_template('hg/template/issue_commit_comment.tmpl', 73 template = get_template('hg/template/issue_commit_comment.tmpl',
68 autoescape=False) 74 autoescape=False)
69 for ref in refs: 75 for ref in refs:
70 comment_text = template.render({'repository_name': repo_name, 76 comment_text = template.render({'repository_name': repo_name,
71 'changes': ref.commits}) 77 'changes': ref.commits})
72 with _trac_proxy(ui, config, 'getting issue {}'.format(ref.id)) as tp: 78 with _trac_proxy(ui, config, 'getting issue {}'.format(ref.id)) as tp:
73 attrs = tp.ticket.get(ref.id)[3] 79 attrs = tp.ticket.get(ref.id)[3]
74 _update_issue(ui, config, ref.id, comment_text, 80 changes = {'_ts': attrs['_ts'], 'action': 'leave'}
75 {'_ts': attrs['_ts'], 'action': 'leave'}) 81 _update_issue(ui, config, ref.id, changes, comment_text)
76 82
77 83
78 def _compile_module_regexps(ui, config, modules): 84 def _compile_module_regexps(ui, config, modules):
79 for module, regexp in config.items('hg_module_milestones'): 85 for module, regexp in config.items('hg_module_milestones'):
80 try: 86 try:
81 yield module, re.compile('^' + regexp + '$', re.I) 87 yield module, re.compile('^{}$'.format(regexp), re.I)
82 except Exception as exc: 88 except Exception as exc:
83 ui.warn('warning: invalid module milestone regexp in config:' 89 ui.warn('warning: skipped invalid regexp for module {} in '
kzar 2016/05/02 09:29:40 This message looks pretty clear but maybe we could
Vasily Kuznetsov 2016/05/02 16:54:03 I suppose you meant "Skipped invalid regexp for MO
84 "'{}' ({})\n".format(regexp, exc)) 90 "[hg_module_milestones] config: '{}' ({})\n"
91 .format(module, regexp, exc))
85 92
86 93
87 def _get_module_milestones(ui, config, modules): 94 def _get_module_milestones(ui, config, modules):
88 module_regexps = dict(_compile_module_regexps(ui, config, modules)) 95 module_regexps = dict(_compile_module_regexps(ui, config, modules))
89 modules = module_regexps.keys() 96 modules = module_regexps.keys()
90 if not modules: 97 if not modules:
91 return [] 98 return []
92 99
93 milestones_by_module = {} 100 milestones_by_module = {}
94 with _trac_proxy(ui, config, 'getting milestones') as tp: 101 with _trac_proxy(ui, config, 'getting milestones') as tp:
95 milestone_names = [ 102 milestone_names = [
96 name for name in tp.ticket.milestone.getAll() 103 name for name in tp.ticket.milestone.getAll()
97 if any(regexp.search(name) for regexp in module_regexps.values()) 104 if any(regexp.search(name) for regexp in module_regexps.values())
98 ] 105 ]
99 # Using a MultiCall is better because we might have many 106 # Using a MultiCall is better because we might have many milestones.
100 # milestones. 107 get_milestones = xmlrpclib.MultiCall(tp)
101 multicall = xmlrpclib.MultiCall(tp)
102 for name in milestone_names: 108 for name in milestone_names:
103 multicall.ticket.milestone.get(name) 109 get_milestones.ticket.milestone.get(name)
104 milestones = filter(lambda m: not m['completed'], multicall()) 110 milestones = [ms for ms in get_milestones() if not ms['completed']]
kzar 2016/04/29 14:05:43 Nit: I've been told to use list comprehension inst
Vasily Kuznetsov 2016/05/02 16:54:04 Yep, it's definitely better here. I also renamed `
105 for module in modules: 111 for module in modules:
106 for milestone in milestones: 112 for milestone in milestones:
107 if module_regexps[module].search(milestone['name']): 113 if module_regexps[module].search(milestone['name']):
108 milestones_by_module[module] = milestone['name'] 114 milestones_by_module[module] = milestone['name']
109 break 115 break
110 116
111 return milestones_by_module.items() 117 return milestones_by_module.items()
112 118
113 119
114 def _declare_fixed(ui, config, refs): 120 def _declare_fixed(ui, config, refs):
(...skipping 14 matching lines...) Expand all
129 if not attrs['milestone']: 135 if not attrs['milestone']:
130 need_milestones[attrs['component']].append(changes) 136 need_milestones[attrs['component']].append(changes)
131 updates.append((ref.id, changes)) 137 updates.append((ref.id, changes))
132 138
133 for module, milestone in _get_module_milestones(ui, config, 139 for module, milestone in _get_module_milestones(ui, config,
134 need_milestones.keys()): 140 need_milestones.keys()):
135 for changes in need_milestones[module]: 141 for changes in need_milestones[module]:
136 changes['milestone'] = milestone 142 changes['milestone'] = milestone
137 143
138 for issue_id, changes in updates: 144 for issue_id, changes in updates:
139 _update_issue(ui, config, issue_id, '', changes) 145 _update_issue(ui, config, issue_id, changes)
kzar 2016/05/02 09:29:40 Since comment seems like the only optional paramet
Vasily Kuznetsov 2016/05/02 16:54:03 Done.
140 146
141 147
142 def _collect_references(ui, commits): 148 def _collect_references(ui, commits):
143 issue_number_regex = re.compile(r'\b(issue|fixes)\s+(\d+)\b', re.I)
kzar 2016/05/02 09:29:41 Maybe these regexps should be compiled as constant
Vasily Kuznetsov 2016/05/02 16:54:04 Actually it's not called in a loop, only once from
144 noissue_regex = re.compile(r'^noissue\b', re.I)
145
146 commits_by_issue = collections.defaultdict(list) 149 commits_by_issue = collections.defaultdict(list)
147 fixed_issues = set() 150 fixed_issues = set()
148 151
149 for commit in commits: 152 for commit in commits:
150 message = commit.description() 153 message = commit.description()
151 if ' - ' not in message: 154 if ' - ' not in message:
152 ui.warn("warning: invalid commit message format: '{}'\n" 155 ui.warn("warning: invalid commit message format: '{}'\n"
153 .format(message)) 156 .format(message))
154 continue 157 continue
155 158
156 refs, rest = message.split(" - ", 1) 159 refs, rest = message.split(' - ', 1)
157 issue_refs = issue_number_regex.findall(refs) 160 issue_refs = ISSUE_NUMBER_REGEX.findall(refs)
158 if issue_refs: 161 if issue_refs:
159 for ref_type, issue_id in issue_refs: 162 for ref_type, issue_id in issue_refs:
160 issue_id = int(issue_id) 163 issue_id = int(issue_id)
161 commits_by_issue[issue_id].append(commit) 164 commits_by_issue[issue_id].append(commit)
162 if ref_type.lower() == 'fixes': 165 if ref_type.lower() == 'fixes':
163 fixed_issues.add(issue_id) 166 fixed_issues.add(issue_id)
164 elif not noissue_regex.search(refs): 167 elif not NOISSUE_REGEX.search(refs):
165 ui.warn("warning: no issue reference in commit message: '{}'\n" 168 ui.warn("warning: no issue reference in commit message: '{}'\n"
166 .format(message)) 169 .format(message))
167 170
168 for issue_id, commits in sorted(commits_by_issue.items()): 171 for issue_id, commits in sorted(commits_by_issue.items()):
169 yield _IssueRef(issue_id, commits, is_fixed=issue_id in fixed_issues) 172 yield _IssueRef(issue_id, commits, is_fixed=issue_id in fixed_issues)
170 173
171 174
172 def changegroup_hook(ui, repo, node, **kwargs): 175 def changegroup_hook(ui, repo, node, **kwargs):
173 config = get_config() 176 config = get_config()
174 first_rev = repo[node].rev() 177 first_rev = repo[node].rev()
175 pushed_commits = repo[first_rev:] 178 pushed_commits = repo[first_rev:]
176 refs = _collect_references(ui, pushed_commits) 179 refs = _collect_references(ui, pushed_commits)
177 _post_comments(ui, repo, config, refs) 180 _post_comments(ui, repo, config, refs)
178 181
179 182
180 def pushkey_hook(ui, repo, **kwargs): 183 def pushkey_hook(ui, repo, **kwargs):
181 if kwargs['namespace'] != 'bookmarks': 184 if (kwargs['namespace'] != 'bookmarks' or # Not a bookmark move.
kzar 2016/04/29 14:05:43 Nit: Replace these three with one if?
Vasily Kuznetsov 2016/05/02 16:54:03 Done.
182 return # Not a bookmark move. 185 kwargs['key'] != 'master' or # Not `master` bookmark.
183 if kwargs['key'] != 'master': 186 not kwargs['old']): # The bookmark is just created.
184 return # Not master bookmark. 187 return
185 if not kwargs['old']:
186 return # The bookmark is just created -- don't do anything.
187 188
188 config = get_config() 189 config = get_config()
189 old_master_rev = repo[kwargs['old']].rev() 190 old_master_rev = repo[kwargs['old']].rev()
190 new_master_rev = repo[kwargs['new']].rev() 191 new_master_rev = repo[kwargs['new']].rev()
191 added_revs = repo.changelog.findmissingrevs([old_master_rev], 192 added_revs = repo.changelog.findmissingrevs([old_master_rev],
192 [new_master_rev]) 193 [new_master_rev])
193 added_commits = [repo[rev] for rev in added_revs] 194 added_commits = [repo[rev] for rev in added_revs]
194 refs = [ref for ref in _collect_references(ui, added_commits) 195 refs = [ref for ref in _collect_references(ui, added_commits)
195 if ref.is_fixed] 196 if ref.is_fixed]
196 _declare_fixed(ui, config, refs) 197 _declare_fixed(ui, config, refs)
197 198
198 199
199 # Alias for backward compatibility. 200 # Alias for backward compatibility.
200 hook = changegroup_hook 201 hook = changegroup_hook
LEFTRIGHT

Powered by Google App Engine
This is Rietveld