リビジョン | f974fa9c8b20e3c00ec110d68ae1b35a3a0a7503 (tree) |
---|---|
日時 | 2011-05-30 18:37:31 |
作者 | kanu_orz |
コミッター | kanu_orz |
update AdvancedTicketWorkflowPlugin to 0.11
@@ -0,0 +1,17 @@ | ||
1 | +K 25 | |
2 | +svn:wc:ra_dav:version-url | |
3 | +V 52 | |
4 | +/svn/!svn/ver/9962/advancedticketworkflowplugin/0.12 | |
5 | +END | |
6 | +setup.py | |
7 | +K 25 | |
8 | +svn:wc:ra_dav:version-url | |
9 | +V 61 | |
10 | +/svn/!svn/ver/9961/advancedticketworkflowplugin/0.12/setup.py | |
11 | +END | |
12 | +setup.cfg | |
13 | +K 25 | |
14 | +svn:wc:ra_dav:version-url | |
15 | +V 62 | |
16 | +/svn/!svn/ver/9955/advancedticketworkflowplugin/0.12/setup.cfg | |
17 | +END |
@@ -0,0 +1,8 @@ | ||
1 | +K 10 | |
2 | +svn:ignore | |
3 | +V 22 | |
4 | +build | |
5 | +dist | |
6 | +*.egg-info | |
7 | + | |
8 | +END |
@@ -0,0 +1,99 @@ | ||
1 | +10 | |
2 | + | |
3 | +dir | |
4 | +10247 | |
5 | +http://trac-hacks.org/svn/advancedticketworkflowplugin/0.12 | |
6 | +http://trac-hacks.org/svn | |
7 | + | |
8 | + | |
9 | + | |
10 | +2011-03-16T14:57:39.379328Z | |
11 | +9962 | |
12 | +retracile | |
13 | +has-props | |
14 | + | |
15 | + | |
16 | + | |
17 | + | |
18 | + | |
19 | + | |
20 | + | |
21 | + | |
22 | + | |
23 | + | |
24 | + | |
25 | + | |
26 | + | |
27 | +7322e99d-02ea-0310-aa39-e9a107903beb | |
28 | + | |
29 | +advancedworkflow | |
30 | +dir | |
31 | + | |
32 | +setup.py | |
33 | +file | |
34 | + | |
35 | + | |
36 | + | |
37 | + | |
38 | +2011-03-16T14:33:31.665995Z | |
39 | +0626f7ceb43b73a4c878ef6239cfa25e | |
40 | +2011-03-16T14:33:31.665995Z | |
41 | +9961 | |
42 | +retracile | |
43 | + | |
44 | + | |
45 | + | |
46 | + | |
47 | + | |
48 | + | |
49 | + | |
50 | + | |
51 | + | |
52 | + | |
53 | + | |
54 | + | |
55 | + | |
56 | + | |
57 | + | |
58 | + | |
59 | + | |
60 | + | |
61 | + | |
62 | + | |
63 | + | |
64 | +703 | |
65 | + | |
66 | +setup.cfg | |
67 | +file | |
68 | + | |
69 | + | |
70 | + | |
71 | + | |
72 | +2008-05-06T03:24:37.734833Z | |
73 | +ced32e2c28d3b229c8aad7c585425ccf | |
74 | +2008-05-06T03:24:37.734833Z | |
75 | +3611 | |
76 | +retracile | |
77 | + | |
78 | + | |
79 | + | |
80 | + | |
81 | + | |
82 | + | |
83 | + | |
84 | + | |
85 | + | |
86 | + | |
87 | + | |
88 | + | |
89 | + | |
90 | + | |
91 | + | |
92 | + | |
93 | + | |
94 | + | |
95 | + | |
96 | + | |
97 | + | |
98 | +51 | |
99 | + |
@@ -0,0 +1,3 @@ | ||
1 | +[egg_info] | |
2 | +tag_build = dev | |
3 | +tag_svn_revision = true |
@@ -0,0 +1,20 @@ | ||
1 | +#!/usr/bin/env python | |
2 | + | |
3 | +from setuptools import setup, find_packages | |
4 | + | |
5 | +setup( | |
6 | + name='AdvancedTicketWorkflowPlugin', | |
7 | + version='0.11', | |
8 | + author = 'Eli Carter', | |
9 | + author_email = 'elicarter@retracile.net', | |
10 | + license='BSD', | |
11 | + description = 'Advanced workflow operations Trac plugin', | |
12 | + long_description = 'Provides more advanced workflow operations for Trac 0.12', | |
13 | + url = 'http://trac-hacks.org/wiki/AdvancedTicketWorkflowPlugin', | |
14 | + | |
15 | + packages = find_packages(), | |
16 | + package_data = {}, | |
17 | + entry_points = {'trac.plugins':['advancedworkflow.controller = advancedworkflow.controller']}, | |
18 | + install_requires = [], | |
19 | + #zip_safe = False, | |
20 | +) |
@@ -0,0 +1,17 @@ | ||
1 | +K 25 | |
2 | +svn:wc:ra_dav:version-url | |
3 | +V 69 | |
4 | +/svn/!svn/ver/9962/advancedticketworkflowplugin/0.12/advancedworkflow | |
5 | +END | |
6 | +__init__.py | |
7 | +K 25 | |
8 | +svn:wc:ra_dav:version-url | |
9 | +V 81 | |
10 | +/svn/!svn/ver/9955/advancedticketworkflowplugin/0.12/advancedworkflow/__init__.py | |
11 | +END | |
12 | +controller.py | |
13 | +K 25 | |
14 | +svn:wc:ra_dav:version-url | |
15 | +V 83 | |
16 | +/svn/!svn/ver/9962/advancedticketworkflowplugin/0.12/advancedworkflow/controller.py | |
17 | +END |
@@ -0,0 +1,7 @@ | ||
1 | +K 10 | |
2 | +svn:ignore | |
3 | +V 12 | |
4 | +*.pyc | |
5 | +*.pyo | |
6 | + | |
7 | +END |
@@ -0,0 +1,96 @@ | ||
1 | +10 | |
2 | + | |
3 | +dir | |
4 | +10247 | |
5 | +http://trac-hacks.org/svn/advancedticketworkflowplugin/0.12/advancedworkflow | |
6 | +http://trac-hacks.org/svn | |
7 | + | |
8 | + | |
9 | + | |
10 | +2011-03-16T14:57:39.379328Z | |
11 | +9962 | |
12 | +retracile | |
13 | +has-props | |
14 | + | |
15 | + | |
16 | + | |
17 | + | |
18 | + | |
19 | + | |
20 | + | |
21 | + | |
22 | + | |
23 | + | |
24 | + | |
25 | + | |
26 | + | |
27 | +7322e99d-02ea-0310-aa39-e9a107903beb | |
28 | + | |
29 | +__init__.py | |
30 | +file | |
31 | + | |
32 | + | |
33 | + | |
34 | + | |
35 | +2011-05-30T09:34:41.187500Z | |
36 | +d41d8cd98f00b204e9800998ecf8427e | |
37 | +2008-05-06T03:24:37.734833Z | |
38 | +3611 | |
39 | +retracile | |
40 | + | |
41 | + | |
42 | + | |
43 | + | |
44 | + | |
45 | + | |
46 | + | |
47 | + | |
48 | + | |
49 | + | |
50 | + | |
51 | + | |
52 | + | |
53 | + | |
54 | + | |
55 | + | |
56 | + | |
57 | + | |
58 | + | |
59 | + | |
60 | + | |
61 | +0 | |
62 | + | |
63 | +controller.py | |
64 | +file | |
65 | + | |
66 | + | |
67 | + | |
68 | + | |
69 | +2011-03-16T14:57:39.379328Z | |
70 | +831e72b9f8950209e1291d01c543ab48 | |
71 | +2011-03-16T14:57:39.379328Z | |
72 | +9962 | |
73 | +retracile | |
74 | + | |
75 | + | |
76 | + | |
77 | + | |
78 | + | |
79 | + | |
80 | + | |
81 | + | |
82 | + | |
83 | + | |
84 | + | |
85 | + | |
86 | + | |
87 | + | |
88 | + | |
89 | + | |
90 | + | |
91 | + | |
92 | + | |
93 | + | |
94 | + | |
95 | +19213 | |
96 | + |
@@ -0,0 +1,511 @@ | ||
1 | +"""Trac plugin that provides a number of advanced operations for customizable | |
2 | +workflows. | |
3 | +""" | |
4 | + | |
5 | +import os | |
6 | +import time | |
7 | +from datetime import datetime | |
8 | +from subprocess import call | |
9 | +from genshi.builder import tag | |
10 | + | |
11 | +from trac.core import implements, Component | |
12 | +from trac.ticket import model | |
13 | +from trac.ticket.api import ITicketActionController, TicketSystem | |
14 | +from trac.ticket.default_workflow import ConfigurableTicketWorkflow | |
15 | +from trac.ticket.model import Milestone | |
16 | +from trac.ticket.notification import TicketNotifyEmail | |
17 | +from trac.resource import ResourceNotFound | |
18 | +from trac.util.datefmt import utc | |
19 | +from trac.web.chrome import add_warning | |
20 | + | |
21 | + | |
22 | +class TicketWorkflowOpBase(Component): | |
23 | + """Abstract base class for 'simple' ticket workflow operations.""" | |
24 | + | |
25 | + implements(ITicketActionController) | |
26 | + abstract = True | |
27 | + | |
28 | + _op_name = None # Must be specified. | |
29 | + | |
30 | + def get_configurable_workflow(self): | |
31 | + controllers = TicketSystem(self.env).action_controllers | |
32 | + for controller in controllers: | |
33 | + if isinstance(controller, ConfigurableTicketWorkflow): | |
34 | + return controller | |
35 | + return ConfigurableTicketWorkflow(self.env) | |
36 | + | |
37 | + # ITicketActionController methods | |
38 | + | |
39 | + def get_ticket_actions(self, req, ticket): | |
40 | + """Finds the actions that use this operation""" | |
41 | + controller = self.get_configurable_workflow() | |
42 | + return controller.get_actions_by_operation_for_req(req, ticket, | |
43 | + self._op_name) | |
44 | + | |
45 | + def get_all_status(self): | |
46 | + """Provide any additional status values""" | |
47 | + # We don't have anything special here; the statuses will be recognized | |
48 | + # by the default controller. | |
49 | + return [] | |
50 | + | |
51 | + # This should most likely be overridden to be more functional | |
52 | + def render_ticket_action_control(self, req, ticket, action): | |
53 | + """Returns the action control""" | |
54 | + actions = self.get_configurable_workflow().actions | |
55 | + label = actions[action]['name'] | |
56 | + return (label, tag(''), '') | |
57 | + | |
58 | + def get_ticket_changes(self, req, ticket, action): | |
59 | + """Must be implemented in subclasses""" | |
60 | + raise NotImplementedError | |
61 | + | |
62 | + def apply_action_side_effects(self, req, ticket, action): | |
63 | + """No side effects""" | |
64 | + pass | |
65 | + | |
66 | + | |
67 | +class TicketWorkflowOpOwnerReporter(TicketWorkflowOpBase): | |
68 | + """Sets the owner to the reporter of the ticket. | |
69 | + | |
70 | + needinfo = * -> needinfo | |
71 | + needinfo.name = Need info | |
72 | + needinfo.operations = set_owner_to_reporter | |
73 | + | |
74 | + | |
75 | + Don't forget to add the `TicketWorkflowOpOwnerReporter` to the workflow | |
76 | + option in [ticket]. | |
77 | + If there is no workflow option, the line will look like this: | |
78 | + | |
79 | + workflow = ConfigurableTicketWorkflow,TicketWorkflowOpOwnerReporter | |
80 | + """ | |
81 | + | |
82 | + _op_name = 'set_owner_to_reporter' | |
83 | + | |
84 | + # ITicketActionController methods | |
85 | + | |
86 | + def render_ticket_action_control(self, req, ticket, action): | |
87 | + """Returns the action control""" | |
88 | + actions = self.get_configurable_workflow().actions | |
89 | + label = actions[action]['name'] | |
90 | + hint = 'The owner will change to %s' % ticket['reporter'] | |
91 | + control = tag('') | |
92 | + return (label, control, hint) | |
93 | + | |
94 | + def get_ticket_changes(self, req, ticket, action): | |
95 | + """Returns the change of owner.""" | |
96 | + return {'owner': ticket['reporter']} | |
97 | + | |
98 | + | |
99 | +class TicketWorkflowOpOwnerComponent(TicketWorkflowOpBase): | |
100 | + """Sets the owner to the default owner for the component. | |
101 | + | |
102 | + <someaction>.operations = set_owner_to_component_owner | |
103 | + | |
104 | + Don't forget to add the `TicketWorkflowOpOwnerComponent` to the workflow | |
105 | + option in [ticket]. | |
106 | + If there is no workflow option, the line will look like this: | |
107 | + | |
108 | + workflow = ConfigurableTicketWorkflow,TicketWorkflowOpOwnerComponent | |
109 | + """ | |
110 | + | |
111 | + _op_name = 'set_owner_to_component_owner' | |
112 | + | |
113 | + # ITicketActionController methods | |
114 | + | |
115 | + def render_ticket_action_control(self, req, ticket, action): | |
116 | + """Returns the action control""" | |
117 | + actions = self.get_configurable_workflow().actions | |
118 | + label = actions[action]['name'] | |
119 | + hint = 'The owner will change to %s' % self._new_owner(ticket) | |
120 | + control = tag('') | |
121 | + return (label, control, hint) | |
122 | + | |
123 | + def get_ticket_changes(self, req, ticket, action): | |
124 | + """Returns the change of owner.""" | |
125 | + return {'owner': self._new_owner(ticket)} | |
126 | + | |
127 | + def _new_owner(self, ticket): | |
128 | + """Determines the new owner""" | |
129 | + component = model.Component(self.env, name=ticket['component']) | |
130 | + self.env.log.debug("component %s, owner %s" % (component, component.owner)) | |
131 | + return component.owner | |
132 | + | |
133 | + | |
134 | +class TicketWorkflowOpOwnerField(TicketWorkflowOpBase): | |
135 | + """Sets the owner to the value of a ticket field | |
136 | + | |
137 | + <someaction>.operations = set_owner_to_field | |
138 | + <someaction>.set_owner_to_field = myfield | |
139 | + | |
140 | + Don't forget to add the `TicketWorkflowOpOwnerField` to the workflow | |
141 | + option in [ticket]. | |
142 | + If there is no workflow option, the line will look like this: | |
143 | + | |
144 | + workflow = ConfigurableTicketWorkflow,TicketWorkflowOpOwnerField | |
145 | + """ | |
146 | + | |
147 | + _op_name = 'set_owner_to_field' | |
148 | + | |
149 | + # ITicketActionController methods | |
150 | + | |
151 | + def render_ticket_action_control(self, req, ticket, action): | |
152 | + """Returns the action control""" | |
153 | + actions = self.get_configurable_workflow().actions | |
154 | + label = actions[action]['name'] | |
155 | + hint = 'The owner will change to %s' % self._new_owner(action, ticket) | |
156 | + control = tag('') | |
157 | + return (label, control, hint) | |
158 | + | |
159 | + def get_ticket_changes(self, req, ticket, action): | |
160 | + """Returns the change of owner.""" | |
161 | + return {'owner': self._new_owner(action, ticket)} | |
162 | + | |
163 | + def _new_owner(self, action, ticket): | |
164 | + """Determines the new owner""" | |
165 | + # Should probably do some sanity checking... | |
166 | + field = self.config.get('ticket-workflow', | |
167 | + action + '.' + self._op_name).strip() | |
168 | + return ticket[field] | |
169 | + | |
170 | + | |
171 | +class TicketWorkflowOpOwnerPrevious(TicketWorkflowOpBase): | |
172 | + """Sets the owner to the previous owner | |
173 | + | |
174 | + Don't forget to add the `TicketWorkflowOpOwnerPrevious` to the workflow | |
175 | + option in [ticket]. | |
176 | + If there is no workflow option, the line will look like this: | |
177 | + | |
178 | + workflow = ConfigurableTicketWorkflow,TicketWorkflowOpOwnerPrevious | |
179 | + """ | |
180 | + | |
181 | + _op_name = 'set_owner_to_previous' | |
182 | + | |
183 | + # ITicketActionController methods | |
184 | + | |
185 | + def render_ticket_action_control(self, req, ticket, action): | |
186 | + """Returns the action control""" | |
187 | + actions = self.get_configurable_workflow().actions | |
188 | + label = actions[action]['name'] | |
189 | + new_owner = self._new_owner(ticket) | |
190 | + if new_owner: | |
191 | + hint = 'The owner will change to %s' % new_owner | |
192 | + else: | |
193 | + hint = 'The owner will be deleted.' | |
194 | + control = tag('') | |
195 | + return (label, control, hint) | |
196 | + | |
197 | + def get_ticket_changes(self, req, ticket, action): | |
198 | + """Returns the change of owner.""" | |
199 | + return {'owner': self._new_owner(ticket)} | |
200 | + | |
201 | + def _new_owner(self, ticket): | |
202 | + """Determines the new owner""" | |
203 | + db = self.env.get_db_cnx() | |
204 | + cursor = db.cursor() | |
205 | + cursor.execute("SELECT oldvalue FROM ticket_change WHERE ticket=%s " \ | |
206 | + "AND field='owner' ORDER BY -time", (ticket.id, )) | |
207 | + row = cursor.fetchone() | |
208 | + if row: | |
209 | + owner = row[0] | |
210 | + else: # The owner has never changed. | |
211 | + owner = ticket['owner'] | |
212 | + return owner | |
213 | + | |
214 | + | |
215 | +class TicketWorkflowOpStatusPrevious(TicketWorkflowOpBase): | |
216 | + """Sets the status to the previous status | |
217 | + | |
218 | + Don't forget to add the `TicketWorkflowOpStatusPrevious` to the workflow | |
219 | + option in [ticket]. | |
220 | + If there is no workflow option, the line will look like this: | |
221 | + | |
222 | + workflow = ConfigurableTicketWorkflow,TicketWorkflowOpStatusPrevious | |
223 | + """ | |
224 | + | |
225 | + _op_name = 'set_status_to_previous' | |
226 | + | |
227 | + # ITicketActionController methods | |
228 | + | |
229 | + def render_ticket_action_control(self, req, ticket, action): | |
230 | + """Returns the action control""" | |
231 | + actions = self.get_configurable_workflow().actions | |
232 | + label = actions[action]['name'] | |
233 | + new_status = self._new_status(ticket) | |
234 | + if new_status != self._old_status(ticket): | |
235 | + hint = 'The status will change to %s' % new_status | |
236 | + else: | |
237 | + hint = '' | |
238 | + control = tag('') | |
239 | + return (label, control, hint) | |
240 | + | |
241 | + def get_ticket_changes(self, req, ticket, action): | |
242 | + """Returns the change of status.""" | |
243 | + return {'status': self._new_status(ticket)} | |
244 | + | |
245 | + def _old_status(self, ticket): | |
246 | + """Determines what the ticket state was (is)""" | |
247 | + return ticket._old.get('status', ticket['status']) | |
248 | + | |
249 | + def _new_status(self, ticket): | |
250 | + """Determines the new status""" | |
251 | + db = self.env.get_db_cnx() | |
252 | + cursor = db.cursor() | |
253 | + cursor.execute("SELECT oldvalue FROM ticket_change WHERE ticket=%s " \ | |
254 | + "AND field='status' ORDER BY -time", (ticket.id, )) | |
255 | + row = cursor.fetchone() | |
256 | + if row: | |
257 | + status = row[0] | |
258 | + else: # The status has never changed. | |
259 | + status = 'new' | |
260 | + return status | |
261 | + | |
262 | + | |
263 | +class TicketWorkflowOpRunExternal(TicketWorkflowOpBase): | |
264 | + """Action to allow running an external command as a side-effect. | |
265 | + | |
266 | + If it is a lengthy task, it should daemonize so the webserver can get back | |
267 | + to doing its thing. If the script exits with a non-zero return code, an | |
268 | + error will be logged to the Trac log. | |
269 | + The plugin will look for a script named <tracenv>/hooks/<someaction>, and | |
270 | + will pass it 2 parameters: the ticket number, and the user. | |
271 | + | |
272 | + <someaction>.operations = run_external | |
273 | + <someaction>.run_external = Hint for the user | |
274 | + | |
275 | + Don't forget to add the `TicketWorkflowOpRunExternal` to the workflow | |
276 | + option in [ticket]. | |
277 | + If there is no workflow option, the line will look like this: | |
278 | + | |
279 | + workflow = ConfigurableTicketWorkflow,TicketWorkflowOpRunExternal | |
280 | + """ | |
281 | + | |
282 | + implements(ITicketActionController) | |
283 | + | |
284 | + # ITicketActionController methods | |
285 | + | |
286 | + def get_ticket_actions(self, req, ticket): | |
287 | + """Finds the actions that use this operation""" | |
288 | + controller = self.get_configurable_workflow() | |
289 | + return controller.get_actions_by_operation_for_req(req, ticket, | |
290 | + 'run_external') | |
291 | + | |
292 | + def get_all_status(self): | |
293 | + """Provide any additional status values""" | |
294 | + # We don't have anything special here; the statuses will be recognized | |
295 | + # by the default controller. | |
296 | + return [] | |
297 | + | |
298 | + def render_ticket_action_control(self, req, ticket, action): | |
299 | + """Returns the action control""" | |
300 | + actions = self.get_configurable_workflow().actions | |
301 | + label = actions[action]['name'] | |
302 | + hint = self.config.get('ticket-workflow', | |
303 | + action + '.run_external').strip() | |
304 | + if hint is None: | |
305 | + hint = "Will run external script." | |
306 | + return (label, tag(''), hint) | |
307 | + | |
308 | + def get_ticket_changes(self, req, ticket, action): | |
309 | + """No changes to the ticket""" | |
310 | + return {} | |
311 | + | |
312 | + def apply_action_side_effects(self, req, ticket, action): | |
313 | + """Run the external script""" | |
314 | + print "running external script for %s" % action | |
315 | + script = os.path.join(self.env.path, 'hooks', action) | |
316 | + for extension in ('', '.exe', '.cmd', '.bat'): | |
317 | + if os.path.exists(script + extension): | |
318 | + script += extension | |
319 | + break | |
320 | + else: | |
321 | + self.env.log.error("Error in ticket workflow config; could not find external command to run for %s in %s" % (action, os.path.join(self.env.path, 'hooks'))) | |
322 | + return | |
323 | + retval = call([script, str(ticket.id), req.authname]) | |
324 | + if retval: | |
325 | + self.env.log.error("External script %r exited with return code %s." % (script, retval)) | |
326 | + | |
327 | + | |
328 | +class TicketWorkflowOpTriage(TicketWorkflowOpBase): | |
329 | + """Action to split a workflow based on a field | |
330 | + | |
331 | + <someaction> = somestatus -> * | |
332 | + <someaction>.operations = triage | |
333 | + <someaction>.triage_field = type | |
334 | + <someaction>.traige_split = defect -> new_defect, task -> new_task, enhancement -> new_enhancement | |
335 | + | |
336 | + Don't forget to add the `TicketWorkflowOpTriage` to the workflow option in | |
337 | + [ticket]. | |
338 | + If there is no workflow option, the line will look like this: | |
339 | + | |
340 | + workflow = ConfigurableTicketWorkflow,TicketWorkflowOpTriage | |
341 | + """ | |
342 | + | |
343 | + _op_name = 'triage' | |
344 | + | |
345 | + # ITicketActionController methods | |
346 | + | |
347 | + def render_ticket_action_control(self, req, ticket, action): | |
348 | + """Returns the action control""" | |
349 | + actions = self.get_configurable_workflow().actions | |
350 | + label = actions[action]['name'] | |
351 | + new_status = self._new_status(ticket, action) | |
352 | + if new_status != ticket['status']: | |
353 | + hint = 'The status will change to %s.' % new_status | |
354 | + else: | |
355 | + hint = '' | |
356 | + control = tag('') | |
357 | + return (label, control, hint) | |
358 | + | |
359 | + def get_ticket_changes(self, req, ticket, action): | |
360 | + """Returns the change of status.""" | |
361 | + return {'status': self._new_status(ticket, action)} | |
362 | + | |
363 | + def _new_status(self, ticket, action): | |
364 | + """Determines the new status""" | |
365 | + field = self.config.get('ticket-workflow', | |
366 | + action + '.triage_field').strip() | |
367 | + transitions = self.config.get('ticket-workflow', | |
368 | + action + '.triage_split').strip() | |
369 | + for transition in [x.strip() for x in transitions.split(',')]: | |
370 | + value, status = [y.strip() for y in transition.split('->')] | |
371 | + if value == ticket[field].strip(): | |
372 | + break | |
373 | + else: | |
374 | + self.env.log.error("Bad configuration for 'triage' operation in action '%s'" % action) | |
375 | + status = 'new' | |
376 | + return status | |
377 | + | |
378 | + | |
379 | +class TicketWorkflowOpXRef(TicketWorkflowOpBase): | |
380 | + """Adds a cross reference to another ticket | |
381 | + | |
382 | + <someaction>.operations = xref | |
383 | + <someaction>.xref = "Ticket %s is related to this ticket" | |
384 | + <someaction>.xref_local = "Ticket %s was marked as related to this ticket" | |
385 | + <someaction>.xref_hint = "The specified ticket will be cross-referenced with this ticket" | |
386 | + | |
387 | + The example values shown are the default values. | |
388 | + Don't forget to add the `TicketWorkflowOpXRef` to the workflow | |
389 | + option in [ticket]. | |
390 | + If there is no workflow option, the line will look like this: | |
391 | + | |
392 | + workflow = ConfigurableTicketWorkflow,TicketWorkflowOpXRef | |
393 | + """ | |
394 | + | |
395 | + _op_name = 'xref' | |
396 | + | |
397 | + # ITicketActionController methods | |
398 | + | |
399 | + def render_ticket_action_control(self, req, ticket, action): | |
400 | + """Returns the action control""" | |
401 | + id = 'action_%s_xref' % action | |
402 | + ticketnum = req.args.get(id, '') | |
403 | + actions = self.get_configurable_workflow().actions | |
404 | + label = actions[action]['name'] | |
405 | + hint = actions[action].get('xref_hint', | |
406 | + 'The specified ticket will be cross-referenced with this ticket') | |
407 | + control = tag.input(type='text', id=id, name=id, value=ticketnum) | |
408 | + return (label, control, hint) | |
409 | + | |
410 | + def get_ticket_changes(self, req, ticket, action): | |
411 | + # WARNING: Directly modifying the ticket in this method breaks the | |
412 | + # intent of this method. But it does accomplish the desired goal. | |
413 | + if not 'preview' in req.args: | |
414 | + id = 'action_%s_xref' % action | |
415 | + ticketnum = req.args.get(id).strip('#') | |
416 | + | |
417 | + try: | |
418 | + xticket = model.Ticket(self.env, ticketnum) | |
419 | + except ValueError: | |
420 | + req.args['preview'] = True | |
421 | + add_warning(req, 'The cross-referenced ticket number "%s" was not a number' % ticketnum) | |
422 | + return {} | |
423 | + except ResourceNotFound, e: | |
424 | + #put in preview mode to prevent ticket being saved | |
425 | + req.args['preview'] = True | |
426 | + add_warning(req, "Unable to cross-reference Ticket #%s (%s)." % (ticketnum, e.message)) | |
427 | + return {} | |
428 | + | |
429 | + oldcomment = req.args.get('comment') | |
430 | + actions = self.get_configurable_workflow().actions | |
431 | + format_string = actions[action].get('xref_local', | |
432 | + 'Ticket %s was marked as related to this ticket') | |
433 | + # Add a comment to this ticket to indicate that the "remote" ticket is | |
434 | + # related to it. (But only if <action>.xref_local was set in the | |
435 | + # config.) | |
436 | + if format_string: | |
437 | + comment = format_string % ('#%s' % ticketnum) | |
438 | + req.args['comment'] = "%s%s%s" % \ | |
439 | + (comment, oldcomment and "[[BR]]" or "", oldcomment or "") | |
440 | + | |
441 | + """Returns no changes.""" | |
442 | + return {} | |
443 | + | |
444 | + def apply_action_side_effects(self, req, ticket, action): | |
445 | + """Add a cross-reference comment to the other ticket""" | |
446 | + # TODO: This needs a lot more error checking. | |
447 | + id = 'action_%s_xref' % action | |
448 | + ticketnum = req.args.get(id).strip('#') | |
449 | + actions = self.get_configurable_workflow().actions | |
450 | + author = req.authname | |
451 | + | |
452 | + # Add a comment to the "remote" ticket to indicate this ticket is | |
453 | + # related to it. | |
454 | + format_string = actions[action].get('xref', | |
455 | + 'Ticket %s is related to this ticket') | |
456 | + comment = format_string % ('#%s' % ticket.id) | |
457 | + # FIXME: we need a cnum to avoid messing up | |
458 | + xticket = model.Ticket(self.env, ticketnum) | |
459 | + # FIXME: We _assume_ we have sufficient permissions to comment on the | |
460 | + # other ticket. | |
461 | + now = datetime.now(utc) | |
462 | + xticket.save_changes(author, comment, now) | |
463 | + | |
464 | + #Send notification on the other ticket | |
465 | + try: | |
466 | + tn = TicketNotifyEmail(self.env) | |
467 | + tn.notify(xticket, newticket=False, modtime=now) | |
468 | + except Exception, e: | |
469 | + self.log.exception("Failure sending notification on change to " | |
470 | + "ticket #%s: %s" % (ticketnum, e)) | |
471 | + | |
472 | + | |
473 | +class TicketWorkflowOpResetMilestone(TicketWorkflowOpBase): | |
474 | + """Resets the ticket milestone if it is assigned to a completed milestone. | |
475 | + This is useful for reopen operations. | |
476 | + | |
477 | + reopened = closed -> reopened | |
478 | + reopened.name = Reopened | |
479 | + reopened.operations = reset_milestone | |
480 | + | |
481 | + | |
482 | + Don't forget to add the `TicketWorkflowOpResetMilestone` to the workflow | |
483 | + option in [ticket]. | |
484 | + If there is no workflow option, the line will look like this: | |
485 | + | |
486 | + workflow = ConfigurableTicketWorkflow,TicketWorkflowOpResetMilestone | |
487 | + """ | |
488 | + | |
489 | + _op_name = 'reset_milestone' | |
490 | + | |
491 | + # ITicketActionController methods | |
492 | + | |
493 | + def render_ticket_action_control(self, req, ticket, action): | |
494 | + """Returns the action control""" | |
495 | + actions = self.get_configurable_workflow().actions | |
496 | + label = actions[action]['name'] | |
497 | + # check if the assigned milestone has been completed | |
498 | + milestone = Milestone(self.env,ticket['milestone']) | |
499 | + if milestone.is_completed: | |
500 | + hint = 'The milestone will be reset' | |
501 | + else: | |
502 | + hint = '' | |
503 | + control = tag('') | |
504 | + return (label, control, hint) | |
505 | + | |
506 | + def get_ticket_changes(self, req, ticket, action): | |
507 | + """Returns the change of milestone, if needed.""" | |
508 | + milestone = Milestone(self.env,ticket['milestone']) | |
509 | + if milestone.is_completed: | |
510 | + return {'milestone': ''} | |
511 | + return {} |
@@ -1,473 +1,511 @@ | ||
1 | -"""Trac plugin that provides a number of advanced operations for customizable | |
2 | -workflows. | |
3 | -""" | |
4 | - | |
5 | -import os | |
6 | -import time | |
7 | -from subprocess import call | |
8 | -from genshi.builder import tag | |
9 | - | |
10 | -from trac.core import implements, Component | |
11 | -from trac.ticket import model | |
12 | -from trac.ticket.api import ITicketActionController | |
13 | -from trac.ticket.default_workflow import ConfigurableTicketWorkflow | |
14 | -from trac.ticket.model import Milestone | |
15 | - | |
16 | - | |
17 | -class TicketWorkflowOpBase(Component): | |
18 | - """Abstract base class for 'simple' ticket workflow operations.""" | |
19 | - | |
20 | - implements(ITicketActionController) | |
21 | - abstract = True | |
22 | - | |
23 | - _op_name = None # Must be specified. | |
24 | - | |
25 | - # ITicketActionController methods | |
26 | - | |
27 | - def get_ticket_actions(self, req, ticket): | |
28 | - """Finds the actions that use this operation""" | |
29 | - controller = ConfigurableTicketWorkflow(self.env) | |
30 | - return controller.get_actions_by_operation_for_req(req, ticket, | |
31 | - self._op_name) | |
32 | - | |
33 | - def get_all_status(self): | |
34 | - """Provide any additional status values""" | |
35 | - # We don't have anything special here; the statuses will be recognized | |
36 | - # by the default controller. | |
37 | - return [] | |
38 | - | |
39 | - # This should most likely be overridden to be more functional | |
40 | - def render_ticket_action_control(self, req, ticket, action): | |
41 | - """Returns the action control""" | |
42 | - actions = ConfigurableTicketWorkflow(self.env).actions | |
43 | - label = actions[action]['name'] | |
44 | - return (label, tag(''), '') | |
45 | - | |
46 | - def get_ticket_changes(self, req, ticket, action): | |
47 | - """Must be implemented in subclasses""" | |
48 | - raise NotImplementedError | |
49 | - | |
50 | - def apply_action_side_effects(self, req, ticket, action): | |
51 | - """No side effects""" | |
52 | - pass | |
53 | - | |
54 | - | |
55 | -class TicketWorkflowOpOwnerReporter(TicketWorkflowOpBase): | |
56 | - """Sets the owner to the reporter of the ticket. | |
57 | - | |
58 | - needinfo = * -> needinfo | |
59 | - needinfo.name = Need info | |
60 | - needinfo.operations = set_owner_to_reporter | |
61 | - | |
62 | - | |
63 | - Don't forget to add the `TicketWorkflowOpOwnerReporter` to the workflow | |
64 | - option in [ticket]. | |
65 | - If there is no workflow option, the line will look like this: | |
66 | - | |
67 | - workflow = ConfigurableTicketWorkflow,TicketWorkflowOpOwnerReporter | |
68 | - """ | |
69 | - | |
70 | - _op_name = 'set_owner_to_reporter' | |
71 | - | |
72 | - # ITicketActionController methods | |
73 | - | |
74 | - def render_ticket_action_control(self, req, ticket, action): | |
75 | - """Returns the action control""" | |
76 | - actions = ConfigurableTicketWorkflow(self.env).actions | |
77 | - label = actions[action]['name'] | |
78 | - hint = 'The owner will change to %s' % ticket['reporter'] | |
79 | - control = tag('') | |
80 | - return (label, control, hint) | |
81 | - | |
82 | - def get_ticket_changes(self, req, ticket, action): | |
83 | - """Returns the change of owner.""" | |
84 | - return {'owner': ticket['reporter']} | |
85 | - | |
86 | - | |
87 | -class TicketWorkflowOpOwnerComponent(TicketWorkflowOpBase): | |
88 | - """Sets the owner to the default owner for the component. | |
89 | - | |
90 | - <someaction>.operations = set_owner_to_component_owner | |
91 | - | |
92 | - Don't forget to add the `TicketWorkflowOpOwnerComponent` to the workflow | |
93 | - option in [ticket]. | |
94 | - If there is no workflow option, the line will look like this: | |
95 | - | |
96 | - workflow = ConfigurableTicketWorkflow,TicketWorkflowOpOwnerComponent | |
97 | - """ | |
98 | - | |
99 | - _op_name = 'set_owner_to_component_owner' | |
100 | - | |
101 | - # ITicketActionController methods | |
102 | - | |
103 | - def render_ticket_action_control(self, req, ticket, action): | |
104 | - """Returns the action control""" | |
105 | - actions = ConfigurableTicketWorkflow(self.env).actions | |
106 | - label = actions[action]['name'] | |
107 | - hint = 'The owner will change to %s' % self._new_owner(ticket) | |
108 | - control = tag('') | |
109 | - return (label, control, hint) | |
110 | - | |
111 | - def get_ticket_changes(self, req, ticket, action): | |
112 | - """Returns the change of owner.""" | |
113 | - return {'owner': self._new_owner(ticket)} | |
114 | - | |
115 | - def _new_owner(self, ticket): | |
116 | - """Determines the new owner""" | |
117 | - component = model.Component(self.env, name=ticket['component']) | |
118 | - self.env.log.debug("component %s, owner %s" % (component, component.owner)) | |
119 | - return component.owner | |
120 | - | |
121 | - | |
122 | -class TicketWorkflowOpOwnerField(TicketWorkflowOpBase): | |
123 | - """Sets the owner to the value of a ticket field | |
124 | - | |
125 | - <someaction>.operations = set_owner_to_field | |
126 | - <someaction>.set_owner_to_field = myfield | |
127 | - | |
128 | - Don't forget to add the `TicketWorkflowOpOwnerField` to the workflow | |
129 | - option in [ticket]. | |
130 | - If there is no workflow option, the line will look like this: | |
131 | - | |
132 | - workflow = ConfigurableTicketWorkflow,TicketWorkflowOpOwnerField | |
133 | - """ | |
134 | - | |
135 | - _op_name = 'set_owner_to_field' | |
136 | - | |
137 | - # ITicketActionController methods | |
138 | - | |
139 | - def render_ticket_action_control(self, req, ticket, action): | |
140 | - """Returns the action control""" | |
141 | - actions = ConfigurableTicketWorkflow(self.env).actions | |
142 | - label = actions[action]['name'] | |
143 | - hint = 'The owner will change to %s' % self._new_owner(action, ticket) | |
144 | - control = tag('') | |
145 | - return (label, control, hint) | |
146 | - | |
147 | - def get_ticket_changes(self, req, ticket, action): | |
148 | - """Returns the change of owner.""" | |
149 | - return {'owner': self._new_owner(action, ticket)} | |
150 | - | |
151 | - def _new_owner(self, action, ticket): | |
152 | - """Determines the new owner""" | |
153 | - # Should probably do some sanity checking... | |
154 | - field = self.config.get('ticket-workflow', | |
155 | - action + '.' + self._op_name).strip() | |
156 | - return ticket[field] | |
157 | - | |
158 | - | |
159 | -class TicketWorkflowOpOwnerPrevious(TicketWorkflowOpBase): | |
160 | - """Sets the owner to the previous owner | |
161 | - | |
162 | - Don't forget to add the `TicketWorkflowOpOwnerPrevious` to the workflow | |
163 | - option in [ticket]. | |
164 | - If there is no workflow option, the line will look like this: | |
165 | - | |
166 | - workflow = ConfigurableTicketWorkflow,TicketWorkflowOpOwnerPrevious | |
167 | - """ | |
168 | - | |
169 | - _op_name = 'set_owner_to_previous' | |
170 | - | |
171 | - # ITicketActionController methods | |
172 | - | |
173 | - def render_ticket_action_control(self, req, ticket, action): | |
174 | - """Returns the action control""" | |
175 | - actions = ConfigurableTicketWorkflow(self.env).actions | |
176 | - label = actions[action]['name'] | |
177 | - new_owner = self._new_owner(ticket) | |
178 | - if new_owner: | |
179 | - hint = 'The owner will change to %s' % new_owner | |
180 | - else: | |
181 | - hint = 'The owner will be deleted.' | |
182 | - control = tag('') | |
183 | - return (label, control, hint) | |
184 | - | |
185 | - def get_ticket_changes(self, req, ticket, action): | |
186 | - """Returns the change of owner.""" | |
187 | - return {'owner': self._new_owner(ticket)} | |
188 | - | |
189 | - def _new_owner(self, ticket): | |
190 | - """Determines the new owner""" | |
191 | - db = self.env.get_db_cnx() | |
192 | - cursor = db.cursor() | |
193 | - cursor.execute("SELECT oldvalue FROM ticket_change WHERE ticket=%s " \ | |
194 | - "AND field='owner' ORDER BY -time", (ticket.id, )) | |
195 | - row = cursor.fetchone() | |
196 | - if row: | |
197 | - owner = row[0] | |
198 | - else: # The owner has never changed. | |
199 | - owner = '' | |
200 | - return owner | |
201 | - | |
202 | - | |
203 | -class TicketWorkflowOpStatusPrevious(TicketWorkflowOpBase): | |
204 | - """Sets the status to the previous status | |
205 | - | |
206 | - Don't forget to add the `TicketWorkflowOpStatusPrevious` to the workflow | |
207 | - option in [ticket]. | |
208 | - If there is no workflow option, the line will look like this: | |
209 | - | |
210 | - workflow = ConfigurableTicketWorkflow,TicketWorkflowOpStatusPrevious | |
211 | - """ | |
212 | - | |
213 | - _op_name = 'set_status_to_previous' | |
214 | - | |
215 | - # ITicketActionController methods | |
216 | - | |
217 | - def render_ticket_action_control(self, req, ticket, action): | |
218 | - """Returns the action control""" | |
219 | - actions = ConfigurableTicketWorkflow(self.env).actions | |
220 | - label = actions[action]['name'] | |
221 | - new_status = self._new_status(ticket) | |
222 | - if new_status != self._old_status(ticket): | |
223 | - hint = 'The status will change to %s' % new_status | |
224 | - else: | |
225 | - hint = '' | |
226 | - control = tag('') | |
227 | - return (label, control, hint) | |
228 | - | |
229 | - def get_ticket_changes(self, req, ticket, action): | |
230 | - """Returns the change of status.""" | |
231 | - return {'status': self._new_status(ticket)} | |
232 | - | |
233 | - def _old_status(self, ticket): | |
234 | - """Determines what the ticket state was (is)""" | |
235 | - return ticket._old.get('status', ticket['status']) | |
236 | - | |
237 | - def _new_status(self, ticket): | |
238 | - """Determines the new status""" | |
239 | - db = self.env.get_db_cnx() | |
240 | - cursor = db.cursor() | |
241 | - cursor.execute("SELECT oldvalue FROM ticket_change WHERE ticket=%s " \ | |
242 | - "AND field='status' ORDER BY -time", (ticket.id, )) | |
243 | - row = cursor.fetchone() | |
244 | - if row: | |
245 | - status = row[0] | |
246 | - else: # The status has never changed. | |
247 | - status = 'new' | |
248 | - return status | |
249 | - | |
250 | - | |
251 | -class TicketWorkflowOpRunExternal(Component): | |
252 | - """Action to allow running an external command as a side-effect. | |
253 | - | |
254 | - If it is a lengthy task, it should daemonize so the webserver can get back | |
255 | - to doing its thing. If the script exits with a non-zero return code, an | |
256 | - error will be logged to the Trac log. | |
257 | - The plugin will look for a script named <tracenv>/hooks/<someaction>, and | |
258 | - will pass it 2 parameters: the ticket number, and the user. | |
259 | - | |
260 | - <someaction>.operations = run_external | |
261 | - <someaction>.run_external = Hint for the user | |
262 | - | |
263 | - Don't forget to add the `TicketWorkflowOpRunExternal` to the workflow | |
264 | - option in [ticket]. | |
265 | - If there is no workflow option, the line will look like this: | |
266 | - | |
267 | - workflow = ConfigurableTicketWorkflow,TicketWorkflowOpRunExternal | |
268 | - """ | |
269 | - | |
270 | - implements(ITicketActionController) | |
271 | - | |
272 | - # ITicketActionController methods | |
273 | - | |
274 | - def get_ticket_actions(self, req, ticket): | |
275 | - """Finds the actions that use this operation""" | |
276 | - controller = ConfigurableTicketWorkflow(self.env) | |
277 | - return controller.get_actions_by_operation_for_req(req, ticket, | |
278 | - 'run_external') | |
279 | - | |
280 | - def get_all_status(self): | |
281 | - """Provide any additional status values""" | |
282 | - # We don't have anything special here; the statuses will be recognized | |
283 | - # by the default controller. | |
284 | - return [] | |
285 | - | |
286 | - def render_ticket_action_control(self, req, ticket, action): | |
287 | - """Returns the action control""" | |
288 | - actions = ConfigurableTicketWorkflow(self.env).actions | |
289 | - label = actions[action]['name'] | |
290 | - hint = self.config.get('ticket-workflow', | |
291 | - action + '.run_external').strip() | |
292 | - if hint is None: | |
293 | - hint = "Will run external script." | |
294 | - return (label, tag(''), hint) | |
295 | - | |
296 | - def get_ticket_changes(self, req, ticket, action): | |
297 | - """No changes to the ticket""" | |
298 | - return {} | |
299 | - | |
300 | - def apply_action_side_effects(self, req, ticket, action): | |
301 | - """Run the external script""" | |
302 | - print "running external script for %s" % action | |
303 | - script = os.path.join(self.env.path, 'hooks', action) | |
304 | - for extension in ('', '.exe', '.cmd', '.bat'): | |
305 | - if os.path.exists(script + extension): | |
306 | - script += extension | |
307 | - break | |
308 | - else: | |
309 | - self.env.log.error("Error in ticket workflow config; could not find external command to run for %s in %s" % (action, os.path.join(self.env.path, 'hooks'))) | |
310 | - return | |
311 | - retval = call([script, str(ticket.id), req.authname]) | |
312 | - if retval: | |
313 | - self.env.log.error("External script %r exited with return code %s." % (script, retval)) | |
314 | - | |
315 | - | |
316 | -class TicketWorkflowOpTriage(TicketWorkflowOpBase): | |
317 | - """Action to split a workflow based on a field | |
318 | - | |
319 | - <someaction> = somestatus -> * | |
320 | - <someaction>.operations = triage | |
321 | - <someaction>.triage_field = type | |
322 | - <someaction>.traige_split = defect -> new_defect, task -> new_task, enhancement -> new_enhancement | |
323 | - | |
324 | - Don't forget to add the `TicketWorkflowOpTriage` to the workflow option in | |
325 | - [ticket]. | |
326 | - If there is no workflow option, the line will look like this: | |
327 | - | |
328 | - workflow = ConfigurableTicketWorkflow,TicketWorkflowOpTriage | |
329 | - """ | |
330 | - | |
331 | - _op_name = 'triage' | |
332 | - | |
333 | - # ITicketActionController methods | |
334 | - | |
335 | - def render_ticket_action_control(self, req, ticket, action): | |
336 | - """Returns the action control""" | |
337 | - actions = ConfigurableTicketWorkflow(self.env).actions | |
338 | - label = actions[action]['name'] | |
339 | - new_status = self._new_status(ticket, action) | |
340 | - if new_status != ticket['status']: | |
341 | - hint = 'The status will change to %s.' % new_status | |
342 | - else: | |
343 | - hint = '' | |
344 | - control = tag('') | |
345 | - return (label, control, hint) | |
346 | - | |
347 | - def get_ticket_changes(self, req, ticket, action): | |
348 | - """Returns the change of status.""" | |
349 | - return {'status': self._new_status(ticket, action)} | |
350 | - | |
351 | - def _new_status(self, ticket, action): | |
352 | - """Determines the new status""" | |
353 | - field = self.config.get('ticket-workflow', | |
354 | - action + '.triage_field').strip() | |
355 | - transitions = self.config.get('ticket-workflow', | |
356 | - action + '.triage_split').strip() | |
357 | - for transition in [x.strip() for x in transitions.split(',')]: | |
358 | - value, status = [y.strip() for y in transition.split('->')] | |
359 | - if value == ticket[field].strip(): | |
360 | - break | |
361 | - else: | |
362 | - self.env.log.error("Bad configuration for 'triage' operation in action '%s'" % action) | |
363 | - status = 'new' | |
364 | - return status | |
365 | - | |
366 | - | |
367 | -class TicketWorkflowOpXRef(TicketWorkflowOpBase): | |
368 | - """Adds a cross reference to another ticket | |
369 | - | |
370 | - <someaction>.operations = xref | |
371 | - <someaction>.xref = "Ticket %s is related to this ticket" | |
372 | - <someaction>.xref_local = "Ticket %s was marked as related to this ticket" | |
373 | - <someaction>.xref_hint = "The specified ticket will be cross-referenced with this ticket" | |
374 | - | |
375 | - The example values shown are the default values. | |
376 | - Don't forget to add the `TicketWorkflowOpXRef` to the workflow | |
377 | - option in [ticket]. | |
378 | - If there is no workflow option, the line will look like this: | |
379 | - | |
380 | - workflow = ConfigurableTicketWorkflow,TicketWorkflowOpXRef | |
381 | - """ | |
382 | - | |
383 | - _op_name = 'xref' | |
384 | - | |
385 | - # ITicketActionController methods | |
386 | - | |
387 | - def render_ticket_action_control(self, req, ticket, action): | |
388 | - """Returns the action control""" | |
389 | - id = 'action_%s_xref' % action | |
390 | - ticketnum = req.args.get(id, '') | |
391 | - actions = ConfigurableTicketWorkflow(self.env).actions | |
392 | - label = actions[action]['name'] | |
393 | - hint = actions[action].get('xref_hint', | |
394 | - 'The specified ticket will be cross-referenced with this ticket') | |
395 | - control = tag.input(type='text', id=id, name=id, value=ticketnum) | |
396 | - return (label, control, hint) | |
397 | - | |
398 | - def get_ticket_changes(self, req, ticket, action): | |
399 | - """Returns no changes.""" | |
400 | - return {} | |
401 | - | |
402 | - def apply_action_side_effects(self, req, ticket, action): | |
403 | - """Add a cross-reference comment to the other ticket""" | |
404 | - # TODO: This needs a lot more error checking. | |
405 | - id = 'action_%s_xref' % action | |
406 | - ticketnum = req.args.get(id).strip('#') | |
407 | - actions = ConfigurableTicketWorkflow(self.env).actions | |
408 | - author = req.authname | |
409 | - | |
410 | - # Add a comment to the "remote" ticket to indicate this ticket is | |
411 | - # related to it. | |
412 | - format_string = actions[action].get('xref', | |
413 | - 'Ticket %s is related to this ticket') | |
414 | - comment = format_string % ('#%s' % ticket.id) | |
415 | - # FIXME: This assumes the referenced ticket exists. | |
416 | - xticket = model.Ticket(self.env, ticketnum) | |
417 | - # FIXME: We _assume_ we have sufficient permissions to comment on the | |
418 | - # other ticket. | |
419 | - xticket.save_changes(author, comment) | |
420 | - | |
421 | - # Add a comment to this ticket to indicate that the "remote" ticket is | |
422 | - # related to it. (But only if <action>.xref_local was set in the | |
423 | - # config.) | |
424 | - format_string = actions[action].get('xref_local', | |
425 | - 'Ticket %s was marked as related to this ticket') | |
426 | - if format_string: | |
427 | - comment = format_string % ('#%s' % ticketnum) | |
428 | - time.sleep(1) # FIXME: Hack around IntegrityError | |
429 | - # HACK: Grab a new ticket object to avoid getting | |
430 | - # "OperationalError: no such column: new" | |
431 | - xticket = model.Ticket(self.env, ticket.id) | |
432 | - xticket.save_changes(author, comment) | |
433 | - | |
434 | - | |
435 | -class TicketWorkflowOpResetMilestone(TicketWorkflowOpBase): | |
436 | - """Resets the ticket milestone if it is assigned to a completed milestone. | |
437 | - This is useful for reopen operations. | |
438 | - | |
439 | - reopened = closed -> reopened | |
440 | - reopened.name = Reopened | |
441 | - reopened.operations = reset_milestone | |
442 | - | |
443 | - | |
444 | - Don't forget to add the `TicketWorkflowOpResetMilestone` to the workflow | |
445 | - option in [ticket]. | |
446 | - If there is no workflow option, the line will look like this: | |
447 | - | |
448 | - workflow = ConfigurableTicketWorkflow,TicketWorkflowOpResetMilestone | |
449 | - """ | |
450 | - | |
451 | - _op_name = 'reset_milestone' | |
452 | - | |
453 | - # ITicketActionController methods | |
454 | - | |
455 | - def render_ticket_action_control(self, req, ticket, action): | |
456 | - """Returns the action control""" | |
457 | - actions = ConfigurableTicketWorkflow(self.env).actions | |
458 | - label = actions[action]['name'] | |
459 | - # check if the assigned milestone has been completed | |
460 | - milestone = Milestone(self.env,ticket['milestone']) | |
461 | - if milestone.is_completed: | |
462 | - hint = 'The milestone will be reset' | |
463 | - else: | |
464 | - hint = '' | |
465 | - control = tag('') | |
466 | - return (label, control, hint) | |
467 | - | |
468 | - def get_ticket_changes(self, req, ticket, action): | |
469 | - """Returns the change of milestone, if needed.""" | |
470 | - milestone = Milestone(self.env,ticket['milestone']) | |
471 | - if milestone.is_completed: | |
472 | - return {'milestone': ''} | |
473 | - return {} | |
1 | +"""Trac plugin that provides a number of advanced operations for customizable | |
2 | +workflows. | |
3 | +""" | |
4 | + | |
5 | +import os | |
6 | +import time | |
7 | +from datetime import datetime | |
8 | +from subprocess import call | |
9 | +from genshi.builder import tag | |
10 | + | |
11 | +from trac.core import implements, Component | |
12 | +from trac.ticket import model | |
13 | +from trac.ticket.api import ITicketActionController, TicketSystem | |
14 | +from trac.ticket.default_workflow import ConfigurableTicketWorkflow | |
15 | +from trac.ticket.model import Milestone | |
16 | +from trac.ticket.notification import TicketNotifyEmail | |
17 | +from trac.resource import ResourceNotFound | |
18 | +from trac.util.datefmt import utc | |
19 | +from trac.web.chrome import add_warning | |
20 | + | |
21 | + | |
22 | +class TicketWorkflowOpBase(Component): | |
23 | + """Abstract base class for 'simple' ticket workflow operations.""" | |
24 | + | |
25 | + implements(ITicketActionController) | |
26 | + abstract = True | |
27 | + | |
28 | + _op_name = None # Must be specified. | |
29 | + | |
30 | + def get_configurable_workflow(self): | |
31 | + controllers = TicketSystem(self.env).action_controllers | |
32 | + for controller in controllers: | |
33 | + if isinstance(controller, ConfigurableTicketWorkflow): | |
34 | + return controller | |
35 | + return ConfigurableTicketWorkflow(self.env) | |
36 | + | |
37 | + # ITicketActionController methods | |
38 | + | |
39 | + def get_ticket_actions(self, req, ticket): | |
40 | + """Finds the actions that use this operation""" | |
41 | + controller = self.get_configurable_workflow() | |
42 | + return controller.get_actions_by_operation_for_req(req, ticket, | |
43 | + self._op_name) | |
44 | + | |
45 | + def get_all_status(self): | |
46 | + """Provide any additional status values""" | |
47 | + # We don't have anything special here; the statuses will be recognized | |
48 | + # by the default controller. | |
49 | + return [] | |
50 | + | |
51 | + # This should most likely be overridden to be more functional | |
52 | + def render_ticket_action_control(self, req, ticket, action): | |
53 | + """Returns the action control""" | |
54 | + actions = self.get_configurable_workflow().actions | |
55 | + label = actions[action]['name'] | |
56 | + return (label, tag(''), '') | |
57 | + | |
58 | + def get_ticket_changes(self, req, ticket, action): | |
59 | + """Must be implemented in subclasses""" | |
60 | + raise NotImplementedError | |
61 | + | |
62 | + def apply_action_side_effects(self, req, ticket, action): | |
63 | + """No side effects""" | |
64 | + pass | |
65 | + | |
66 | + | |
67 | +class TicketWorkflowOpOwnerReporter(TicketWorkflowOpBase): | |
68 | + """Sets the owner to the reporter of the ticket. | |
69 | + | |
70 | + needinfo = * -> needinfo | |
71 | + needinfo.name = Need info | |
72 | + needinfo.operations = set_owner_to_reporter | |
73 | + | |
74 | + | |
75 | + Don't forget to add the `TicketWorkflowOpOwnerReporter` to the workflow | |
76 | + option in [ticket]. | |
77 | + If there is no workflow option, the line will look like this: | |
78 | + | |
79 | + workflow = ConfigurableTicketWorkflow,TicketWorkflowOpOwnerReporter | |
80 | + """ | |
81 | + | |
82 | + _op_name = 'set_owner_to_reporter' | |
83 | + | |
84 | + # ITicketActionController methods | |
85 | + | |
86 | + def render_ticket_action_control(self, req, ticket, action): | |
87 | + """Returns the action control""" | |
88 | + actions = self.get_configurable_workflow().actions | |
89 | + label = actions[action]['name'] | |
90 | + hint = 'The owner will change to %s' % ticket['reporter'] | |
91 | + control = tag('') | |
92 | + return (label, control, hint) | |
93 | + | |
94 | + def get_ticket_changes(self, req, ticket, action): | |
95 | + """Returns the change of owner.""" | |
96 | + return {'owner': ticket['reporter']} | |
97 | + | |
98 | + | |
99 | +class TicketWorkflowOpOwnerComponent(TicketWorkflowOpBase): | |
100 | + """Sets the owner to the default owner for the component. | |
101 | + | |
102 | + <someaction>.operations = set_owner_to_component_owner | |
103 | + | |
104 | + Don't forget to add the `TicketWorkflowOpOwnerComponent` to the workflow | |
105 | + option in [ticket]. | |
106 | + If there is no workflow option, the line will look like this: | |
107 | + | |
108 | + workflow = ConfigurableTicketWorkflow,TicketWorkflowOpOwnerComponent | |
109 | + """ | |
110 | + | |
111 | + _op_name = 'set_owner_to_component_owner' | |
112 | + | |
113 | + # ITicketActionController methods | |
114 | + | |
115 | + def render_ticket_action_control(self, req, ticket, action): | |
116 | + """Returns the action control""" | |
117 | + actions = self.get_configurable_workflow().actions | |
118 | + label = actions[action]['name'] | |
119 | + hint = 'The owner will change to %s' % self._new_owner(ticket) | |
120 | + control = tag('') | |
121 | + return (label, control, hint) | |
122 | + | |
123 | + def get_ticket_changes(self, req, ticket, action): | |
124 | + """Returns the change of owner.""" | |
125 | + return {'owner': self._new_owner(ticket)} | |
126 | + | |
127 | + def _new_owner(self, ticket): | |
128 | + """Determines the new owner""" | |
129 | + component = model.Component(self.env, name=ticket['component']) | |
130 | + self.env.log.debug("component %s, owner %s" % (component, component.owner)) | |
131 | + return component.owner | |
132 | + | |
133 | + | |
134 | +class TicketWorkflowOpOwnerField(TicketWorkflowOpBase): | |
135 | + """Sets the owner to the value of a ticket field | |
136 | + | |
137 | + <someaction>.operations = set_owner_to_field | |
138 | + <someaction>.set_owner_to_field = myfield | |
139 | + | |
140 | + Don't forget to add the `TicketWorkflowOpOwnerField` to the workflow | |
141 | + option in [ticket]. | |
142 | + If there is no workflow option, the line will look like this: | |
143 | + | |
144 | + workflow = ConfigurableTicketWorkflow,TicketWorkflowOpOwnerField | |
145 | + """ | |
146 | + | |
147 | + _op_name = 'set_owner_to_field' | |
148 | + | |
149 | + # ITicketActionController methods | |
150 | + | |
151 | + def render_ticket_action_control(self, req, ticket, action): | |
152 | + """Returns the action control""" | |
153 | + actions = self.get_configurable_workflow().actions | |
154 | + label = actions[action]['name'] | |
155 | + hint = 'The owner will change to %s' % self._new_owner(action, ticket) | |
156 | + control = tag('') | |
157 | + return (label, control, hint) | |
158 | + | |
159 | + def get_ticket_changes(self, req, ticket, action): | |
160 | + """Returns the change of owner.""" | |
161 | + return {'owner': self._new_owner(action, ticket)} | |
162 | + | |
163 | + def _new_owner(self, action, ticket): | |
164 | + """Determines the new owner""" | |
165 | + # Should probably do some sanity checking... | |
166 | + field = self.config.get('ticket-workflow', | |
167 | + action + '.' + self._op_name).strip() | |
168 | + return ticket[field] | |
169 | + | |
170 | + | |
171 | +class TicketWorkflowOpOwnerPrevious(TicketWorkflowOpBase): | |
172 | + """Sets the owner to the previous owner | |
173 | + | |
174 | + Don't forget to add the `TicketWorkflowOpOwnerPrevious` to the workflow | |
175 | + option in [ticket]. | |
176 | + If there is no workflow option, the line will look like this: | |
177 | + | |
178 | + workflow = ConfigurableTicketWorkflow,TicketWorkflowOpOwnerPrevious | |
179 | + """ | |
180 | + | |
181 | + _op_name = 'set_owner_to_previous' | |
182 | + | |
183 | + # ITicketActionController methods | |
184 | + | |
185 | + def render_ticket_action_control(self, req, ticket, action): | |
186 | + """Returns the action control""" | |
187 | + actions = self.get_configurable_workflow().actions | |
188 | + label = actions[action]['name'] | |
189 | + new_owner = self._new_owner(ticket) | |
190 | + if new_owner: | |
191 | + hint = 'The owner will change to %s' % new_owner | |
192 | + else: | |
193 | + hint = 'The owner will be deleted.' | |
194 | + control = tag('') | |
195 | + return (label, control, hint) | |
196 | + | |
197 | + def get_ticket_changes(self, req, ticket, action): | |
198 | + """Returns the change of owner.""" | |
199 | + return {'owner': self._new_owner(ticket)} | |
200 | + | |
201 | + def _new_owner(self, ticket): | |
202 | + """Determines the new owner""" | |
203 | + db = self.env.get_db_cnx() | |
204 | + cursor = db.cursor() | |
205 | + cursor.execute("SELECT oldvalue FROM ticket_change WHERE ticket=%s " \ | |
206 | + "AND field='owner' ORDER BY -time", (ticket.id, )) | |
207 | + row = cursor.fetchone() | |
208 | + if row: | |
209 | + owner = row[0] | |
210 | + else: # The owner has never changed. | |
211 | + owner = ticket['owner'] | |
212 | + return owner | |
213 | + | |
214 | + | |
215 | +class TicketWorkflowOpStatusPrevious(TicketWorkflowOpBase): | |
216 | + """Sets the status to the previous status | |
217 | + | |
218 | + Don't forget to add the `TicketWorkflowOpStatusPrevious` to the workflow | |
219 | + option in [ticket]. | |
220 | + If there is no workflow option, the line will look like this: | |
221 | + | |
222 | + workflow = ConfigurableTicketWorkflow,TicketWorkflowOpStatusPrevious | |
223 | + """ | |
224 | + | |
225 | + _op_name = 'set_status_to_previous' | |
226 | + | |
227 | + # ITicketActionController methods | |
228 | + | |
229 | + def render_ticket_action_control(self, req, ticket, action): | |
230 | + """Returns the action control""" | |
231 | + actions = self.get_configurable_workflow().actions | |
232 | + label = actions[action]['name'] | |
233 | + new_status = self._new_status(ticket) | |
234 | + if new_status != self._old_status(ticket): | |
235 | + hint = 'The status will change to %s' % new_status | |
236 | + else: | |
237 | + hint = '' | |
238 | + control = tag('') | |
239 | + return (label, control, hint) | |
240 | + | |
241 | + def get_ticket_changes(self, req, ticket, action): | |
242 | + """Returns the change of status.""" | |
243 | + return {'status': self._new_status(ticket)} | |
244 | + | |
245 | + def _old_status(self, ticket): | |
246 | + """Determines what the ticket state was (is)""" | |
247 | + return ticket._old.get('status', ticket['status']) | |
248 | + | |
249 | + def _new_status(self, ticket): | |
250 | + """Determines the new status""" | |
251 | + db = self.env.get_db_cnx() | |
252 | + cursor = db.cursor() | |
253 | + cursor.execute("SELECT oldvalue FROM ticket_change WHERE ticket=%s " \ | |
254 | + "AND field='status' ORDER BY -time", (ticket.id, )) | |
255 | + row = cursor.fetchone() | |
256 | + if row: | |
257 | + status = row[0] | |
258 | + else: # The status has never changed. | |
259 | + status = 'new' | |
260 | + return status | |
261 | + | |
262 | + | |
263 | +class TicketWorkflowOpRunExternal(TicketWorkflowOpBase): | |
264 | + """Action to allow running an external command as a side-effect. | |
265 | + | |
266 | + If it is a lengthy task, it should daemonize so the webserver can get back | |
267 | + to doing its thing. If the script exits with a non-zero return code, an | |
268 | + error will be logged to the Trac log. | |
269 | + The plugin will look for a script named <tracenv>/hooks/<someaction>, and | |
270 | + will pass it 2 parameters: the ticket number, and the user. | |
271 | + | |
272 | + <someaction>.operations = run_external | |
273 | + <someaction>.run_external = Hint for the user | |
274 | + | |
275 | + Don't forget to add the `TicketWorkflowOpRunExternal` to the workflow | |
276 | + option in [ticket]. | |
277 | + If there is no workflow option, the line will look like this: | |
278 | + | |
279 | + workflow = ConfigurableTicketWorkflow,TicketWorkflowOpRunExternal | |
280 | + """ | |
281 | + | |
282 | + implements(ITicketActionController) | |
283 | + | |
284 | + # ITicketActionController methods | |
285 | + | |
286 | + def get_ticket_actions(self, req, ticket): | |
287 | + """Finds the actions that use this operation""" | |
288 | + controller = self.get_configurable_workflow() | |
289 | + return controller.get_actions_by_operation_for_req(req, ticket, | |
290 | + 'run_external') | |
291 | + | |
292 | + def get_all_status(self): | |
293 | + """Provide any additional status values""" | |
294 | + # We don't have anything special here; the statuses will be recognized | |
295 | + # by the default controller. | |
296 | + return [] | |
297 | + | |
298 | + def render_ticket_action_control(self, req, ticket, action): | |
299 | + """Returns the action control""" | |
300 | + actions = self.get_configurable_workflow().actions | |
301 | + label = actions[action]['name'] | |
302 | + hint = self.config.get('ticket-workflow', | |
303 | + action + '.run_external').strip() | |
304 | + if hint is None: | |
305 | + hint = "Will run external script." | |
306 | + return (label, tag(''), hint) | |
307 | + | |
308 | + def get_ticket_changes(self, req, ticket, action): | |
309 | + """No changes to the ticket""" | |
310 | + return {} | |
311 | + | |
312 | + def apply_action_side_effects(self, req, ticket, action): | |
313 | + """Run the external script""" | |
314 | + print "running external script for %s" % action | |
315 | + script = os.path.join(self.env.path, 'hooks', action) | |
316 | + for extension in ('', '.exe', '.cmd', '.bat'): | |
317 | + if os.path.exists(script + extension): | |
318 | + script += extension | |
319 | + break | |
320 | + else: | |
321 | + self.env.log.error("Error in ticket workflow config; could not find external command to run for %s in %s" % (action, os.path.join(self.env.path, 'hooks'))) | |
322 | + return | |
323 | + retval = call([script, str(ticket.id), req.authname]) | |
324 | + if retval: | |
325 | + self.env.log.error("External script %r exited with return code %s." % (script, retval)) | |
326 | + | |
327 | + | |
328 | +class TicketWorkflowOpTriage(TicketWorkflowOpBase): | |
329 | + """Action to split a workflow based on a field | |
330 | + | |
331 | + <someaction> = somestatus -> * | |
332 | + <someaction>.operations = triage | |
333 | + <someaction>.triage_field = type | |
334 | + <someaction>.traige_split = defect -> new_defect, task -> new_task, enhancement -> new_enhancement | |
335 | + | |
336 | + Don't forget to add the `TicketWorkflowOpTriage` to the workflow option in | |
337 | + [ticket]. | |
338 | + If there is no workflow option, the line will look like this: | |
339 | + | |
340 | + workflow = ConfigurableTicketWorkflow,TicketWorkflowOpTriage | |
341 | + """ | |
342 | + | |
343 | + _op_name = 'triage' | |
344 | + | |
345 | + # ITicketActionController methods | |
346 | + | |
347 | + def render_ticket_action_control(self, req, ticket, action): | |
348 | + """Returns the action control""" | |
349 | + actions = self.get_configurable_workflow().actions | |
350 | + label = actions[action]['name'] | |
351 | + new_status = self._new_status(ticket, action) | |
352 | + if new_status != ticket['status']: | |
353 | + hint = 'The status will change to %s.' % new_status | |
354 | + else: | |
355 | + hint = '' | |
356 | + control = tag('') | |
357 | + return (label, control, hint) | |
358 | + | |
359 | + def get_ticket_changes(self, req, ticket, action): | |
360 | + """Returns the change of status.""" | |
361 | + return {'status': self._new_status(ticket, action)} | |
362 | + | |
363 | + def _new_status(self, ticket, action): | |
364 | + """Determines the new status""" | |
365 | + field = self.config.get('ticket-workflow', | |
366 | + action + '.triage_field').strip() | |
367 | + transitions = self.config.get('ticket-workflow', | |
368 | + action + '.triage_split').strip() | |
369 | + for transition in [x.strip() for x in transitions.split(',')]: | |
370 | + value, status = [y.strip() for y in transition.split('->')] | |
371 | + if value == ticket[field].strip(): | |
372 | + break | |
373 | + else: | |
374 | + self.env.log.error("Bad configuration for 'triage' operation in action '%s'" % action) | |
375 | + status = 'new' | |
376 | + return status | |
377 | + | |
378 | + | |
379 | +class TicketWorkflowOpXRef(TicketWorkflowOpBase): | |
380 | + """Adds a cross reference to another ticket | |
381 | + | |
382 | + <someaction>.operations = xref | |
383 | + <someaction>.xref = "Ticket %s is related to this ticket" | |
384 | + <someaction>.xref_local = "Ticket %s was marked as related to this ticket" | |
385 | + <someaction>.xref_hint = "The specified ticket will be cross-referenced with this ticket" | |
386 | + | |
387 | + The example values shown are the default values. | |
388 | + Don't forget to add the `TicketWorkflowOpXRef` to the workflow | |
389 | + option in [ticket]. | |
390 | + If there is no workflow option, the line will look like this: | |
391 | + | |
392 | + workflow = ConfigurableTicketWorkflow,TicketWorkflowOpXRef | |
393 | + """ | |
394 | + | |
395 | + _op_name = 'xref' | |
396 | + | |
397 | + # ITicketActionController methods | |
398 | + | |
399 | + def render_ticket_action_control(self, req, ticket, action): | |
400 | + """Returns the action control""" | |
401 | + id = 'action_%s_xref' % action | |
402 | + ticketnum = req.args.get(id, '') | |
403 | + actions = self.get_configurable_workflow().actions | |
404 | + label = actions[action]['name'] | |
405 | + hint = actions[action].get('xref_hint', | |
406 | + 'The specified ticket will be cross-referenced with this ticket') | |
407 | + control = tag.input(type='text', id=id, name=id, value=ticketnum) | |
408 | + return (label, control, hint) | |
409 | + | |
410 | + def get_ticket_changes(self, req, ticket, action): | |
411 | + # WARNING: Directly modifying the ticket in this method breaks the | |
412 | + # intent of this method. But it does accomplish the desired goal. | |
413 | + if not 'preview' in req.args: | |
414 | + id = 'action_%s_xref' % action | |
415 | + ticketnum = req.args.get(id).strip('#') | |
416 | + | |
417 | + try: | |
418 | + xticket = model.Ticket(self.env, ticketnum) | |
419 | + except ValueError: | |
420 | + req.args['preview'] = True | |
421 | + add_warning(req, 'The cross-referenced ticket number "%s" was not a number' % ticketnum) | |
422 | + return {} | |
423 | + except ResourceNotFound, e: | |
424 | + #put in preview mode to prevent ticket being saved | |
425 | + req.args['preview'] = True | |
426 | + add_warning(req, "Unable to cross-reference Ticket #%s (%s)." % (ticketnum, e.message)) | |
427 | + return {} | |
428 | + | |
429 | + oldcomment = req.args.get('comment') | |
430 | + actions = self.get_configurable_workflow().actions | |
431 | + format_string = actions[action].get('xref_local', | |
432 | + 'Ticket %s was marked as related to this ticket') | |
433 | + # Add a comment to this ticket to indicate that the "remote" ticket is | |
434 | + # related to it. (But only if <action>.xref_local was set in the | |
435 | + # config.) | |
436 | + if format_string: | |
437 | + comment = format_string % ('#%s' % ticketnum) | |
438 | + req.args['comment'] = "%s%s%s" % \ | |
439 | + (comment, oldcomment and "[[BR]]" or "", oldcomment or "") | |
440 | + | |
441 | + """Returns no changes.""" | |
442 | + return {} | |
443 | + | |
444 | + def apply_action_side_effects(self, req, ticket, action): | |
445 | + """Add a cross-reference comment to the other ticket""" | |
446 | + # TODO: This needs a lot more error checking. | |
447 | + id = 'action_%s_xref' % action | |
448 | + ticketnum = req.args.get(id).strip('#') | |
449 | + actions = self.get_configurable_workflow().actions | |
450 | + author = req.authname | |
451 | + | |
452 | + # Add a comment to the "remote" ticket to indicate this ticket is | |
453 | + # related to it. | |
454 | + format_string = actions[action].get('xref', | |
455 | + 'Ticket %s is related to this ticket') | |
456 | + comment = format_string % ('#%s' % ticket.id) | |
457 | + # FIXME: we need a cnum to avoid messing up | |
458 | + xticket = model.Ticket(self.env, ticketnum) | |
459 | + # FIXME: We _assume_ we have sufficient permissions to comment on the | |
460 | + # other ticket. | |
461 | + now = datetime.now(utc) | |
462 | + xticket.save_changes(author, comment, now) | |
463 | + | |
464 | + #Send notification on the other ticket | |
465 | + try: | |
466 | + tn = TicketNotifyEmail(self.env) | |
467 | + tn.notify(xticket, newticket=False, modtime=now) | |
468 | + except Exception, e: | |
469 | + self.log.exception("Failure sending notification on change to " | |
470 | + "ticket #%s: %s" % (ticketnum, e)) | |
471 | + | |
472 | + | |
473 | +class TicketWorkflowOpResetMilestone(TicketWorkflowOpBase): | |
474 | + """Resets the ticket milestone if it is assigned to a completed milestone. | |
475 | + This is useful for reopen operations. | |
476 | + | |
477 | + reopened = closed -> reopened | |
478 | + reopened.name = Reopened | |
479 | + reopened.operations = reset_milestone | |
480 | + | |
481 | + | |
482 | + Don't forget to add the `TicketWorkflowOpResetMilestone` to the workflow | |
483 | + option in [ticket]. | |
484 | + If there is no workflow option, the line will look like this: | |
485 | + | |
486 | + workflow = ConfigurableTicketWorkflow,TicketWorkflowOpResetMilestone | |
487 | + """ | |
488 | + | |
489 | + _op_name = 'reset_milestone' | |
490 | + | |
491 | + # ITicketActionController methods | |
492 | + | |
493 | + def render_ticket_action_control(self, req, ticket, action): | |
494 | + """Returns the action control""" | |
495 | + actions = self.get_configurable_workflow().actions | |
496 | + label = actions[action]['name'] | |
497 | + # check if the assigned milestone has been completed | |
498 | + milestone = Milestone(self.env,ticket['milestone']) | |
499 | + if milestone.is_completed: | |
500 | + hint = 'The milestone will be reset' | |
501 | + else: | |
502 | + hint = '' | |
503 | + control = tag('') | |
504 | + return (label, control, hint) | |
505 | + | |
506 | + def get_ticket_changes(self, req, ticket, action): | |
507 | + """Returns the change of milestone, if needed.""" | |
508 | + milestone = Milestone(self.env,ticket['milestone']) | |
509 | + if milestone.is_completed: | |
510 | + return {'milestone': ''} | |
511 | + return {} |
@@ -1,3 +1,3 @@ | ||
1 | -[egg_info] | |
2 | -tag_build = dev | |
3 | -tag_svn_revision = true | |
1 | +[egg_info] | |
2 | +tag_build = dev | |
3 | +tag_svn_revision = true |
@@ -1,20 +1,20 @@ | ||
1 | -#!/usr/bin/env python | |
2 | - | |
3 | -from setuptools import setup, find_packages | |
4 | - | |
5 | -setup( | |
6 | - name='AdvancedTicketWorkflowPlugin', | |
7 | - version='0.10', | |
8 | - author = 'Eli Carter', | |
9 | - author_email = 'elicarter@retracile.net', | |
10 | - license='BSD', | |
11 | - description = 'Advanced workflow operations Trac plugin', | |
12 | - long_description = 'Provides more advanced workflow operations', | |
13 | - url = 'http://trac-hacks.org/wiki/AdvancedTicketWorkflowPlugin', | |
14 | - | |
15 | - packages = find_packages(), | |
16 | - package_data = {}, | |
17 | - entry_points = {'trac.plugins':['advancedworkflow.controller = advancedworkflow.controller']}, | |
18 | - install_requires = [], | |
19 | - #zip_safe = False, | |
20 | -) | |
1 | +#!/usr/bin/env python | |
2 | + | |
3 | +from setuptools import setup, find_packages | |
4 | + | |
5 | +setup( | |
6 | + name='AdvancedTicketWorkflowPlugin', | |
7 | + version='0.11', | |
8 | + author = 'Eli Carter', | |
9 | + author_email = 'elicarter@retracile.net', | |
10 | + license='BSD', | |
11 | + description = 'Advanced workflow operations Trac plugin', | |
12 | + long_description = 'Provides more advanced workflow operations for Trac 0.12', | |
13 | + url = 'http://trac-hacks.org/wiki/AdvancedTicketWorkflowPlugin', | |
14 | + | |
15 | + packages = find_packages(), | |
16 | + package_data = {}, | |
17 | + entry_points = {'trac.plugins':['advancedworkflow.controller = advancedworkflow.controller']}, | |
18 | + install_requires = [], | |
19 | + #zip_safe = False, | |
20 | +) |
@@ -0,0 +1,294 @@ | ||
1 | +#!/usr/bin/env python | |
2 | +# | |
3 | +# This script is run after receive-pack has accepted a pack and the | |
4 | +# repository has been updated. It is passed arguments in through stdin | |
5 | +# in the form | |
6 | +# <oldrev> <newrev> <refname> | |
7 | +# For example: | |
8 | +# aa453216d1b3e49e7f6f98441fa56946ddcd6a20 68f7abf4e6f922807889f52bc043ecd31b79f814 refs/heads/master | |
9 | + | |
10 | +# GOAL: Prevent patches that have appeared in one branch from | |
11 | +# reposting to trac when they are moved to another branch | |
12 | +# (this was causing duplicate comments / time from topic branches | |
13 | +# being merged into main | |
14 | + | |
15 | +# This specific script will query the repository trying to isolate what | |
16 | +# in this receive is a new commit that the repository has not yet | |
17 | +# seen. It does this by a big call to git rev-parse, including revs | |
18 | +# that are now reachable, excluding everything else (tags, heads, | |
19 | +# oldrevs). | |
20 | + | |
21 | +# http://www.kernel.org/pub/software/scm/git/docs/git-rev-list.html | |
22 | + | |
23 | +# Once it has isolated what is new it posts those to trac. | |
24 | + | |
25 | + | |
26 | +import os, os.path, sys,logging, getpass, optparse, re | |
27 | +import subprocess, threading, time, errno | |
28 | +from optparse import OptionParser | |
29 | +from subprocess import PIPE | |
30 | + | |
31 | +TRAC_POST_COMMIT = "/home/ACCELERATION/russ/trac-dev/TandE/trac0.12/scripts/trac-post-commit.py" | |
32 | + | |
33 | + | |
34 | + | |
35 | + | |
36 | +logdir=os.getenv("LOGDIR") or "/var/log/commit-hooks" | |
37 | +log = logging.getLogger('gpr') | |
38 | + | |
39 | +## Fn to easy working with remote processes | |
40 | +def capturedCall(cmd, **kwargs) : | |
41 | + """Do the equivelent of the subprocess.call except | |
42 | + log the stderr and stdout where appropriate.""" | |
43 | + p= capturedPopen(cmd,**kwargs) | |
44 | + rc = p.wait() | |
45 | + #this is a cheap attempt to make sure the monitors | |
46 | + #are scheduled and hopefully finished. | |
47 | + time.sleep(0.01) | |
48 | + time.sleep(0.01) | |
49 | + return rc | |
50 | + | |
51 | +#be warned, if you see your pipelines hanging: | |
52 | +#http://old.nabble.com/subprocess.Popen-pipeline-bug--td16026600.html | |
53 | +#close_fds=True | |
54 | + | |
55 | +## Fn to easy working with remote processes | |
56 | +def capturedPopen(cmd, stdin=None, stdout=None, stderr=None, | |
57 | + logger=log,cd=None, | |
58 | + stdout_level=logging.INFO, | |
59 | + stderr_level=logging.WARNING, **kwargs) : | |
60 | + """Equivalent to subprocess.Popen except log stdout and stderr | |
61 | + where appropriate. Also log the command being called.""" | |
62 | + #we use None as sigil values for stdin,stdout,stderr above so we | |
63 | + # can distinguish from the caller passing in Pipe. | |
64 | + if(logger): | |
65 | + #if we are logging, record the command we're running, | |
66 | + #trying to strip out passwords. | |
67 | + logger.debug("Running cmd: %s", | |
68 | + isinstance(cmd,str) and cmd | |
69 | + or subprocess.list2cmdline([i for i in cmd | |
70 | + if not i.startswith('-p')])) | |
71 | + | |
72 | + if cd : | |
73 | + #subprocess does this already with the cwd arg, | |
74 | + #convert cd over so as not to break anyone's. | |
75 | + kwargs['cwd']=cd | |
76 | + p = subprocess.Popen(cmd, stdin=stdin, | |
77 | + stdout=(stdout or (logger and PIPE)), | |
78 | + stderr=(stderr or (logger and PIPE)), | |
79 | + **kwargs) | |
80 | + if logger : | |
81 | + def monitor(level, src, name) : | |
82 | + lname = "%s.%s" % (cmd[0], name) | |
83 | + if(hasattr(logger, 'name')) : | |
84 | + lname = "%s.%s" % (logger.name, lname) | |
85 | + sublog = logging.getLogger(lname) | |
86 | + | |
87 | + def tfn() : | |
88 | + l = src.readline() | |
89 | + while l != "": | |
90 | + sublog.log(level,l.strip()) | |
91 | + l = src.readline() | |
92 | + | |
93 | + th = threading.Thread(target=tfn,name=lname) | |
94 | + p.__setattr__("std%s_thread" % name, th) | |
95 | + th.start() | |
96 | + | |
97 | + if stdout == None : monitor(stdout_level, p.stdout,"out") | |
98 | + if stderr == None : monitor(stderr_level, p.stderr,"err") | |
99 | + return p | |
100 | + | |
101 | + | |
102 | + | |
103 | +def gitPopen(gitdir, cmd, **kwargs) : | |
104 | + """Popen git with the given command and the git-dir given. kwargs | |
105 | + are passed onwards to popen.""" | |
106 | + cmd = ["git","--git-dir="+gitdir] + cmd | |
107 | + return capturedPopen(cmd, logger=log, **kwargs) | |
108 | + | |
109 | +def find_all_refs(gitdir) : | |
110 | + "Get a list of all ref names in the git database, i.e. any head or tag name" | |
111 | + git = gitPopen(gitdir, ["show-ref"], stdout=PIPE) | |
112 | + return set(line.split()[1] for line in git.stdout) | |
113 | + | |
114 | + | |
115 | +def new_commits(gitdir, ref_updates) : | |
116 | + """For the given gitdir and list of ref_updates (an array that | |
117 | + holds [oldrev,newrev,refname] arrays) find any commit that is new | |
118 | + to this repo. | |
119 | + | |
120 | + This works primarily by issuing a: | |
121 | + git rev-list new1 ^old1 new2 ^old2 ^refs/tags/foo ^refs/heads/bar | |
122 | + | |
123 | + This function yields commits that are new in the format: | |
124 | + [hash, author, date, message] | |
125 | +""" | |
126 | + #the set of previously reachable roots starts as a list of all | |
127 | + #refs currently known, which is post-receive so we will need to | |
128 | + #remove some from here. Everything left will become ^refs. | |
129 | + prev_roots = find_all_refs(gitdir) | |
130 | + log.debug("Found %s named refs", len(prev_roots)) | |
131 | + | |
132 | + #open the rev-list process and make a writer function to it. | |
133 | + grl = gitPopen(gitdir, ["rev-list","--reverse", "--stdin", | |
134 | + "--pretty=tformat:%an <%ae>%n%ci%n%s%n%+b"], | |
135 | + stdin=PIPE, stdout=PIPE) | |
136 | + def w(ref) : grl.stdin.write(ref + "\n") | |
137 | + | |
138 | + for (old,new,ref) in ref_updates : | |
139 | + #branch deletion: newval is 00000, skip the ref, leave it in | |
140 | + #the list of prev_roots | |
141 | + if re.match("^0+$",new) : continue | |
142 | + | |
143 | + #Include the newrev as now reachable. | |
144 | + w(new) | |
145 | + | |
146 | + #a ref that is being updated should be removed from the | |
147 | + #previous list and ... | |
148 | + prev_roots.discard(ref) | |
149 | + #instead write out the negative line directly. However, if it | |
150 | + #is a new branch (denoted by all 0s) there is no negative to | |
151 | + #include for this ref. | |
152 | + if re.search("[1-9]",old) : | |
153 | + w("^" + old) | |
154 | + else : | |
155 | + log.info("New ref %r", ref) | |
156 | + | |
157 | + | |
158 | + log.debug("After discarding updates, writing %s prev_roots", | |
159 | + len(prev_roots)) | |
160 | + #write lines for (not reachable from anything else') | |
161 | + for ref in prev_roots : w("^" + ref) | |
162 | + grl.stdin.close() | |
163 | + | |
164 | + ### this is a little parser for the format | |
165 | + #commit <hash> | |
166 | + #<Author> | |
167 | + #<Date> | |
168 | + #<msg> | |
169 | + #<blank line> | |
170 | + commit = None | |
171 | + msg = "" | |
172 | + def finish() : | |
173 | + commit.append(msg[:-1]) #-1 to strip one \n from the pair. | |
174 | + log.info("New commit: %r", commit) | |
175 | + return commit | |
176 | + | |
177 | + while True : | |
178 | + line = grl.stdout.readline() | |
179 | + #blank line and exit code set, we're done here. | |
180 | + if line == '' and grl.poll() != None : | |
181 | + if commit: yield finish() | |
182 | + log.debug("Exiting loop: %s", grl.poll()) | |
183 | + break | |
184 | + | |
185 | + m = re.match("commit ([0-9a-f]+)$", line) | |
186 | + if m : #start of a new commit | |
187 | + if commit: yield finish() | |
188 | + log.debug("Starting new commit: %s", m.group(1)) | |
189 | + hash = m.group(1) | |
190 | + author = grl.stdout.readline().strip() | |
191 | + date = grl.stdout.readline().strip() | |
192 | + commit = [hash,author,date] | |
193 | + msg = grl.stdout.readline() | |
194 | + else : | |
195 | + msg += line | |
196 | + | |
197 | +def post(commits, gitdir, cname, trac_env) : | |
198 | + for [rev,author,date,msg] in commits : | |
199 | + #this subprocess uses python logging with the same formatter, | |
200 | + #so tell it not to log, and pass through our streams and its | |
201 | + #logging should just fall in line. | |
202 | + log.debug("Posting %s to trac", rev) | |
203 | + capturedCall(["python", TRAC_POST_COMMIT, | |
204 | + "-p", trac_env or "", | |
205 | + "-r", rev, | |
206 | + "-u", author, | |
207 | + "-m", msg, | |
208 | + cname], | |
209 | + logger=None, | |
210 | + stdout=sys.stdout, | |
211 | + stderr=sys.stderr) | |
212 | + | |
213 | +def process(gitdir, cname, trac_env, ref_updates) : | |
214 | + log.info("Push by %r; CNAME: %r, TRAC_ENV: %r, updating %s refs", | |
215 | + getpass.getuser(), cname, trac_env, len(updates)) | |
216 | + | |
217 | + post(new_commits(gitdir,ref_updates), gitdir, cname, trac_env) | |
218 | + log.info("Finished commit hook loop, git-post-receive") | |
219 | + | |
220 | + | |
221 | + | |
222 | +################################################################# | |
223 | +#### Runtime control | |
224 | + | |
225 | +parser = OptionParser(""" """) | |
226 | +parser.add_option("-v", "--verbose", action="store_true", dest="verbose", | |
227 | + help="Show more verbose log messages.") | |
228 | + | |
229 | + | |
230 | +if __name__ == "__main__": | |
231 | + (options, args) = parser.parse_args() | |
232 | + | |
233 | + #when run as a hook the directory is the git repo. | |
234 | + #either /var/git/ServerManagement.git | |
235 | + #or /var/git/ServerManagement/.git | |
236 | + gitdir = os.getcwd() | |
237 | + | |
238 | + cname = os.getenv("CNAME") | |
239 | + if cname == None : | |
240 | + if len(args) >= 1 : | |
241 | + cname = args.pop(0) | |
242 | + else : | |
243 | + #strip off .git if it is bare or /.git if it is a checkout. | |
244 | + cname = re.sub("/?\.git$","", gitdir) | |
245 | + cname = os.path.basename(cname) | |
246 | + TRAC_ENV = os.getenv("TRAC_ENV") or os.path.join("/var/trac/",cname) | |
247 | + | |
248 | + #### Logging configuration | |
249 | + log.setLevel(logging.DEBUG) | |
250 | + ## log verbosely to a file | |
251 | + logfile=os.path.join(logdir, "%s.git-post-receive.log" % cname) | |
252 | + fh = logging.FileHandler(logfile,mode='a') | |
253 | + fh.setFormatter(logging.Formatter('%(asctime)s %(name)s %(levelname)-8s %(message)s', | |
254 | + datefmt='%Y%m%d %H:%M:%S')) | |
255 | + | |
256 | + ## and to standard error keep the level higher | |
257 | + sh = logging.StreamHandler() | |
258 | + sh.setLevel(options.verbose and logging.DEBUG or logging.INFO) | |
259 | + sh.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)-8s %(message)s", | |
260 | + datefmt='%H:%M:%S')) | |
261 | + | |
262 | + log.addHandler(sh) | |
263 | + log.addHandler(fh) | |
264 | + log.info("----- git-post-receive.py -----") | |
265 | + | |
266 | + #Where will we be posting to? | |
267 | + if not os.path.exists(TRAC_ENV) : | |
268 | + logging.warn("None existant trac_env: %s", TRAC_ENV) | |
269 | + TRAC_ENV = None | |
270 | + #actually read the ref updates from stdin | |
271 | + updates = [line.split() for line in sys.stdin] | |
272 | + process(gitdir, cname, TRAC_ENV, updates) | |
273 | + | |
274 | +# # The MIT License | |
275 | + | |
276 | +# # Copyright (c) 2010 Acceleration.net | |
277 | + | |
278 | +# # Permission is hereby granted, free of charge, to any person obtaining a copy | |
279 | +# # of this software and associated documentation files (the "Software"), to deal | |
280 | +# # in the Software without restriction, including without limitation the rights | |
281 | +# # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
282 | +# # copies of the Software, and to permit persons to whom the Software is | |
283 | +# # furnished to do so, subject to the following conditions: | |
284 | + | |
285 | +# # The above copyright notice and this permission notice shall be included in | |
286 | +# # all copies or substantial portions of the Software. | |
287 | + | |
288 | +# # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
289 | +# # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
290 | +# # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
291 | +# # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
292 | +# # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
293 | +# # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN | |
294 | +# # THE SOFTWARE. |