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 |
---|