Merge lp:~nbm/ibid/svnplugin-phase1 into lp:~ibid-core/ibid/old-trunk-1.6
- svnplugin-phase1
- Merge into old-trunk-1.6
Proposed by
Neil Blakey-Milner
Status: | Merged | ||||
---|---|---|---|---|---|
Approved by: | Stefano Rivera | ||||
Approved revision: | not available | ||||
Merged at revision: | 818 | ||||
Proposed branch: | lp:~nbm/ibid/svnplugin-phase1 | ||||
Merge into: | lp:~ibid-core/ibid/old-trunk-1.6 | ||||
Diff against target: |
471 lines (+467/-0) 1 file modified
ibid/plugins/svn.py (+467/-0) |
||||
To merge this branch: | bzr merge lp:~nbm/ibid/svnplugin-phase1 | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Stefano Rivera | Approve | ||
Michael Gorven | Approve | ||
Jonathan Hitchcock | Approve | ||
Review via email: mp+16671@code.launchpad.net |
Commit message
Description of the change
To post a comment you must log in.
Revision history for this message
Jonathan Hitchcock (vhata) : | # |
review:
Approve
Revision history for this message
Stefano Rivera (stefanor) wrote : | # |
Revision history for this message
Michael Gorven (mgorven) wrote : | # |
Looks fine.
review approve
review:
Approve
Revision history for this message
Stefano Rivera (stefanor) wrote : | # |
The output of "Snitch: last commit full" looks awful on IRC.
I am going to fix the tab characters in the multiline-501410 branch (they'll get transformed into spaces for IRC). But it should probably return pretty formatted messages.
review:
Needs Fixing
lp:~nbm/ibid/svnplugin-phase1
updated
- 811. By Neil Blakey-Milner
-
Fall back to non-multiline responses by default, since IRC doesn't like them at all. Make it configurable to use multiline, though.
Revision history for this message
Neil Blakey-Milner (nbm) wrote : | # |
I've cleaned up the response to match the bzr one.
Now says:
Snitch: nbm: Commit 3039 by bradley to checkoutservice [trunk] on 2009-12-31 at 09:01:50 UTC: Better error handling on non-normal responses from DS during register (Modified: src/checkoutser
Revision history for this message
Stefano Rivera (stefanor) : | # |
review:
Approve
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === added file 'ibid/plugins/svn.py' |
2 | --- ibid/plugins/svn.py 1970-01-01 00:00:00 +0000 |
3 | +++ ibid/plugins/svn.py 2009-12-31 13:15:24 +0000 |
4 | @@ -0,0 +1,467 @@ |
5 | +""" |
6 | +Configuration: |
7 | + |
8 | +[plugins] |
9 | + [[svn]] |
10 | + [[[repositories]]] |
11 | + [[[[repo1]]]] |
12 | + url = https://... |
13 | + username = user1 |
14 | + password = mypassword |
15 | + [[[[repo2]]]] |
16 | + url = https://... |
17 | + username = user2 |
18 | + password = mypassword |
19 | + ... |
20 | + |
21 | +""" |
22 | + |
23 | +from datetime import datetime, timedelta |
24 | +import logging |
25 | +import os.path |
26 | +from os import kill |
27 | +from signal import SIGTERM |
28 | +import textwrap |
29 | +from ibid.compat import ElementTree as ET |
30 | +from subprocess import Popen, PIPE |
31 | +from time import time, sleep, mktime |
32 | + |
33 | +# Can use either pysvn or command-line svn |
34 | +try: |
35 | + import pysvn |
36 | +except: |
37 | + pysvn = None |
38 | + |
39 | +import ibid |
40 | +from ibid.plugins import Processor, match, RPC, handler, run_every, authorise |
41 | +from ibid.config import DictOption, FloatOption, Option, BoolOption |
42 | +from ibid.utils import ago, format_date, human_join |
43 | + |
44 | +help = {'svn': u'Retrieves commit logs from a Subversion repository.'} |
45 | + |
46 | +HEAD_REVISION = object() |
47 | + |
48 | +class Branch(object): |
49 | + def __init__(self, repository_name = None, url = None, username = None, password = None, multiline = False): |
50 | + self.repository = repository_name |
51 | + self.url = url |
52 | + self.username = username |
53 | + self.password = password |
54 | + self.multiline = multiline |
55 | + |
56 | + def get_commits(self, start_revision = None, end_revision = None, limit = None, full = False): |
57 | + """ |
58 | + Get formatted commit messages for each of the commits in range |
59 | + [start_revision:end_revision], defaulting to the latest revision. |
60 | + """ |
61 | + if not full: # such as None |
62 | + full = False |
63 | + |
64 | + if not start_revision: |
65 | + start_revision = HEAD_REVISION |
66 | + |
67 | + start_revision = self._convert_to_revision(start_revision) |
68 | + |
69 | + # If no end-revision and no limit given, set limit to 1 |
70 | + if not end_revision: |
71 | + end_revision = 0 |
72 | + if not limit: |
73 | + limit = 1 |
74 | + |
75 | + end_revision = self._convert_to_revision(end_revision) |
76 | + |
77 | + log_messages = self.log(start_revision, end_revision, limit=limit, paths=full) |
78 | + commits = [self.format_log_message(log_message, full) for log_message in log_messages] |
79 | + |
80 | + return commits |
81 | + |
82 | + def _generate_delta(self, changed_paths): |
83 | + class T(object): |
84 | + pass |
85 | + |
86 | + delta = T() |
87 | + delta.basepath = "/" |
88 | + delta.added = [] |
89 | + delta.modified = [] |
90 | + delta.removed = [] |
91 | + delta.renamed = [] |
92 | + |
93 | + action_mapper = { |
94 | + 'M': delta.modified, |
95 | + 'A': delta.added, |
96 | + 'D': delta.removed, |
97 | + } |
98 | + |
99 | + all_paths = [changed_path.path for changed_path in changed_paths] |
100 | + |
101 | + commonprefix = os.path.commonprefix(all_paths) |
102 | + |
103 | + # os.path.commonprefix will return "/e" if you give it "/etc/passwd" |
104 | + # and "/exports/foo", which is not what we want. Remove until the last |
105 | + # "/" character. |
106 | + while commonprefix and commonprefix[-1] != "/": |
107 | + commonprefix = commonprefix[:-1] |
108 | + |
109 | + pathinfo = commonprefix |
110 | + |
111 | + if commonprefix.startswith("/trunk/"): |
112 | + commonprefix = "/trunk/" |
113 | + pathinfo = " [trunk]" |
114 | + |
115 | + if commonprefix.startswith("/branches/"): |
116 | + commonprefix = "/branches/%s" % (commonprefix.split('/')[2],) |
117 | + pathinfo = " [" + commonprefix.split('/')[2] + "]" |
118 | + |
119 | + if commonprefix.startswith("/tags/"): |
120 | + commonprefix = "/tags/%s" % (commonprefix.split('/')[2],) |
121 | + pathinfo = " [" + commonprefix.split('/')[2] + "]" |
122 | + |
123 | + for changed_path in changed_paths: |
124 | + action_mapper[changed_path.action].append([changed_path.path[len(commonprefix):], None]) |
125 | + |
126 | + return pathinfo, delta |
127 | + |
128 | + def format_log_message(self, log_message, full=False): |
129 | + """ |
130 | + author - string - the name of the author who committed the revision |
131 | + date - float time - the date of the commit |
132 | + message - string - the text of the log message for the commit |
133 | + revision - pysvn.Revision - the revision of the commit |
134 | + |
135 | + changed_paths - list of dictionaries. Each dictionary contains: |
136 | + path - string - the path in the repository |
137 | + action - string |
138 | + copyfrom_path - string - if copied, the original path, else None |
139 | + copyfrom_revision - pysvn.Revision - if copied, the revision of the original, else None |
140 | + """ |
141 | + revision_number = log_message['revision'].number |
142 | + author = log_message['author'] |
143 | + commit_message = log_message['message'] |
144 | + timestamp = log_message['date'] |
145 | + |
146 | + if full: |
147 | + pathinfo, delta = self._generate_delta(log_message['changed_paths']) |
148 | + changes = [] |
149 | + |
150 | + if delta.added: |
151 | + if self.multiline: |
152 | + changes.append('Added:\n\t%s' % '\n\t'.join([file[0] for file in delta.added])) |
153 | + else: |
154 | + changes.append('Added: %s' % ', '.join([file[0] for file in delta.added])) |
155 | + if delta.modified: |
156 | + if self.multiline: |
157 | + changes.append('Modified:\n\t%s' % '\n\t'.join([file[0] for file in delta.modified])) |
158 | + else: |
159 | + changes.append('Modified: %s' % '\, '.join([file[0] for file in delta.modified])) |
160 | + if delta.removed: |
161 | + if self.multiline: |
162 | + changes.append('Removed:\n\t%s' % '\n\t'.join([file[0] for file in delta.removed])) |
163 | + else: |
164 | + changes.append('Removed: %s' % ', '.join([file[0] for file in delta.removed])) |
165 | + if delta.renamed: |
166 | + changes.append('Renamed: %s' % ', '.join(['%s => %s' % (file[0], file[1]) for file in delta.renamed])) |
167 | + |
168 | + timestamp_dt = datetime.utcfromtimestamp(timestamp) |
169 | + |
170 | + if self.multiline: |
171 | + commit = 'Commit %s by %s to %s%s on %s at %s:\n\n\t%s \n\n%s\n' % ( |
172 | + revision_number, |
173 | + author, |
174 | + self.repository, |
175 | + pathinfo, |
176 | + format_date(timestamp_dt, 'date'), |
177 | + format_date(timestamp_dt, 'time'), |
178 | + u'\n'.join(textwrap.wrap(commit_message, initial_indent=" ", subsequent_indent=" ")), |
179 | + '\n\n'.join(changes)) |
180 | + else: |
181 | + commit = 'Commit %s by %s to %s%s on %s at %s: %s (%s)\n' % ( |
182 | + revision_number, |
183 | + author, |
184 | + self.repository, |
185 | + pathinfo, |
186 | + format_date(timestamp_dt, 'date'), |
187 | + format_date(timestamp_dt, 'time'), |
188 | + commit_message.replace('\n', ' '), |
189 | + '; '.join(changes)) |
190 | + else: |
191 | + commit = 'Commit %s by %s to %s %s ago: %s\n' % ( |
192 | + revision_number, |
193 | + author, |
194 | + self.repository, |
195 | + ago(datetime.now() - datetime.fromtimestamp(timestamp), 2), |
196 | + commit_message.replace('\n', ' ')) |
197 | + |
198 | + return commit |
199 | + |
200 | +class PySVNBranch(Branch): |
201 | + def _call_command(self, command, *args, **kw): |
202 | + return command(self, username=self.username, password=self.password)(*args, **kw) |
203 | + |
204 | + def log(self, *args, **kw): |
205 | + """ |
206 | + Low-level SVN logging call - returns lists of pysvn.PysvnLog objects. |
207 | + """ |
208 | + return self._call_command(SVNLog, *args, **kw) |
209 | + |
210 | + def _convert_to_revision(self, revision): |
211 | + """ |
212 | + Convert numbers to pysvn.Revision instances |
213 | + """ |
214 | + if revision is HEAD_REVISION: |
215 | + return pysvn.Revision(pysvn.opt_revision_kind.head) |
216 | + |
217 | + try: |
218 | + revision.kind |
219 | + return revision |
220 | + except: |
221 | + return pysvn.Revision(pysvn.opt_revision_kind.number, revision) |
222 | + |
223 | +class CommandLineChangedPath(object): |
224 | + pass |
225 | + |
226 | +class TimeoutException(Exception): |
227 | + pass |
228 | + |
229 | +class CommandLineRevision(object): |
230 | + def __init__(self, number): |
231 | + self.number = number |
232 | + |
233 | +class CommandLineBranch(Branch): |
234 | + def __init__(self, repository_name = None, url = None, username = None, password = None, svn_command = 'svn', svn_timeout = 15.0, multiline = False): |
235 | + super(CommandLineBranch, self).__init__(repository_name, url, username, password, multiline=multiline) |
236 | + self.svn_command = svn_command |
237 | + self.svn_timeout = svn_timeout |
238 | + |
239 | + def _convert_to_revision(self, revision): |
240 | + return revision |
241 | + |
242 | + def log(self, start_revision, end_revision, paths=False, limit=1): |
243 | + cmd = ["svn", "log", "--no-auth-cache", "--non-interactive", "--xml"] |
244 | + |
245 | + if paths: |
246 | + cmd.append("-v") |
247 | + |
248 | + if self.username: |
249 | + cmd.append("--username") |
250 | + cmd.append(self.username) |
251 | + |
252 | + if self.password: |
253 | + cmd.append("--password") |
254 | + cmd.append(self.password) |
255 | + |
256 | + if limit: |
257 | + cmd.append("--limit") |
258 | + cmd.append(str(limit)) |
259 | + |
260 | + if start_revision is None or start_revision is HEAD_REVISION: |
261 | + pass |
262 | + else: |
263 | + if not end_revision or start_revision == end_revision: |
264 | + if not limit: |
265 | + # if start revision, no end revision (or equal to start_revision), and no limit given, just the revision |
266 | + cmd.append("-r") |
267 | + cmd.append(str(start_revision)) |
268 | + cmd.append("--limit") |
269 | + cmd.append("1") |
270 | + else: |
271 | + cmd.append("-r") |
272 | + cmd.append("%i" % (start_revision,)) |
273 | + else: |
274 | + cmd.append("-r") |
275 | + cmd.append("%i:%i" % (end_revision, start_revision)) |
276 | + |
277 | + cmd.append(self.url) |
278 | + |
279 | + logging.getLogger('plugins.svn').info(str(cmd)) |
280 | + |
281 | + svnlog = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE) |
282 | + svnlog.stdin.close() |
283 | + |
284 | + start_time = time() |
285 | + |
286 | + while svnlog.poll() is None and time() - start_time < self.svn_timeout: |
287 | + sleep(0.1) |
288 | + |
289 | + if svnlog.poll() is None: |
290 | + kill(svnlog.pid, SIGTERM) |
291 | + raise TimeoutException() |
292 | + |
293 | + output = svnlog.stdout.read() |
294 | + error = svnlog.stderr.read() |
295 | + |
296 | + code = svnlog.wait() |
297 | + |
298 | + return self._xml_to_log_message(output) |
299 | + |
300 | + def _xmldate_to_timestamp(self, xmldate): |
301 | + xmldate = xmldate.split('.')[0] |
302 | + dt = datetime.strptime(xmldate, "%Y-%m-%dT%H:%M:%S") |
303 | + return mktime(dt.timetuple()) |
304 | + |
305 | + def _xml_to_log_message(self, output): |
306 | + """ |
307 | + author - string - the name of the author who committed the revision |
308 | + date - float time - the date of the commit |
309 | + message - string - the text of the log message for the commit |
310 | + revision - pysvn.Revision - the revision of the commit |
311 | + |
312 | + changed_paths - list of dictionaries. Each dictionary contains: |
313 | + path - string - the path in the repository |
314 | + action - string |
315 | + copyfrom_path - string - if copied, the original path, else None |
316 | + copyfrom_revision - pysvn.Revision - if copied, the revision of the original, else None |
317 | + """ |
318 | + doc = ET.fromstring(output) |
319 | + entries = [] |
320 | + |
321 | + for logentry in doc: |
322 | + entry = dict( |
323 | + revision = CommandLineRevision(logentry.get('revision')), |
324 | + author = logentry.findtext("author"), |
325 | + date = self._xmldate_to_timestamp(logentry.findtext("date")), |
326 | + message = logentry.findtext("msg"), |
327 | + ) |
328 | + |
329 | + entry['changed_paths'] = [] |
330 | + paths = logentry.find("paths") |
331 | + if paths: |
332 | + for path in paths: |
333 | + cp = CommandLineChangedPath() |
334 | + cp.kind = path.get('kind') |
335 | + cp.action = path.get('action') |
336 | + cp.path = path.text |
337 | + entry['changed_paths'].append(cp) |
338 | + entries.append(entry) |
339 | + return entries |
340 | + |
341 | + |
342 | +class SVNCommand(object): |
343 | + def __init__(self, branch, username=None, password=None): |
344 | + self._branch = branch |
345 | + self._username = username |
346 | + self._password = password |
347 | + self._client = self._initClient(branch) |
348 | + |
349 | + def _initClient(self, branch): |
350 | + client = pysvn.Client() |
351 | + client.callback_get_login = self.get_login |
352 | + client.callback_cancel = CancelAfterTimeout() |
353 | + return client |
354 | + |
355 | + def get_login(self, realm, username, may_save): |
356 | + if self._username and self._password: |
357 | + return True, self._username.encode('utf-8'), self._password.encode('utf-8'), False |
358 | + return False, None, None, False |
359 | + |
360 | + def _initCommand(self): |
361 | + self._client.callback_cancel.start() |
362 | + pass |
363 | + |
364 | + def _destroyCommand(self): |
365 | + self._client.callback_cancel.done() |
366 | + pass |
367 | + |
368 | + def __call__(self, *args, **kw): |
369 | + self._initCommand() |
370 | + return self._command(*args, **kw) |
371 | + self._destroyCommand() |
372 | + |
373 | +class SVNLog(SVNCommand): |
374 | + def _command(self, start_revision=HEAD_REVISION, end_revision=None, paths=False, stop_on_copy=True, limit=1): |
375 | + log_messages = self._client.log(self._branch.url, revision_start=start_revision, revision_end=end_revision, discover_changed_paths=paths, strict_node_history=stop_on_copy, limit=limit) |
376 | + return log_messages |
377 | + |
378 | +class CancelAfterTimeout(object): |
379 | + """ |
380 | + Implement timeout for if a SVN command is taking its time |
381 | + """ |
382 | + def __init__(self, timeout = 15): |
383 | + self.timeout = timeout |
384 | + |
385 | + def start(self): |
386 | + self.cancel_at = datetime.now() + timedelta(seconds=self.timeout) |
387 | + |
388 | + def __call__(self): |
389 | + return datetime.now() > self.cancel_at |
390 | + |
391 | + def done(self): |
392 | + pass |
393 | + |
394 | +class Subversion(Processor, RPC): |
395 | + u"""(last commit|commit <revno>) [to <repo>] [full] |
396 | + (svnrepos|svnrepositories) |
397 | + """ |
398 | + feature = 'svn' |
399 | + autoload = False |
400 | + |
401 | + permission = u'svn' |
402 | + |
403 | + repositories = DictOption('repositories', 'Dict of repositories names and URLs') |
404 | + |
405 | + svn_command = Option('svn_command', 'Path to svn executable', 'svn') |
406 | + svn_timeout = FloatOption('svn_timeout', 'Maximum svn execution time (sec)', 15.0) |
407 | + multiline = BoolOption('multiline', 'Output multi-line (Jabber, Campfire)', False) |
408 | + |
409 | + def __init__(self, name): |
410 | + self.log = logging.getLogger('plugins.svn') |
411 | + Processor.__init__(self, name) |
412 | + RPC.__init__(self) |
413 | + |
414 | + def setup(self): |
415 | + self.branches = {} |
416 | + for name, repository in self.repositories.items(): |
417 | + reponame = name.lower() |
418 | + if pysvn: |
419 | + self.branches[reponame] = PySVNBranch(reponame, repository['url'], username = repository['username'], password = repository['password'], multiline=self.multiline) |
420 | + else: |
421 | + self.branches[reponame] = CommandLineBranch(reponame, repository['url'], username = repository['username'], password = repository['password'], svn_command=self.svn_command, svn_timeout=self.svn_timeout, multiline=self.multiline) |
422 | + |
423 | + @match(r'^svn ?(?:repos|repositories)$') |
424 | + @authorise() |
425 | + def handle_repositories(self, event): |
426 | + repositories = self.branches.keys() |
427 | + if repositories: |
428 | + event.addresponse(u'I know about: %s', human_join(sorted(repositories))) |
429 | + else: |
430 | + event.addresponse(u"I don't know about any repositories") |
431 | + |
432 | + @match(r'^(?:last\s+)?commit(?:\s+(\d+))?(?:(?:\s+to)?\s+(\S+?))?(\s+full)?$') |
433 | + @authorise() |
434 | + def commit(self, event, revno, repository, full): |
435 | + |
436 | + if repository == "full": |
437 | + repository = None |
438 | + full = True |
439 | + |
440 | + if full: |
441 | + full = True |
442 | + |
443 | + revno = revno and int(revno) or None |
444 | + commits = self.get_commits(repository, revno, full=full) |
445 | + |
446 | + if commits: |
447 | + for commit in commits: |
448 | + if commit: |
449 | + event.addresponse(commit.strip()) |
450 | + |
451 | + def get_commits(self, repository, start, end=None, full=None): |
452 | + branch = None |
453 | + if repository: |
454 | + repository = repository.lower() |
455 | + if repository not in self.branches: |
456 | + return None |
457 | + branch = self.branches[repository] |
458 | + |
459 | + if not branch: |
460 | + (repository, branch) = self.branches.items()[0] |
461 | + |
462 | + if not start: |
463 | + start = HEAD_REVISION |
464 | + |
465 | + if not end: |
466 | + end = None |
467 | + |
468 | + commits = branch.get_commits(start, end_revision=end, full=full) |
469 | + return commits |
470 | + |
471 | +# vi: set et sta sw=4 ts=4: |
I haven't tested it, but it seems sensible enough.
One thing: Python 2.4 doesn't have datetime.strptime. ibid.compat has a workaround for that.