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: Created April 8, 2016, 7:32 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 """Hook for integrating Mercurial with Trac. 16 """Hooks for integrating Mercurial with Trac.
kzar 2016/04/18 14:48:30 Mind putting this documentation in a README (sites
Vasily Kuznetsov 2016/04/24 21:49:50 Done.
17 17
18 The function called `hook` in this module should be installed as `changegroup` 18 Update the issues that are referenced in commit messages when the commits
19 or `pretxnchangegroup` in target Mercurial repositories. It will recognise 19 are pushed and `master` bookmark is moved. See README.md for more
20 issue references in commit messages and update referenced issues in Adblock 20 information on behavior and configuration.
21 Plus issue tracker.
22
23 The canonical format of the commit messages is "ISSUE-REFERENCE - MESSAGE"
24 where ISSUE-REFERENCE is one of "Noissue", "Issue NUMBER" or "Fixes NUMBER".
25 Several "Issue" and "Fixes" references can be present in the same commit
26 message. Such commit will affect all the referenced issues.
27
28 For "Issue" references a comment is posted into the referenced issue informing
29 that a related commit has landed and providing a link to the commit.
30
31 For "Fixes" references, in addition to posting the comment, the issue will be
32 closed (unless it's already closed) and a module-dependent milestone will be
33 assigned to it if configured and unless the issue already has a milestone.
kzar 2016/04/18 14:48:30 We should also avoid assigning milestones and clos
Vasily Kuznetsov 2016/04/24 21:49:50 Oh damn! I missed this in the original code. Now t
34
35 # Configuration
36
37 This hook is configured via `sitescripts.ini` using [hg] and
38 [hg_module_milestones] sections.
39
40 `track_xmlrpc_url` key from [hg] is used to determine the address of XMLRPC
41 interface of Trac and `issue_url_template` as a template for producing links to
42 the referenced issues that are displayed in the log.
43
44 The keys of [hg_module_milestones] section are module names and the values are
45 corresponding milestone regular expressions. The first open milestone that
46 matches the regular expression of issue's module will be assigned to the issue
47 when a commit fixing it arrives.
48 """ 21 """
49 22
50 import collections 23 import collections
24 import contextlib
51 import posixpath 25 import posixpath
52 import re 26 import re
53 import xmlrpclib 27 import xmlrpclib
54 28
55 from sitescripts.utils import get_config, get_template 29 from sitescripts.utils import get_config, get_template
56 30
57 class _Update(object): 31
kzar 2016/04/18 14:48:30 I guess I don't understand the need for this class
Vasily Kuznetsov 2016/04/24 21:49:50 Tried to make the code below prettier/more readabl
58 def __init__(self, issue_id, commits, is_fixed): 32 _IssueRef = collections.namedtuple('IssueRef', 'id commits is_fixed')
59 self.issue_id = issue_id 33
60 self.commits = commits 34 ISSUE_NUMBER_REGEX = re.compile(r'\b(issue|fixes)\s+(\d+)\b', re.I)
61 self.is_fixed = is_fixed 35 NOISSUE_REGEX = re.compile(r'^noissue\b', re.I)
62 36 COMMIT_MESSAGE_REGEX = re.compile(r'\[\S+\ ([^\]]+)\]')
63 def _create_issue_updates(ui, repo, node): 37
64 issue_number_regex = re.compile(r"\b(issue|fixes)\s+(\d+)\b", re.I) 38
65 noissue_regex = re.compile(r"^noissue\b", re.I) 39 @contextlib.contextmanager
66 commits_by_issue = collections.defaultdict(list) 40 def _trac_proxy(ui, config, action_descr):
67 fixed_issues = set() 41 trac_url = config.get('hg', 'trac_xmlrpc_url')
68 first_rev = repo[node].rev()
69 for change in repo[first_rev:]:
70 message = change.description()
71 if ' - ' not in message:
72 ui.warn("warning: invalid commit message format: '{}'\n".format(message))
73 continue
74 refs, rest = message.split(' - ', 1)
75 issue_refs = issue_number_regex.findall(refs)
76 if issue_refs:
77 for ref_type, issue_id in issue_refs:
78 issue_id = int(issue_id)
79 commits_by_issue[issue_id].append(change)
80 if ref_type.lower() == 'fixes':
81 fixed_issues.add(issue_id)
82 elif not noissue_regex.search(refs):
83 ui.warn("warning: no issue reference in commit message: '{}'\n"
84 .format(message))
85 for issue_id, commits in sorted(commits_by_issue.items()):
86 yield _Update(issue_id, commits, is_fixed=issue_id in fixed_issues)
87
88 def _prepare_changes(ui, trac, updates):
89 for update in updates:
90 try: 42 try:
91 update.issue_attrs = trac.ticket.get(update.issue_id)[3] 43 yield xmlrpclib.ServerProxy(trac_url)
92 update.changes = {"_ts": update.issue_attrs["_ts"], "action": "leave"}
93 if update.is_fixed:
94 actions = trac.ticket.getActions(update.issue_id)
95 if any(action[0] == "resolve" for action in actions):
96 update.changes["action"] = "resolve"
97 yield update
98 except Exception as exc: 44 except Exception as exc:
99 if getattr(exc, 'faultCode', 0) == 404: # Not found 45 if getattr(exc, 'faultCode', 0) == 404:
100 ui.warn("warning: reference to a non-existent issue: {}\n" 46 ui.warn('warning: 404 (not found) while {}\n'.format(action_descr))
101 .format(update.issue_id)) 47 else:
102 else: 48 ui.warn('error: {} while {}\n'.format(exc, action_descr))
103 ui.warn("warning: error while getting issue {}: {}\n" 49
104 .format(update.issue_id, exc)) 50
51 def _update_issue(ui, config, issue_id, changes, comment=''):
52 issue_url_template = config.get('hg', 'issue_url_template')
53 issue_url = issue_url_template.format(id=issue_id)
54
55 updates = []
56 if comment:
57 for message in COMMIT_MESSAGE_REGEX.findall(comment):
58 updates.append(' - referenced a commit: ' + message)
59 if 'milestone' in changes:
60 updates.append(' - set milestone to ' + changes['milestone'])
61 if changes['action'] == 'resolve':
62 updates.append(' - closed')
63 if not updates:
64 return
65
66 with _trac_proxy(ui, config, 'updating issue {}'.format(issue_id)) as tp:
67 tp.ticket.update(issue_id, comment, changes, True)
68 ui.status('updated {}:\n{}\n'.format(issue_url, '\n'.join(updates)))
69
70
71 def _post_comments(ui, repo, config, refs):
72 repo_name = posixpath.split(repo.url())[1]
73 template = get_template('hg/template/issue_commit_comment.tmpl',
74 autoescape=False)
75 for ref in refs:
76 comment_text = template.render({'repository_name': repo_name,
77 'changes': ref.commits})
78 with _trac_proxy(ui, config, 'getting issue {}'.format(ref.id)) as tp:
79 attrs = tp.ticket.get(ref.id)[3]
80 changes = {'_ts': attrs['_ts'], 'action': 'leave'}
81 _update_issue(ui, config, ref.id, changes, comment_text)
82
105 83
106 def _compile_module_regexps(ui, config, modules): 84 def _compile_module_regexps(ui, config, modules):
107 for module, regexp in config.items("hg_module_milestones"): 85 for module, regexp in config.items('hg_module_milestones'):
108 try: 86 try:
109 yield module, re.compile("^" + regexp + "$", re.I) 87 yield module, re.compile('^{}$'.format(regexp), re.I)
kzar 2016/04/18 14:48:30 I don't think this regexp should be case-insensiti
Vasily Kuznetsov 2016/04/24 21:49:50 My reasoning was that we want to be more forgiving
kzar 2016/04/29 14:05:42 Well the issue doesn't say the regexp here is matc
Vasily Kuznetsov 2016/05/02 16:54:02 I changed the README and the issue to reflect the
110 except Exception as exc: 88 except Exception as exc:
111 ui.warn("warning: invalid module milestone regexp in config: '{}' ({})\n" 89 ui.warn('warning: skipped invalid regexp for module {} in '
112 .format(regexp, exc)) 90 "[hg_module_milestones] config: '{}' ({})\n"
113 91 .format(module, regexp, exc))
114 def _get_milestone_by_module(ui, config, trac, updates): 92
115 modules = {update.issue_attrs["component"] for update in updates 93
116 if update.is_fixed and not update.issue_attrs["milestone"]} 94 def _get_module_milestones(ui, config, modules):
117 module_regexps = list(_compile_module_regexps(ui, config, modules)) 95 module_regexps = dict(_compile_module_regexps(ui, config, modules))
118 96 modules = module_regexps.keys()
119 def get_milestone_module(milestone_name): 97 if not modules:
120 for module, regex in module_regexps: 98 return []
121 if regex.search(milestone_name): 99
122 return module 100 milestones_by_module = {}
123 return None 101 with _trac_proxy(ui, config, 'getting milestones') as tp:
124 102 milestone_names = [
125 milestones_by_module = collections.defaultdict(str) 103 name for name in tp.ticket.milestone.getAll()
126 if modules & {module for module, regexp in module_regexps}: 104 if any(regexp.search(name) for regexp in module_regexps.values())
127 try: 105 ]
128 milestone_names = filter(get_milestone_module, 106 # Using a MultiCall is better because we might have many milestones.
129 trac.ticket.milestone.getAll()) 107 get_milestones = xmlrpclib.MultiCall(tp)
130 # Using a MultiCall is better because we might have many milestones. 108 for name in milestone_names:
131 multicall = xmlrpclib.MultiCall(trac) 109 get_milestones.ticket.milestone.get(name)
132 for name in milestone_names: 110 milestones = [ms for ms in get_milestones() if not ms['completed']]
133 multicall.ticket.milestone.get(name) 111 for module in modules:
134 milestones = multicall() 112 for milestone in milestones:
135 except Exception as exc: 113 if module_regexps[module].search(milestone['name']):
136 ui.warn("warning: unable to get milestones from trac: {}\n".format(exc)) 114 milestones_by_module[module] = milestone['name']
137 else: 115 break
138 for milestone in milestones: 116
139 if not milestone["completed"]: 117 return milestones_by_module.items()
140 module = get_milestone_module(milestone["name"]) 118
141 if module not in milestones_by_module: 119
142 milestones_by_module[module] = milestone["name"] 120 def _declare_fixed(ui, config, refs):
143 return milestones_by_module 121 updates = []
144 122 # Changes that need milestones added to them, indexed by module.
145 def _assign_milestones(ui, config, trac, updates): 123 need_milestones = collections.defaultdict(list)
146 updates = list(updates) 124
147 milestones_by_module = _get_milestone_by_module(ui, config, trac, updates) 125 for ref in refs:
148 for update in updates: 126 with _trac_proxy(ui, config, 'getting issue {}'.format(ref.id)) as tp:
149 if update.is_fixed and not update.issue_attrs["milestone"]: 127 attrs = tp.ticket.get(ref.id)[3]
150 component = update.issue_attrs["component"] 128 changes = {
151 if milestones_by_module[component]: 129 '_ts': attrs['_ts'],
152 update.changes["milestone"] = milestones_by_module[component] 130 'action': 'leave'
153 yield update 131 }
154 132 actions = tp.ticket.getActions(ref.id)
155 def _format_comments(repo, updates): 133 if any(action[0] == 'resolve' for action in actions):
156 repository_name = posixpath.split(repo.url())[1] 134 changes['action'] = 'resolve'
157 template = get_template("hg/template/issue_commit_comment.tmpl", 135 if not attrs['milestone']:
158 autoescape=False) 136 need_milestones[attrs['component']].append(changes)
159 for update in updates: 137 updates.append((ref.id, changes))
160 update.comment = template.render({"repository_name": repository_name, 138
161 "changes": update.commits}) 139 for module, milestone in _get_module_milestones(ui, config,
162 yield update 140 need_milestones.keys()):
163 141 for changes in need_milestones[module]:
164 def _apply_updates(ui, config, trac, updates): 142 changes['milestone'] = milestone
165 issue_url_template = config.get("hg", "issue_url_template") 143
166 for update in updates: 144 for issue_id, changes in updates:
167 issue_url = issue_url_template.format(id=update.issue_id) 145 _update_issue(ui, config, issue_id, changes)
168 try: 146
169 trac.ticket.update(update.issue_id, update.comment, update.changes, True) 147
170 updates = ["posted comment"] 148 def _collect_references(ui, commits):
171 if "milestone" in update.changes: 149 commits_by_issue = collections.defaultdict(list)
172 updates.append("set milestone: {}".format(update.changes["milestone"])) 150 fixed_issues = set()
173 if update.changes["action"] == "resolve": 151
174 updates.append("closed") 152 for commit in commits:
175 ui.status("updated {} ({})\n".format(issue_url, ', '.join(updates))) 153 message = commit.description()
176 except Exception as exc: 154 if ' - ' not in message:
177 ui.warn("warning: failed to update {} ({})\n".format(issue_url, exc)) 155 ui.warn("warning: invalid commit message format: '{}'\n"
178 156 .format(message))
179 def hook(ui, repo, node, **kwargs): 157 continue
180 config = get_config() 158
181 trac_url = config.get("hg", "trac_xmlrpc_url") 159 refs, rest = message.split(' - ', 1)
182 trac = xmlrpclib.ServerProxy(trac_url) 160 issue_refs = ISSUE_NUMBER_REGEX.findall(refs)
183 updates = _create_issue_updates(ui, repo, node) 161 if issue_refs:
kzar 2016/04/18 14:48:30 Couldn't we instead iterate through the updates on
Vasily Kuznetsov 2016/04/24 21:49:50 We're basically creating a stream of updates and s
184 updates = _prepare_changes(ui, trac, updates) 162 for ref_type, issue_id in issue_refs:
185 updates = _assign_milestones(ui, config, trac, updates) 163 issue_id = int(issue_id)
186 updates = _format_comments(repo, updates) 164 commits_by_issue[issue_id].append(commit)
187 _apply_updates(ui, config, trac, updates) 165 if ref_type.lower() == 'fixes':
166 fixed_issues.add(issue_id)
167 elif not NOISSUE_REGEX.search(refs):
168 ui.warn("warning: no issue reference in commit message: '{}'\n"
169 .format(message))
170
171 for issue_id, commits in sorted(commits_by_issue.items()):
172 yield _IssueRef(issue_id, commits, is_fixed=issue_id in fixed_issues)
173
174
175 def changegroup_hook(ui, repo, node, **kwargs):
176 config = get_config()
177 first_rev = repo[node].rev()
178 pushed_commits = repo[first_rev:]
179 refs = _collect_references(ui, pushed_commits)
180 _post_comments(ui, repo, config, refs)
181
182
183 def pushkey_hook(ui, repo, **kwargs):
184 if (kwargs['namespace'] != 'bookmarks' or # Not a bookmark move.
185 kwargs['key'] != 'master' or # Not `master` bookmark.
186 not kwargs['old']): # The bookmark is just created.
187 return
188
189 config = get_config()
190 old_master_rev = repo[kwargs['old']].rev()
191 new_master_rev = repo[kwargs['new']].rev()
192 added_revs = repo.changelog.findmissingrevs([old_master_rev],
193 [new_master_rev])
194 added_commits = [repo[rev] for rev in added_revs]
195 refs = [ref for ref in _collect_references(ui, added_commits)
196 if ref.is_fixed]
197 _declare_fixed(ui, config, refs)
198
199
200 # Alias for backward compatibility.
201 hook = changegroup_hook
LEFTRIGHT

Powered by Google App Engine
This is Rietveld