[16] | 1 | #!/usr/bin/env python |
---|
| 2 | |
---|
[17] | 3 | import socket |
---|
| 4 | import time |
---|
| 5 | import os |
---|
| 6 | import base64 |
---|
[31] | 7 | from subprocess import call |
---|
[16] | 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 |
---|
[17] | 13 | from trac.env import IEnvironmentSetupParticipant |
---|
[16] | 14 | from trac.perm import IPermissionRequestor |
---|
| 15 | from trac.web.main import IRequestHandler |
---|
[17] | 16 | from trac.web.chrome import INavigationContributor, ITemplateProvider, add_warning |
---|
[16] | 17 | |
---|
[17] | 18 | from action_logic import is_branchable, is_rebranchable, is_mergeable, is_checkmergeable |
---|
[16] | 19 | |
---|
| 20 | class MergeBotModule(Component): |
---|
[17] | 21 | implements(INavigationContributor, IPermissionRequestor, IRequestHandler, ITemplateProvider, IEnvironmentSetupParticipant) |
---|
[16] | 22 | |
---|
[17] | 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 | |
---|
[16] | 46 | # INavigationContributor |
---|
| 47 | # so it shows up in the main nav bar |
---|
| 48 | def get_active_navigation_item(self, req): |
---|
| 49 | return 'mergebot' |
---|
[17] | 50 | |
---|
[16] | 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 = {} |
---|
[17] | 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)) |
---|
[16] | 90 | return info |
---|
| 91 | |
---|
[17] | 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): |
---|
[31] | 98 | retval = call(['mergebotdaemon', self.env.path]) |
---|
| 99 | if retval: |
---|
| 100 | raise Exception('mergebotdaemon failed to start' % retval) |
---|
[17] | 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 | |
---|
[16] | 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 | |
---|
[17] | 127 | # 2nd redirect back to the real mergebot page to address POST and |
---|
| 128 | # browser refresh |
---|
[16] | 129 | if req.path_info == "/mergebot/redir": |
---|
| 130 | req.redirect(req.href.mergebot()) |
---|
| 131 | |
---|
[17] | 132 | data = {} |
---|
[16] | 133 | |
---|
| 134 | if req.method == "POST": # The user hit a button |
---|
[17] | 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) |
---|
[16] | 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 | |
---|
[17] | 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. |
---|
[16] | 202 | # I need to get a list of tickets. For non-admins, restrict the list |
---|
| 203 | # to the tickets owned by that user. |
---|
[17] | 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: |
---|
[16] | 209 | querystring += "&owner=%s" % (req.authname,) |
---|
| 210 | query = Query.from_string(self.env, querystring, order="id") |
---|
| 211 | columns = query.get_columns() |
---|
[17] | 212 | for name in required_columns: |
---|
[16] | 213 | if name not in columns: |
---|
| 214 | columns.append(name) |
---|
| 215 | db = self.env.get_db_cnx() |
---|
| 216 | tickets = query.execute(req, db) |
---|
[17] | 217 | data['unqueued'] = [] |
---|
[16] | 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 |
---|
[17] | 223 | # to support a 'Cancel' button, though. Or present actions |
---|
| 224 | # based upon the expected state of the ticket |
---|
[16] | 225 | continue |
---|
[17] | 226 | |
---|
| 227 | ticket_info = {'info': ticket} |
---|
| 228 | data['unqueued'].append(ticket_info) |
---|
| 229 | |
---|
[16] | 230 | # Select what actions this user may make on this ticket based on |
---|
| 231 | # its current state. |
---|
[17] | 232 | |
---|
[16] | 233 | # MERGE |
---|
[17] | 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) |
---|
[16] | 241 | # CHECK-MERGE |
---|
| 242 | if req.perm.has_permission("MERGEBOT_VIEW"): |
---|
[17] | 243 | ticket_info['checkmerge'] = is_checkmergeable(ticket) |
---|
[16] | 244 | else: |
---|
[17] | 245 | ticket_info['checkmerge'] = False |
---|
[16] | 246 | # BRANCH, REBRANCH |
---|
| 247 | if req.perm.has_permission("MERGEBOT_BRANCH"): |
---|
[17] | 248 | ticket_info['branch'] = is_branchable(ticket) |
---|
| 249 | ticket_info['rebranch'] = is_rebranchable(ticket) |
---|
[16] | 250 | else: |
---|
[17] | 251 | ticket_info['branch'] = False |
---|
| 252 | ticket_info['rebranch'] = False |
---|
[16] | 253 | |
---|
[17] | 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) |
---|
[16] | 260 | |
---|
[17] | 261 | return "mergebot.html", data, None |
---|
[16] | 262 | |
---|
| 263 | # ITemplateProvider |
---|
| 264 | def get_htdocs_dirs(self): |
---|
| 265 | return [] |
---|
[17] | 266 | |
---|
[16] | 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 |
---|