Left: | ||
Right: |
OLD | NEW |
---|---|
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 | |
OLD | NEW |