#!/usr/bin/env python import socket import time import os import base64 from subprocess import check_call from trac.core import * from trac import util from trac.ticket.query import Query from trac.ticket.model import Ticket from trac.env import IEnvironmentSetupParticipant from trac.perm import IPermissionRequestor from trac.web.main import IRequestHandler from trac.web.chrome import INavigationContributor, ITemplateProvider, add_warning from action_logic import is_branchable, is_rebranchable, is_mergeable, is_checkmergeable class MergeBotModule(Component): implements(INavigationContributor, IPermissionRequestor, IRequestHandler, ITemplateProvider, IEnvironmentSetupParticipant) # IEnvironmentSetupParticipant def environment_created(self): self._setup_config() def environment_needs_upgrade(self, db): return not list(self.config.options('mergebot')) def upgrade_environment(self, db): self._setup_config() def _setup_config(self): self.config.set('mergebot', 'work_dir', 'mergebot') self.config.set('mergebot', 'repository_url', 'http://FIXME/svn') self.config.set('mergebot', 'listen.ip', 'localhost') self.config.set('mergebot', 'listen.port', '12345') self.config.set('mergebot', 'worker_count', '2') # Set up the needed custom field for the bot self.config.set('ticket-custom', 'mergebotstate', 'select') self.config.set('ticket-custom', 'mergebotstate.label', 'MergeBotState') self.config.set('ticket-custom', 'mergebotstate.options', '| merged | branched | conflicts') self.config.set('ticket-custom', 'mergebotstate.value', '') self.config.save() # INavigationContributor # so it shows up in the main nav bar def get_active_navigation_item(self, req): return 'mergebot' def get_navigation_items(self, req): """Generator that yields the MergeBot tab, but only if the user has MERGEBOT_VIEW privs.""" if req.perm.has_permission("MERGEBOT_VIEW"): label = util.Markup('MergeBot' % \ req.href.mergebot()) yield ('mainnav', 'mergebot', label) # IPermissionRequestor methods # So we can control access to this functionality def get_permission_actions(self): """Returns a permission structure.""" actions = ["MERGEBOT_VIEW", "MERGEBOT_BRANCH", "MERGEBOT_MERGE_TICKET", "MERGEBOT_MERGE_RELEASE"] # MERGEBOT_ADMIN implies all of the above permissions allactions = actions + [ ("MERGEBOT_ADMIN", actions), ("MERGEBOT_BRANCH", ["MERGEBOT_VIEW"]), ("MERGEBOT_MERGE_TICKET", ["MERGEBOT_VIEW"]), ("MERGEBOT_MERGE_RELEASE", ["MERGEBOT_VIEW"]) ] return allactions # IRequestHandler def match_request(self, req): """Returns true, if the given request path is handled by this module""" # For now, we don't recognize any arguments... return req.path_info == "/mergebot" or req.path_info.startswith("/mergebot/") def _get_ticket_info(self, ticketid): # grab the ticket info we care about fields = ['summary', 'component', 'version', 'status'] info = {} if ticketid: ticket = Ticket(self.env, ticketid) for field in fields: info[field] = ticket[field] info['href'] = self.env.href.ticket(ticketid) self.log.debug("id=%s, info=%r" % (ticketid, info)) return info def daemon_address(self): host = self.env.config.get('mergebot', 'listen.ip') port = int(self.env.config.get('mergebot', 'listen.port')) return (host, port) def start_daemon(self): check_call(['mergebotdaemon', self.env.path]) time.sleep(1) # bleh def _daemon_cmd(self, cmd): self.log.debug('Sending mergebotdaemon: %r' % cmd) try: info_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) info_socket.connect(self.daemon_address()) except socket.error, e: # if we're refused, try starting the daemon and re-try if e and e[0] == 111: self.log.debug('connection to mergebotdaemon refused, trying to start mergebotdaemon') self.start_daemon() self.log.debug('Resending mergebotdaemon: %r' % cmd) info_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) info_socket.connect(self.daemon_address()) info_socket.sendall(cmd) self.log.debug('Reading mergebotdaemon response') raw = info_socket.recv(4096) info_socket.close() self.log.debug('Reading mergebotdaemon response was %r' % raw) return raw def process_request(self, req): """This is the function called when a user requests a mergebot page.""" req.perm.assert_permission("MERGEBOT_VIEW") # 2nd redirect back to the real mergebot page to address POST and # browser refresh if req.path_info == "/mergebot/redir": req.redirect(req.href.mergebot()) data = {} if req.method == "POST": # The user hit a button if req.args['action'] in ['Branch', 'Rebranch', 'CheckMerge', 'Merge']: ticketnum = req.args['ticket'] component = req.args['component'] version = req.args['version'] requestor = req.authname or 'anonymous' ticket = Ticket(self.env, int(ticketnum)) # FIXME: check for 'todo' key? # If the request is not valid, just ignore it. action = None if req.args['action'] == "Branch": req.perm.assert_permission("MERGEBOT_BRANCH") if is_branchable(ticket): action = 'branch' elif req.args['action'] == "Rebranch": req.perm.assert_permission("MERGEBOT_BRANCH") if is_rebranchable(ticket): action = 'rebranch' elif req.args['action'] == "CheckMerge": req.perm.assert_permission("MERGEBOT_VIEW") if is_checkmergeable(ticket): action = 'checkmerge' elif req.args['action'] == "Merge": if version.startswith("#"): req.perm.assert_permission("MERGEBOT_MERGE_TICKET") else: req.perm.assert_permission("MERGEBOT_MERGE_RELEASE") if is_mergeable(ticket): action = 'merge' if action: command = 'ADD %s %s %s %s %s\nQUIT\n' % (ticketnum, action, component, version, requestor) result = self._daemon_cmd(command) if 'OK' not in result: add_warning(req, result) if req.args['action'] == "Cancel": command = 'CANCEL %s\nQUIT\n' % req.args['task'] result = self._daemon_cmd(command) if 'OK' not in result: add_warning(req, result) # First half of a double-redirect to make a refresh not re-send the # POST data. req.redirect(req.href.mergebot("redir")) # We want to fill out the information for the page unconditionally. # Connect to the daemon and read the current queue information raw_queue_info = self._daemon_cmd('LIST\nQUIT\n') # Parse the queue information into something we can display queue_info = [x.split(',') for x in raw_queue_info.split('\n') if ',' in x] status_map = { 'Z':'Zombie', 'R':'Running', 'Q':'Queued', 'W':'Waiting', 'P':'Pending', } for row in queue_info: status = row[1] row[1] = status_map[status] summary = row[7] row[7] = base64.b64decode(summary) data['queue'] = queue_info queued_tickets = set([int(q[2]) for q in queue_info]) # Provide the list of tickets at the bottom of the page, along with # flags for which buttons should be enabled for each ticket. # I need to get a list of tickets. For non-admins, restrict the list # to the tickets owned by that user. querystring = "status!=closed&version!=" required_columns = ["component", "version", "mergebotstate"] if req.perm.has_permission("MERGEBOT_ADMIN"): required_columns.append("owner") else: querystring += "&owner=%s" % (req.authname,) query = Query.from_string(self.env, querystring, order="id") columns = query.get_columns() for name in required_columns: if name not in columns: columns.append(name) db = self.env.get_db_cnx() tickets = query.execute(req, db) data['unqueued'] = [] for ticket in tickets: ticketnum = ticket['id'] if ticketnum in queued_tickets: # Don't allow more actions to be taken on a ticket that is # currently queued for something. In the future, we may want # to support a 'Cancel' button, though. Or present actions # based upon the expected state of the ticket continue ticket_info = {'info': ticket} data['unqueued'].append(ticket_info) # Select what actions this user may make on this ticket based on # its current state. # MERGE ticket_info['merge'] = False if ticket['version'].startswith('#'): if req.perm.has_permission("MERGEBOT_MERGE_TICKET"): ticket_info['merge'] = is_mergeable(ticket) else: if req.perm.has_permission("MERGEBOT_MERGE_RELEASE"): ticket_info['merge'] = is_mergeable(ticket) # CHECK-MERGE if req.perm.has_permission("MERGEBOT_VIEW"): ticket_info['checkmerge'] = is_checkmergeable(ticket) else: ticket_info['checkmerge'] = False # BRANCH, REBRANCH if req.perm.has_permission("MERGEBOT_BRANCH"): ticket_info['branch'] = is_branchable(ticket) ticket_info['rebranch'] = is_rebranchable(ticket) else: ticket_info['branch'] = False ticket_info['rebranch'] = False # proactive warnings work_dir = self.env.config.get('mergebot', 'work_dir') if not os.path.isabs(work_dir): work_dir = os.path.join(self.env.path, work_dir) if not os.path.isdir(work_dir): add_warning(req, 'The Mergebot work directory "%s" does not exist.' % work_dir) return "mergebot.html", data, None # ITemplateProvider def get_htdocs_dirs(self): return [] def get_templates_dirs(self): # It appears that everyone does this import here instead of at the top # level... I'm not sure I understand why... from pkg_resources import resource_filename return [resource_filename(__name__, 'templates')] # vim:foldmethod=indent foldcolumn=8 # vim:softtabstop=4 shiftwidth=4 tabstop=4 expandtab