Merge lp:~nbm/ibid/svnplugin-phase1 into lp:~ibid-core/ibid/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
Reviewer Review Type Date Requested Status
Stefano Rivera Approve
Michael Gorven Approve
Jonathan Hitchcock Approve
Review via email: mp+16671@code.launchpad.net
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 :

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.

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/checkoutservice/products/plugins/domain.py)

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:

Subscribers

People subscribed via source and target branches