#!/usr/bin/env python
# Copyright (c) 2014 Michal Borejszo michal@traal.eu
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
# svn-smf-hook.py
#
# You can use this script as post-commit hook of your svn repo,
# and it'll announce each commit as new topic on selected boards,
# designed to work with Simple Machines Forums.
#
# Tested with SMF 2.0.6
import sys, os, urllib, urllib2, cookielib, time, fnmatch, urlparse, re, threading
import htmlentitydefs as entities
from xml.dom.minidom import parseString
###########
VERSION = 2
###########
# Default config values
USER = 'user'
PASSWORD = 'password'
FORUM_URL = 'http://example.com/index.php'
SVN_URL = 'svn://example.com/repo'
BOARD_MAIN = None
BOARD_SPEC = None
SPEC_CHAR = '!'
TIMEOUT = 60
MAX_LEN = 20000
EMPTY_MESSAGE = '[i]User did not write any message[/i]'
LOG_STDOUT = None
LOG_STDERR = None
PMS = None
PMS_URL = None
# Parse config
try:
execfile(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'svn-smf-hook.conf'), globals())
except Exception, e:
print >>sys.stderr, 'Failed to parse config file: ' + str(e)
sys.exit(1)
if 'TRAC_URL' in globals() and PMS in (None, ''): # compatibility with older versions
PMS = 'trac'
PMS_URL = TRAC_URL
if LOG_STDOUT:
try:
sys.stdout = open(LOG_STDOUT, 'a')
except Exception, e:
print 'Failed to start stdout log at %s - %s' % (LOG_STDOUT, e)
if LOG_STDERR:
try:
sys.stderr = open(LOG_STDERR, 'a')
except Exception, e:
print 'Failed to start stderr log at %s - %s' % (LOG_STDERR, e)
actions = {
'D': 'deleted ',
'M': 'modified',
'A': 'added ',
'R': 'replaced'
}
phpsessid = None
headers = None
class poster(threading.Thread):
def set(self, bbcode, subject, is_beta):
self.bbcode = bbcode
self.subject = subject
self.is_beta = is_beta
def run(self):
post_bbcode(self.bbcode, self.subject, self.is_beta)
def replace(org, rep, start, end):
"""
Injects rep into org replacing characters from start to end.
"""
org = org[:start] + rep + org[end:]
return org
def fix_unicode(s):
s = list(s)
out = []
for x in s:
if ord(x) in entities.codepoint2name:
out += [ '&' + entities.codepoint2name[ord(x)] + ';' ]
else:
out += x
return ''.join(out)
def ticketBB(ticket):
if PMS == 'trac':
return '[url=' + PMS_URL + '/ticket/%s]%s[/url]' % (ticket[1:], ticket)
elif PMS == 'redmine':
return '[url=' + PMS_URL + '/issue/%s]%s[/url]' % (ticket[1:], ticket)
else:
return ticket
def actionBB(path, revision):
if PMS == 'trac':
return ' [url=' + PMS_URL + '/browser' + path.childNodes[0].nodeValue + '?rev=' + str(revision) + ']' + path.childNodes[0].nodeValue + '[/url]'
elif PMS == 'redmine':
return ' [url=' + PMS_URL + '/repository/changes' + path.childNodes[0].nodeValue + '?rev=' + str(revision) + ']' + path.childNodes[0].nodeValue + '[/url]'
else:
return path.childNodes[0].nodeValue
def post_bbcode(bbcode, subject, is_changelog_item):
global phpsessid
username = USER
password = PASSWORD
url_login = FORUM_URL
url_post = FORUM_URL
cj = cookielib.CookieJar()
opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(cj))
urllib2.install_opener(opener)
def get_phpsessid():
global phpsessid
for c in cj:
if c.name == 'PHPSESSID':
phpsessid = c.value
def login():
global headers
# First, GET request
out = urllib2.urlopen(url_login + '?action=login')
get_phpsessid()
# POST request to login2
host = urlparse.urlparse(FORUM_URL)[1]
data = urllib.urlencode(
{
'user': username,
'passwrd': password,
'cookielength': '-1',
'cookieneverexp': 'on'
}
)
headers = {
'Host': host,
'Accept': "text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5",
'Accept-Charset': "ISO-8859-1,utf-8;q=0.7,*;q=0.7",
'Accept-Language': 'en-us;q=0.7,en;q=0.3',
'Accept-Encoding': 'none',
'Keep-Alive': '115',
'Connection': 'keep-alive',
'User-Agent': 'Mozilla/5.0 (X11; U; Linux i686) Gecko/20071127 Firefox/2.0.0.11',
'Referer': url_login + '?action=login',
'DNT': '1',
'Content-Type': 'application/x-www-form-urlencoded',
};
req = urllib2.Request(
url_login + '?PHPSESSID=' + phpsessid + '&action=login2',
data = data,
headers = headers
)
out = urllib2.urlopen(req)
assert '%s' % username in out.read(), 'login probably failed'
def post_thread(forumid):
def _do_post():
global headers
data = {
'subject': subject,
'message': bbcode,
'topic': '0',
'notify': '0',
'lock': '0',
'additional_options': '0',
'icon': 'xx',
'message_mode': '0',
'goback': '1',
'seqnum': seqnum,
}
data.update(sc)
data = urllib.urlencode(data)
headers['Referer'] = url_post + '?action=post;board=' + forumid
req = urllib2.Request(
url_post + '?action=post2;start=0;board=' + forumid, data = data,
headers = headers
)
out = urllib2.urlopen(req)
content = out.read()
if 'Your session timed out while posting.' in content:
# Try till it works, or till script time-out...
_do_post()
# GET New topic page
out = urllib2.urlopen(url_post + '?PHPSESSID=' + phpsessid + '&action=post;board=' + forumid)
seqnum = None
last = None
sc = {}
for line in out.readlines():
if seqnum: break
line = line.strip()
if last and fnmatch.fnmatch(last, ''):
# we have sc here
for x in line.split():
if x.startswith('value'):
v = x.replace('value=', '').replace('"', '')
if x.startswith('name'):
n = x.replace('name=', '').replace('"', '')
sc[n] = v
if fnmatch.fnmatch(line, ''):
for x in line.split():
if x.startswith('value'):
seqnum = x.replace('value=', '').replace('"', '')
last = line
_do_post()
login()
if BOARD_MAIN:
post_thread(BOARD_MAIN)
if is_changelog_item and BOARD_SPEC:
time.sleep(3) # sleep to by-pass SMF spambot detection
post_thread(BOARD_SPEC)
def make_bbcode():
assert len(sys.argv) == 3
revision = int(sys.argv[2])
f = os.popen('svn log --non-interactive --xml --verbose --revision ' + str(revision) + ' ' + SVN_URL)
logxml = f.read()
f.close()
try:
doc = parseString(logxml)
except Exception, e:
print 'failed to parse xml: ' + str(e)
logmsg = doc.getElementsByTagName('logentry')[0]
author = logmsg.childNodes[1]
date = logmsg.childNodes[3]
paths = logmsg.childNodes[5]
msg = logmsg.childNodes[7]
assert author.tagName == 'author'
assert date.tagName == 'date'
assert paths.tagName == 'paths'
assert msg.tagName == 'msg'
is_beta = False
authortxt = author.childNodes[0].nodeValue
if len(msg.childNodes) > 0:
msgtxt = msg.childNodes[0].nodeValue
lines = msgtxt.split('\n')
for l in lines:
if l.startswith(SPEC_CHAR): is_beta = True
title = '[' + authortxt + '] ' + lines[0]
else:
msgtxt = EMPTY_MESSAGE
title = None
regexp = re.compile(r'(#\d+)(?!\[)(?![0-9])')
m = regexp.search(msgtxt)
while m:
ticket = m.groups()[0]
msgtxt = replace(
msgtxt,
ticketBB(ticket),
m.start(),
m.end()
)
m = regexp.search(msgtxt)
pathmsg = []
changedfiles = 0
for path in paths.childNodes:
if hasattr(path, 'tagName'):
assert path.tagName == 'path'
changedfiles += 1
pathmsg.append(actions[path.getAttribute('action')] + actionBB(path, revision))
length = len('/n'.join(pathmsg))
if length > MAX_LEN:
pathmsg = ['[...lots of files...]']
pathmsg.sort()
if title is None:
title = '[' + authortxt + '] ' + 'created revision ' + str(revision) + ' with ' + str(changedfiles) + ' changes'
if PMS == 'trac':
bbcode = """[b]""" + authortxt + """[/b] made revision [b][url=""" + PMS_URL + """/changeset/""" + str(revision) + """]""" + str(revision) + """[/url][/b] changing the following files:
[quote][tt]""" + '\n'.join(pathmsg) + """[/tt][/quote]
with the message:
[quote]""" + msgtxt + """[/quote]"""
elif PMS == 'redmine':
bbcode = """[b]""" + authortxt + """[/b] made revision [b][url=""" + PMS_URL + """/repository/revisions/""" + str(revision) + """]""" + str(revision) + """[/url][/b] changing the following files:
[quote][tt]""" + '\n'.join(pathmsg) + """[/tt][/quote]
with the message:
[quote]""" + msgtxt + """[/quote]"""
else:
bbcode = """[b]""" + authortxt + """[/b] made revision [b]""" + str(revision) + """[/b] changing the following files:
[quote][tt]""" + '\n'.join(pathmsg) + """[/tt][/quote]
with the message:
[quote]""" + msgtxt + """[/quote]"""
bbcode = bbcode.encode('ascii', 'replace')
title = title.encode('ascii', 'replace')
return bbcode, title, is_beta
if __name__ == '__main__':
bbcode, subject, is_beta = make_bbcode()
t = poster()
t.daemon = True # daemon thread will be taken down when main thread finishes
t.set(bbcode, subject, is_beta)
t.start()
t.join(TIMEOUT) # wait for maximum of TIMEOUT seconds for thread to finish, then proceed