source: mergebot/trunk/mergebot/web_ui.py @ 17

Last change on this file since 17 was 17, checked in by retracile, 15 years ago

Mergebot: redesigned implementation. Still has rough edges.

File size: 11.2 KB
Line 
1#!/usr/bin/env python
2
3import socket
4import time
5import os
6import base64
7from subprocess import check_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        check_call(['mergebotdaemon', self.env.path])
99        time.sleep(1) # bleh
100
101    def _daemon_cmd(self, cmd):
102        self.log.debug('Sending mergebotdaemon: %r' % cmd)
103        try:
104            info_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
105            info_socket.connect(self.daemon_address())
106        except socket.error, e:
107            # if we're refused, try starting the daemon and re-try
108            if e and e[0] == 111:
109                self.log.debug('connection to mergebotdaemon refused, trying to start mergebotdaemon')
110                self.start_daemon()
111            self.log.debug('Resending mergebotdaemon: %r' % cmd)
112            info_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
113            info_socket.connect(self.daemon_address())
114        info_socket.sendall(cmd)
115        self.log.debug('Reading mergebotdaemon response')
116        raw = info_socket.recv(4096)
117        info_socket.close()
118        self.log.debug('Reading mergebotdaemon response was %r' % raw)
119        return raw
120
121    def process_request(self, req):
122        """This is the function called when a user requests a mergebot page."""
123        req.perm.assert_permission("MERGEBOT_VIEW")
124
125        # 2nd redirect back to the real mergebot page to address POST and
126        # browser refresh
127        if req.path_info == "/mergebot/redir":
128            req.redirect(req.href.mergebot())
129
130        data = {}
131
132        if req.method == "POST": # The user hit a button
133            if req.args['action'] in ['Branch', 'Rebranch', 'CheckMerge', 'Merge']:
134                ticketnum = req.args['ticket']
135                component = req.args['component']
136                version = req.args['version']
137                requestor = req.authname or 'anonymous'
138                ticket = Ticket(self.env, int(ticketnum))
139                # FIXME: check for 'todo' key?
140                # If the request is not valid, just ignore it.
141                action = None
142                if req.args['action'] == "Branch":
143                    req.perm.assert_permission("MERGEBOT_BRANCH")
144                    if is_branchable(ticket):
145                        action = 'branch'
146                elif req.args['action'] == "Rebranch":
147                    req.perm.assert_permission("MERGEBOT_BRANCH")
148                    if is_rebranchable(ticket):
149                        action = 'rebranch'
150                elif req.args['action'] == "CheckMerge":
151                    req.perm.assert_permission("MERGEBOT_VIEW")
152                    if is_checkmergeable(ticket):
153                        action = 'checkmerge'
154                elif req.args['action'] == "Merge":
155                    if version.startswith("#"):
156                        req.perm.assert_permission("MERGEBOT_MERGE_TICKET")
157                    else:
158                        req.perm.assert_permission("MERGEBOT_MERGE_RELEASE")
159                    if is_mergeable(ticket):
160                        action = 'merge'
161                if action:
162                    command = 'ADD %s %s %s %s %s\nQUIT\n' % (ticketnum, action, component, version, requestor)
163                    result = self._daemon_cmd(command)
164                    if 'OK' not in result:
165                        add_warning(req, result)
166            if req.args['action'] == "Cancel":
167                command = 'CANCEL %s\nQUIT\n' % req.args['task']
168                result = self._daemon_cmd(command)
169                if 'OK' not in result:
170                    add_warning(req, result)
171            # First half of a double-redirect to make a refresh not re-send the
172            # POST data.
173            req.redirect(req.href.mergebot("redir"))
174
175        # We want to fill out the information for the page unconditionally.
176
177        # Connect to the daemon and read the current queue information
178        raw_queue_info = self._daemon_cmd('LIST\nQUIT\n')
179        # Parse the queue information into something we can display
180        queue_info = [x.split(',') for x in raw_queue_info.split('\n') if ',' in x]
181        status_map = {
182            'Z':'Zombie',
183            'R':'Running',
184            'Q':'Queued',
185            'W':'Waiting',
186            'P':'Pending',
187        }
188        for row in queue_info:
189            status = row[1]
190            row[1] = status_map[status]
191            summary = row[7]
192            row[7] = base64.b64decode(summary)
193
194        data['queue'] = queue_info
195
196        queued_tickets = set([int(q[2]) for q in queue_info])
197
198        # Provide the list of tickets at the bottom of the page, along with
199        # flags for which buttons should be enabled for each ticket.
200        # I need to get a list of tickets.  For non-admins, restrict the list
201        # to the tickets owned by that user.
202        querystring = "status!=closed&version!="
203        required_columns = ["component", "version", "mergebotstate"]
204        if req.perm.has_permission("MERGEBOT_ADMIN"):
205            required_columns.append("owner")
206        else:
207            querystring += "&owner=%s" % (req.authname,)
208        query = Query.from_string(self.env, querystring, order="id")
209        columns = query.get_columns()
210        for name in required_columns:
211            if name not in columns:
212                columns.append(name)
213        db = self.env.get_db_cnx()
214        tickets = query.execute(req, db)
215        data['unqueued'] = []
216        for ticket in tickets:
217            ticketnum = ticket['id']
218            if ticketnum in queued_tickets:
219                # Don't allow more actions to be taken on a ticket that is
220                # currently queued for something.  In the future, we may want
221                # to support a 'Cancel' button, though.  Or present actions
222                # based upon the expected state of the ticket
223                continue
224
225            ticket_info = {'info': ticket}
226            data['unqueued'].append(ticket_info)
227
228            # Select what actions this user may make on this ticket based on
229            # its current state.
230
231            # MERGE
232            ticket_info['merge'] = False
233            if ticket['version'].startswith('#'):
234                if req.perm.has_permission("MERGEBOT_MERGE_TICKET"):
235                    ticket_info['merge'] = is_mergeable(ticket)
236            else:
237                if req.perm.has_permission("MERGEBOT_MERGE_RELEASE"):
238                    ticket_info['merge'] = is_mergeable(ticket)
239            # CHECK-MERGE
240            if req.perm.has_permission("MERGEBOT_VIEW"):
241                ticket_info['checkmerge'] = is_checkmergeable(ticket)
242            else:
243                ticket_info['checkmerge'] = False
244            # BRANCH, REBRANCH
245            if req.perm.has_permission("MERGEBOT_BRANCH"):
246                ticket_info['branch'] = is_branchable(ticket)
247                ticket_info['rebranch'] = is_rebranchable(ticket)
248            else:
249                ticket_info['branch'] = False
250                ticket_info['rebranch'] = False
251
252        # proactive warnings
253        work_dir = self.env.config.get('mergebot', 'work_dir')
254        if not os.path.isabs(work_dir):
255            work_dir = os.path.join(self.env.path, work_dir)
256        if not os.path.isdir(work_dir):
257            add_warning(req, 'The Mergebot work directory "%s" does not exist.' % work_dir)
258
259        return "mergebot.html", data, None
260
261    # ITemplateProvider
262    def get_htdocs_dirs(self):
263        return []
264
265    def get_templates_dirs(self):
266        # It appears that everyone does this import here instead of at the top
267        # level... I'm not sure I understand why...
268        from pkg_resources import resource_filename
269        return [resource_filename(__name__, 'templates')]
270
271# vim:foldmethod=indent foldcolumn=8
272# vim:softtabstop=4 shiftwidth=4 tabstop=4 expandtab
Note: See TracBrowser for help on using the repository browser.