source: mergebot/trunk/mergebot/web_ui.py

Last change on this file was 31, checked in by retracile, 14 years ago

subprocess.check_call is new in 2.5, use subprocess.call instead

File size: 11.3 KB
Line 
1#!/usr/bin/env python
2
3import socket
4import time
5import os
6import base64
7from subprocess import call
8
9from trac.core import *
10from trac import util
11from trac.ticket.query import Query
12from trac.ticket.model import Ticket
13from trac.env import IEnvironmentSetupParticipant
14from trac.perm import IPermissionRequestor
15from trac.web.main import IRequestHandler
16from trac.web.chrome import INavigationContributor, ITemplateProvider, add_warning
17
18from action_logic import is_branchable, is_rebranchable, is_mergeable, is_checkmergeable
19
20class 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
Note: See TracBrowser for help on using the repository browser.