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

Side by Side Diff: sitescripts/hg/bin/update_issues.py

Issue 29339623: Issue 3681 - Add suport for "Fixes XXXX - ..." commit messages (Closed)
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.
Left:
Right:
Use n/p to move between diff chunks; N/P to move between comments.
Jump to:
View unified diff | Download patch
OLDNEW
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 """ 16 """Hooks for integrating Mercurial with Trac.
17 This module implements a changegroup (or pretxnchangegroup) hook that inspects 17
18 all commit messages and checks if issues from the Adblock Plus issue tracker are 18 Update the issues that are referenced in commit messages when the commits
19 being referenced. If there are, it updates them with the respective changeset 19 are pushed and and "master" bookmark is moved. See README.md for more
20 URLs. 20 information on behavior and configuration.
21 """ 21 """
22 22
23 import collections
24 import contextlib
23 import posixpath 25 import posixpath
24 import re 26 import re
25 import xmlrpclib 27 import xmlrpclib
26 28
27 from sitescripts.utils import get_config, get_template 29 from sitescripts.utils import get_config, get_template
28 30
29 31
30 def _generate_comments(repository_name, changes_by_issue): 32 _IssueRef = collections.namedtuple('IssueRef', 'id commits is_fixed')
31 comments = {} 33
32 template = get_template("hg/template/issue_commit_comment.tmpl", 34 @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
33 autoescape=False) 35 def _trac_proxy(ui, config, action_descr):
34 for issue_id, changes in changes_by_issue.iteritems(): 36 trac_url = config.get('hg', 'trac_xmlrpc_url')
35 comments[issue_id] = template.render({"repository_name": repository_name , 37 try:
36 "changes": changes}) 38 yield xmlrpclib.ServerProxy(trac_url)
37 return comments 39 except Exception as exc:
40 if getattr(exc, 'faultCode', 0) == 404: # Not found
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))
42 else:
43 ui.warn('error: {} while {}\n'.format(exc, action_descr))
38 44
39 45
40 def _post_comment(issue_id, comment): 46 def _update_issue(ui, config, issue_id, comment, changes):
41 issue_id = int(issue_id) 47 issue_url_template = config.get('hg', 'issue_url_template')
42 url = get_config().get("hg", "trac_xmlrpc_url") 48 issue_url = issue_url_template.format(id=issue_id)
43 server = xmlrpclib.ServerProxy(url) 49
44 attributes = server.ticket.get(issue_id)[3] 50 updates = []
45 server.ticket.update(issue_id, comment, 51 if comment:
46 {"_ts": attributes["_ts"], "action": "leave"}, True) 52 updates.append('posted 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
53 if 'milestone' in changes:
54 updates.append('set milestone: {}'.format(changes['milestone']))
55 if changes['action'] == 'resolve':
56 updates.append('closed')
57 if not updates:
58 return
59
60 with _trac_proxy(ui, config, 'updating issue {}'.format(issue_id)) as tp:
61 tp.ticket.update(issue_id, comment, changes, True)
62 ui.status('updated {} ({})\n'.format(issue_url, ', '.join(updates)))
47 63
48 64
49 def hook(ui, repo, node, **kwargs): 65 def _post_comments(ui, repo, config, refs):
50 first_change = repo[node] 66 repo_name = posixpath.split(repo.url())[1]
51 issue_number_regex = re.compile(r"\bissue\s+(\d+)\b", re.I) 67 template = get_template('hg/template/issue_commit_comment.tmpl',
52 noissue_regex = re.compile(r"^noissue\b", re.I) 68 autoescape=False)
53 changes_by_issue = {} 69 for ref in refs:
54 for revision in xrange(first_change.rev(), len(repo)): 70 comment_text = template.render({'repository_name': repo_name,
55 change = repo[revision] 71 'changes': ref.commits})
56 description = change.description() 72 with _trac_proxy(ui, config, 'getting issue {}'.format(ref.id)) as tp:
57 issue_ids = issue_number_regex.findall(description) 73 attrs = tp.ticket.get(ref.id)[3]
58 if issue_ids: 74 _update_issue(ui, config, ref.id, comment_text,
59 for issue_id in issue_ids: 75 {'_ts': attrs['_ts'], 'action': 'leave'})
60 changes_by_issue.setdefault(issue_id, []).append(change)
61 elif not noissue_regex.search(description):
62 # We should just reject all changes when one of them has an invalid
63 # commit message format, see: https://issues.adblockplus.org/ticket/ 3679
64 ui.warn("warning: invalid commit message format in changeset %s\n" %
65 change)
66 76
67 repository_name = posixpath.split(repo.url())[1]
68 comments = _generate_comments(repository_name, changes_by_issue)
69 77
70 issue_url_template = get_config().get("hg", "issue_url_template") 78 def _compile_module_regexps(ui, config, modules):
71 for issue_id, comment in comments.iteritems(): 79 for module, regexp in config.items('hg_module_milestones'):
72 try: 80 try:
73 _post_comment(issue_id, comment) 81 yield module, re.compile('^' + regexp + '$', re.I)
74 ui.status("updating %s\n" % issue_url_template.format(id=issue_id)) 82 except Exception as exc:
75 except: 83 ui.warn('warning: invalid module milestone regexp in config:'
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
76 ui.warn("warning: failed to update %s\n" % 84 "'{}' ({})\n".format(regexp, exc))
77 issue_url_template.format(id=issue_id)) 85
86
87 def _get_module_milestones(ui, config, modules):
88 module_regexps = dict(_compile_module_regexps(ui, config, modules))
89 modules = module_regexps.keys()
90 if not modules:
91 return []
92
93 milestones_by_module = {}
94 with _trac_proxy(ui, config, 'getting milestones') as tp:
95 milestone_names = [
96 name for name in tp.ticket.milestone.getAll()
97 if any(regexp.search(name) for regexp in module_regexps.values())
98 ]
99 # Using a MultiCall is better because we might have many
100 # milestones.
101 multicall = xmlrpclib.MultiCall(tp)
102 for name in milestone_names:
103 multicall.ticket.milestone.get(name)
104 milestones = filter(lambda m: not m['completed'], multicall())
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:
106 for milestone in milestones:
107 if module_regexps[module].search(milestone['name']):
108 milestones_by_module[module] = milestone['name']
109 break
110
111 return milestones_by_module.items()
112
113
114 def _declare_fixed(ui, config, refs):
115 updates = []
116 # Changes that need milestones added to them, indexed by module.
117 need_milestones = collections.defaultdict(list)
118
119 for ref in refs:
120 with _trac_proxy(ui, config, 'getting issue {}'.format(ref.id)) as tp:
121 attrs = tp.ticket.get(ref.id)[3]
122 changes = {
123 '_ts': attrs['_ts'],
124 'action': 'leave'
125 }
126 actions = tp.ticket.getActions(ref.id)
127 if any(action[0] == 'resolve' for action in actions):
128 changes['action'] = 'resolve'
129 if not attrs['milestone']:
130 need_milestones[attrs['component']].append(changes)
131 updates.append((ref.id, changes))
132
133 for module, milestone in _get_module_milestones(ui, config,
134 need_milestones.keys()):
135 for changes in need_milestones[module]:
136 changes['milestone'] = milestone
137
138 for issue_id, changes in updates:
139 _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
141
142 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)
147 fixed_issues = set()
148
149 for commit in commits:
150 message = commit.description()
151 if ' - ' not in message:
152 ui.warn("warning: invalid commit message format: '{}'\n"
153 .format(message))
154 continue
155
156 refs, rest = message.split(" - ", 1)
157 issue_refs = issue_number_regex.findall(refs)
158 if issue_refs:
159 for ref_type, issue_id in issue_refs:
160 issue_id = int(issue_id)
161 commits_by_issue[issue_id].append(commit)
162 if ref_type.lower() == 'fixes':
163 fixed_issues.add(issue_id)
164 elif not noissue_regex.search(refs):
165 ui.warn("warning: no issue reference in commit message: '{}'\n"
166 .format(message))
167
168 for issue_id, commits in sorted(commits_by_issue.items()):
169 yield _IssueRef(issue_id, commits, is_fixed=issue_id in fixed_issues)
170
171
172 def changegroup_hook(ui, repo, node, **kwargs):
173 config = get_config()
174 first_rev = repo[node].rev()
175 pushed_commits = repo[first_rev:]
176 refs = _collect_references(ui, pushed_commits)
177 _post_comments(ui, repo, config, refs)
178
179
180 def pushkey_hook(ui, repo, **kwargs):
181 if kwargs['namespace'] != 'bookmarks':
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.
183 if kwargs['key'] != 'master':
184 return # Not master bookmark.
185 if not kwargs['old']:
186 return # The bookmark is just created -- don't do anything.
187
188 config = get_config()
189 old_master_rev = repo[kwargs['old']].rev()
190 new_master_rev = repo[kwargs['new']].rev()
191 added_revs = repo.changelog.findmissingrevs([old_master_rev],
192 [new_master_rev])
193 added_commits = [repo[rev] for rev in added_revs]
194 refs = [ref for ref in _collect_references(ui, added_commits)
195 if ref.is_fixed]
196 _declare_fixed(ui, config, refs)
197
198
199 # Alias for backward compatibility.
200 hook = changegroup_hook
OLDNEW

Powered by Google App Engine
This is Rietveld