| 1 | #!/usr/bin/env python |
|---|
| 2 | |
|---|
| 3 | import socket |
|---|
| 4 | import time |
|---|
| 5 | import os |
|---|
| 6 | import base64 |
|---|
| 7 | from subprocess import call |
|---|
| 8 | |
|---|
| 9 | from trac.core import * |
|---|
| 10 | from trac import util |
|---|
| 11 | from trac.ticket.query import Query |
|---|
| 12 | from trac.ticket.model import Ticket |
|---|
| 13 | from trac.env import IEnvironmentSetupParticipant |
|---|
| 14 | from trac.perm import IPermissionRequestor |
|---|
| 15 | from trac.web.main import IRequestHandler |
|---|
| 16 | from trac.web.chrome import INavigationContributor, ITemplateProvider, add_warning |
|---|
| 17 | |
|---|
| 18 | from action_logic import is_branchable, is_rebranchable, is_mergeable, is_checkmergeable |
|---|
| 19 | |
|---|
| 20 | class MergeBotModule(Component): |
|---|
| 21 | implements(INavigationContributor, IPermissionRequestor, IRequestHandler, ITemplateProvider, IEnvironmentSetupParticipant) |
|---|
| 22 | |
|---|
| 23 | # IEnvironmentSetupParticipant |
|---|
| 24 | def environment_created(self): |
|---|
| 25 | self._setup_config() |
|---|
| 26 | |
|---|
| 27 | def environment_needs_upgrade(self, db): |
|---|
| 28 | return not list(self.config.options('mergebot')) |
|---|
| 29 | |
|---|
| 30 | def upgrade_environment(self, db): |
|---|
| 31 | self._setup_config() |
|---|
| 32 | |
|---|
| 33 | def _setup_config(self): |
|---|
| 34 | self.config.set('mergebot', 'work_dir', 'mergebot') |
|---|
| 35 | self.config.set('mergebot', 'repository_url', 'http://FIXME/svn') |
|---|
| 36 | self.config.set('mergebot', 'listen.ip', 'localhost') |
|---|
| 37 | self.config.set('mergebot', 'listen.port', '12345') |
|---|
| 38 | self.config.set('mergebot', 'worker_count', '2') |
|---|
| 39 | # Set up the needed custom field for the bot |
|---|
| 40 | self.config.set('ticket-custom', 'mergebotstate', 'select') |
|---|
| 41 | self.config.set('ticket-custom', 'mergebotstate.label', 'MergeBotState') |
|---|
| 42 | self.config.set('ticket-custom', 'mergebotstate.options', '| merged | branched | conflicts') |
|---|
| 43 | self.config.set('ticket-custom', 'mergebotstate.value', '') |
|---|
| 44 | self.config.save() |
|---|
| 45 | |
|---|
| 46 | # INavigationContributor |
|---|
| 47 | # so it shows up in the main nav bar |
|---|
| 48 | def get_active_navigation_item(self, req): |
|---|
| 49 | return 'mergebot' |
|---|
| 50 | |
|---|
| 51 | def get_navigation_items(self, req): |
|---|
| 52 | """Generator that yields the MergeBot tab, but only if the user has |
|---|
| 53 | MERGEBOT_VIEW privs.""" |
|---|
| 54 | if req.perm.has_permission("MERGEBOT_VIEW"): |
|---|
| 55 | label = util.Markup('<a href="%s">MergeBot</a>' % \ |
|---|
| 56 | req.href.mergebot()) |
|---|
| 57 | yield ('mainnav', 'mergebot', label) |
|---|
| 58 | |
|---|
| 59 | # IPermissionRequestor methods |
|---|
| 60 | # So we can control access to this functionality |
|---|
| 61 | def get_permission_actions(self): |
|---|
| 62 | """Returns a permission structure.""" |
|---|
| 63 | actions = ["MERGEBOT_VIEW", "MERGEBOT_BRANCH", "MERGEBOT_MERGE_TICKET", |
|---|
| 64 | "MERGEBOT_MERGE_RELEASE"] |
|---|
| 65 | # MERGEBOT_ADMIN implies all of the above permissions |
|---|
| 66 | allactions = actions + [ |
|---|
| 67 | ("MERGEBOT_ADMIN", actions), |
|---|
| 68 | ("MERGEBOT_BRANCH", ["MERGEBOT_VIEW"]), |
|---|
| 69 | ("MERGEBOT_MERGE_TICKET", ["MERGEBOT_VIEW"]), |
|---|
| 70 | ("MERGEBOT_MERGE_RELEASE", ["MERGEBOT_VIEW"]) |
|---|
| 71 | ] |
|---|
| 72 | return allactions |
|---|
| 73 | |
|---|
| 74 | # IRequestHandler |
|---|
| 75 | def match_request(self, req): |
|---|
| 76 | """Returns true, if the given request path is handled by this module""" |
|---|
| 77 | # For now, we don't recognize any arguments... |
|---|
| 78 | return req.path_info == "/mergebot" or req.path_info.startswith("/mergebot/") |
|---|
| 79 | |
|---|
| 80 | def _get_ticket_info(self, ticketid): |
|---|
| 81 | # grab the ticket info we care about |
|---|
| 82 | fields = ['summary', 'component', 'version', 'status'] |
|---|
| 83 | info = {} |
|---|
| 84 | if ticketid: |
|---|
| 85 | ticket = Ticket(self.env, ticketid) |
|---|
| 86 | for field in fields: |
|---|
| 87 | info[field] = ticket[field] |
|---|
| 88 | info['href'] = self.env.href.ticket(ticketid) |
|---|
| 89 | self.log.debug("id=%s, info=%r" % (ticketid, info)) |
|---|
| 90 | return info |
|---|
| 91 | |
|---|
| 92 | def daemon_address(self): |
|---|
| 93 | host = self.env.config.get('mergebot', 'listen.ip') |
|---|
| 94 | port = int(self.env.config.get('mergebot', 'listen.port')) |
|---|
| 95 | return (host, port) |
|---|
| 96 | |
|---|
| 97 | def start_daemon(self): |
|---|
| 98 | retval = call(['mergebotdaemon', self.env.path]) |
|---|
| 99 | if retval: |
|---|
| 100 | raise Exception('mergebotdaemon failed to start' % retval) |
|---|
| 101 | time.sleep(1) # bleh |
|---|
| 102 | |
|---|
| 103 | def _daemon_cmd(self, cmd): |
|---|
| 104 | self.log.debug('Sending mergebotdaemon: %r' % cmd) |
|---|
| 105 | try: |
|---|
| 106 | info_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) |
|---|
| 107 | info_socket.connect(self.daemon_address()) |
|---|
| 108 | except socket.error, e: |
|---|
| 109 | # if we're refused, try starting the daemon and re-try |
|---|
| 110 | if e and e[0] == 111: |
|---|
| 111 | self.log.debug('connection to mergebotdaemon refused, trying to start mergebotdaemon') |
|---|
| 112 | self.start_daemon() |
|---|
| 113 | self.log.debug('Resending mergebotdaemon: %r' % cmd) |
|---|
| 114 | info_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) |
|---|
| 115 | info_socket.connect(self.daemon_address()) |
|---|
| 116 | info_socket.sendall(cmd) |
|---|
| 117 | self.log.debug('Reading mergebotdaemon response') |
|---|
| 118 | raw = info_socket.recv(4096) |
|---|
| 119 | info_socket.close() |
|---|
| 120 | self.log.debug('Reading mergebotdaemon response was %r' % raw) |
|---|
| 121 | return raw |
|---|
| 122 | |
|---|
| 123 | def process_request(self, req): |
|---|
| 124 | """This is the function called when a user requests a mergebot page.""" |
|---|
| 125 | req.perm.assert_permission("MERGEBOT_VIEW") |
|---|
| 126 | |
|---|
| 127 | # 2nd redirect back to the real mergebot page to address POST and |
|---|
| 128 | # browser refresh |
|---|
| 129 | if req.path_info == "/mergebot/redir": |
|---|
| 130 | req.redirect(req.href.mergebot()) |
|---|
| 131 | |
|---|
| 132 | data = {} |
|---|
| 133 | |
|---|
| 134 | if req.method == "POST": # The user hit a button |
|---|
| 135 | if req.args['action'] in ['Branch', 'Rebranch', 'CheckMerge', 'Merge']: |
|---|
| 136 | ticketnum = req.args['ticket'] |
|---|
| 137 | component = req.args['component'] |
|---|
| 138 | version = req.args['version'] |
|---|
| 139 | requestor = req.authname or 'anonymous' |
|---|
| 140 | ticket = Ticket(self.env, int(ticketnum)) |
|---|
| 141 | # FIXME: check for 'todo' key? |
|---|
| 142 | # If the request is not valid, just ignore it. |
|---|
| 143 | action = None |
|---|
| 144 | if req.args['action'] == "Branch": |
|---|
| 145 | req.perm.assert_permission("MERGEBOT_BRANCH") |
|---|
| 146 | if is_branchable(ticket): |
|---|
| 147 | action = 'branch' |
|---|
| 148 | elif req.args['action'] == "Rebranch": |
|---|
| 149 | req.perm.assert_permission("MERGEBOT_BRANCH") |
|---|
| 150 | if is_rebranchable(ticket): |
|---|
| 151 | action = 'rebranch' |
|---|
| 152 | elif req.args['action'] == "CheckMerge": |
|---|
| 153 | req.perm.assert_permission("MERGEBOT_VIEW") |
|---|
| 154 | if is_checkmergeable(ticket): |
|---|
| 155 | action = 'checkmerge' |
|---|
| 156 | elif req.args['action'] == "Merge": |
|---|
| 157 | if version.startswith("#"): |
|---|
| 158 | req.perm.assert_permission("MERGEBOT_MERGE_TICKET") |
|---|
| 159 | else: |
|---|
| 160 | req.perm.assert_permission("MERGEBOT_MERGE_RELEASE") |
|---|
| 161 | if is_mergeable(ticket): |
|---|
| 162 | action = 'merge' |
|---|
| 163 | if action: |
|---|
| 164 | command = 'ADD %s %s %s %s %s\nQUIT\n' % (ticketnum, action, component, version, requestor) |
|---|
| 165 | result = self._daemon_cmd(command) |
|---|
| 166 | if 'OK' not in result: |
|---|
| 167 | add_warning(req, result) |
|---|
| 168 | if req.args['action'] == "Cancel": |
|---|
| 169 | command = 'CANCEL %s\nQUIT\n' % req.args['task'] |
|---|
| 170 | result = self._daemon_cmd(command) |
|---|
| 171 | if 'OK' not in result: |
|---|
| 172 | add_warning(req, result) |
|---|
| 173 | # First half of a double-redirect to make a refresh not re-send the |
|---|
| 174 | # POST data. |
|---|
| 175 | req.redirect(req.href.mergebot("redir")) |
|---|
| 176 | |
|---|
| 177 | # We want to fill out the information for the page unconditionally. |
|---|
| 178 | |
|---|
| 179 | # Connect to the daemon and read the current queue information |
|---|
| 180 | raw_queue_info = self._daemon_cmd('LIST\nQUIT\n') |
|---|
| 181 | # Parse the queue information into something we can display |
|---|
| 182 | queue_info = [x.split(',') for x in raw_queue_info.split('\n') if ',' in x] |
|---|
| 183 | status_map = { |
|---|
| 184 | 'Z':'Zombie', |
|---|
| 185 | 'R':'Running', |
|---|
| 186 | 'Q':'Queued', |
|---|
| 187 | 'W':'Waiting', |
|---|
| 188 | 'P':'Pending', |
|---|
| 189 | } |
|---|
| 190 | for row in queue_info: |
|---|
| 191 | status = row[1] |
|---|
| 192 | row[1] = status_map[status] |
|---|
| 193 | summary = row[7] |
|---|
| 194 | row[7] = base64.b64decode(summary) |
|---|
| 195 | |
|---|
| 196 | data['queue'] = queue_info |
|---|
| 197 | |
|---|
| 198 | queued_tickets = set([int(q[2]) for q in queue_info]) |
|---|
| 199 | |
|---|
| 200 | # Provide the list of tickets at the bottom of the page, along with |
|---|
| 201 | # flags for which buttons should be enabled for each ticket. |
|---|
| 202 | # I need to get a list of tickets. For non-admins, restrict the list |
|---|
| 203 | # to the tickets owned by that user. |
|---|
| 204 | querystring = "status!=closed&version!=" |
|---|
| 205 | required_columns = ["component", "version", "mergebotstate"] |
|---|
| 206 | if req.perm.has_permission("MERGEBOT_ADMIN"): |
|---|
| 207 | required_columns.append("owner") |
|---|
| 208 | else: |
|---|
| 209 | querystring += "&owner=%s" % (req.authname,) |
|---|
| 210 | query = Query.from_string(self.env, querystring, order="id") |
|---|
| 211 | columns = query.get_columns() |
|---|
| 212 | for name in required_columns: |
|---|
| 213 | if name not in columns: |
|---|
| 214 | columns.append(name) |
|---|
| 215 | db = self.env.get_db_cnx() |
|---|
| 216 | tickets = query.execute(req, db) |
|---|
| 217 | data['unqueued'] = [] |
|---|
| 218 | for ticket in tickets: |
|---|
| 219 | ticketnum = ticket['id'] |
|---|
| 220 | if ticketnum in queued_tickets: |
|---|
| 221 | # Don't allow more actions to be taken on a ticket that is |
|---|
| 222 | # currently queued for something. In the future, we may want |
|---|
| 223 | # to support a 'Cancel' button, though. Or present actions |
|---|
| 224 | # based upon the expected state of the ticket |
|---|
| 225 | continue |
|---|
| 226 | |
|---|
| 227 | ticket_info = {'info': ticket} |
|---|
| 228 | data['unqueued'].append(ticket_info) |
|---|
| 229 | |
|---|
| 230 | # Select what actions this user may make on this ticket based on |
|---|
| 231 | # its current state. |
|---|
| 232 | |
|---|
| 233 | # MERGE |
|---|
| 234 | ticket_info['merge'] = False |
|---|
| 235 | if ticket['version'].startswith('#'): |
|---|
| 236 | if req.perm.has_permission("MERGEBOT_MERGE_TICKET"): |
|---|
| 237 | ticket_info['merge'] = is_mergeable(ticket) |
|---|
| 238 | else: |
|---|
| 239 | if req.perm.has_permission("MERGEBOT_MERGE_RELEASE"): |
|---|
| 240 | ticket_info['merge'] = is_mergeable(ticket) |
|---|
| 241 | # CHECK-MERGE |
|---|
| 242 | if req.perm.has_permission("MERGEBOT_VIEW"): |
|---|
| 243 | ticket_info['checkmerge'] = is_checkmergeable(ticket) |
|---|
| 244 | else: |
|---|
| 245 | ticket_info['checkmerge'] = False |
|---|
| 246 | # BRANCH, REBRANCH |
|---|
| 247 | if req.perm.has_permission("MERGEBOT_BRANCH"): |
|---|
| 248 | ticket_info['branch'] = is_branchable(ticket) |
|---|
| 249 | ticket_info['rebranch'] = is_rebranchable(ticket) |
|---|
| 250 | else: |
|---|
| 251 | ticket_info['branch'] = False |
|---|
| 252 | ticket_info['rebranch'] = False |
|---|
| 253 | |
|---|
| 254 | # proactive warnings |
|---|
| 255 | work_dir = self.env.config.get('mergebot', 'work_dir') |
|---|
| 256 | if not os.path.isabs(work_dir): |
|---|
| 257 | work_dir = os.path.join(self.env.path, work_dir) |
|---|
| 258 | if not os.path.isdir(work_dir): |
|---|
| 259 | add_warning(req, 'The Mergebot work directory "%s" does not exist.' % work_dir) |
|---|
| 260 | |
|---|
| 261 | return "mergebot.html", data, None |
|---|
| 262 | |
|---|
| 263 | # ITemplateProvider |
|---|
| 264 | def get_htdocs_dirs(self): |
|---|
| 265 | return [] |
|---|
| 266 | |
|---|
| 267 | def get_templates_dirs(self): |
|---|
| 268 | # It appears that everyone does this import here instead of at the top |
|---|
| 269 | # level... I'm not sure I understand why... |
|---|
| 270 | from pkg_resources import resource_filename |
|---|
| 271 | return [resource_filename(__name__, 'templates')] |
|---|
| 272 | |
|---|
| 273 | # vim:foldmethod=indent foldcolumn=8 |
|---|
| 274 | # vim:softtabstop=4 shiftwidth=4 tabstop=4 expandtab |
|---|