source: analyzeplugin/0.12/analyze/analysis.py

Last change on this file was 13992, checked in by Ryan J Ollos, 9 years ago

Changed to 3-Clause BSD license with permission of author. Refs #11832.

  • Property svn:executable set to *
File size: 17.0 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2011-2014 Rob Guttman <guttman@alum.mit.edu>
4# All rights reserved.
5#
6# This software is licensed as described in the file COPYING, which
7# you should have received as part of this distribution.
8#
9
10import re
11from trac.core import *
12from trac.ticket.model import Ticket
13from trac.ticket.api import TicketSystem
14from trac.config import ListOption, Option, ChoiceOption, BoolOption
15from analyze.analyses import milestone, queue, rollup
16
17class IAnalysis(Interface):
18    """An extension point interface for adding analyses.  Each analysis
19    can detect one or more issues in a report.  Each issue can suggest
20    one or more solutions to the user.  The user can select amongst the
21    solutions to fix the issue."""
22
23    def can_analyze(self, report):
24        """Return True if this analysis can analyze the given report."""
25
26    def get_refresh_report(self):
27        """Returns the report (and any params) to refresh upon making one or
28        more fixes.  The default behavior is to refresh the current report."""
29
30    def get_solutions(self, db, args, report):
31        """Return a tuple of an issue description and a dict of its solution
32        dict - or a list of solution dicts - with each dict comprising:
33
34         * name - Text describing the solution
35         * data - any serializable python object that defines the fix/solution
36
37        Any "#<num>" ticket string found in the issue description or name
38        will be converted to its corresponding href.
39
40        If data is a dict of field/value pairs of changes - or a list of
41        these - then the base implementation of fix_issue() will execute
42        the fix upon user command.  Use 'ticket' as the field name for the
43        ticket's id value."""
44
45    def fix_issue(self, db, data, author):
46        """Execute the solution specified by the given data which was
47        previously returned from get_solutions() above.  This method
48        only needs to be defined if the data is not field/value pairs
49        of changes or a list of these - i.e., the fix is more involved."""
50
51
52class Analysis(object):
53    """Abstract class for common analysis properties and utilities."""
54
55    @property
56    def path(self):
57        """Returns the analysis instance's unique path name."""
58        return self.__class__.__name__.lower()
59
60    @property
61    def num(self):
62        """Returns the number of adjacent tickets needed for analysis."""
63        return 1
64
65    @property
66    def title(self):
67        """Returns the analysis class' title used for display purposes.
68        This default implementation returns the analysis's class name
69        with any camel case split into words."""
70        # split CamelCase to Camel Case
71        return self._split_camel_case(self.__class__.__name__)
72
73    def can_analyze(self, report):
74        """Returns True if this analysis can analyze the given report."""
75        return True
76
77    def get_refresh_report(self):
78        """This default behavior refreshes the current report."""
79        return None # default refreshes current report
80
81    def get_solutions(self, db, args, report):
82        raise Exception("Provide this method")
83
84    def fix_issue(self, db, data, author):
85        """This base fix updates a ticket with the field/value pairs in
86        data.  The data object can either be a dict or a list of dicts
87        with the 'ticket' field of each identifying the ticket to change."""
88        if not isinstance(data,list):
89            data = [data]
90
91        # update each ticket
92        for changes in data:
93            ticket = Ticket(self.env, changes['ticket'])
94            del changes['ticket']
95            ticket.populate(changes)
96            ticket.save_changes(author=author, comment='')
97        return None
98
99    # private methods
100    def _capitalize(self, word):
101        if len(word) <= 1:
102            return word.upper()
103        return word[0].upper() + word[1:]
104
105    def _split_camel_case(self, s):
106        return re.sub('((?=[A-Z][a-z])|(?<=[a-z])(?=[A-Z]))', ' ', s).strip()
107
108    def _isint(self, i):
109        try:
110            int(i)
111        except (ValueError, TypeError):
112            return False
113        else:
114            return True
115
116    def _isnum(self, i):
117        try:
118            float(i)
119        except (ValueError, TypeError):
120            return False
121        else:
122            return True
123
124
125class MilestoneDependencyAnalysis(Component, Analysis):
126    """Building on mastertickets' blockedby relationships, this analyzes
127    a report's tickets for one issue:
128
129     1. Detects when dependent tickets are not scheduled in or before
130        the master ticket's milestone.
131
132    This includes when dependent tickets are not yet scheduled or when
133    the scheduled milestone has no due date.  Specify which reports can
134    be analyzed with the milestone_reports option:
135
136     [analyze]
137     milestone_reports = 1,2
138     on_change_clear = version
139
140    In the example above, this analysis is only available for reports
141    1 and 2.  Also, if a ticket's milestone gets fixed, then its version
142    field will get cleared as defined by the on_change_clear option above.
143    """
144
145    implements(IAnalysis)
146
147    reports = ListOption('analyze', 'milestone_reports', default=[],
148            doc="Reports that can be milestone dependency analyzed.")
149    on_change_clear = ListOption('analyze', 'on_change_clear', default=[],
150            doc="Ticket fields to clear on change of milestone.")
151
152    def can_analyze(self, report):
153        return report in self.reports
154
155    def get_solutions(self, db, args, report):
156        args['on_change_clear'] = self.on_change_clear
157        return milestone.get_dependency_solutions(db, args)
158
159
160class QueueDependencyAnalysis(Component, Analysis):
161    """Building on mastertickets' blockedby relationships and the queue's
162    position, this analyzes a report's tickets for two issues:
163
164     1. Detects when dependent tickets are in the wrong queue.
165     2. Detects when dependent tickets' positions are out of order.
166
167    Specify which reports can be analyzed with the queue_reports option:
168
169     [analyze]
170     queue_reports = 1,2,3,9
171
172    In the example above, this analysis is available for reports 1, 2, 3
173    and 9.  If no queue_reports is provided, then the queue's full list of
174    reports will be used instead from the [queues] 'reports' option.
175
176    The queue_fields config option is the list of fields that define
177    a queue.  You can optionally override with a report-specific option:
178
179     [analyze]
180     queue_fields = milestone,queue
181     queue_fields.2 = queue
182     queue_fields.9 = queue,phase!=verifying|readying
183
184    In the example above, reports 1 and 3 are defined by fields 'milestone'
185    and 'queue', report 2 is defined only by field 'queue', and report 9
186    is defined by field 'queue' as well as filtering the 'phase' field.
187
188    The filtering spec should usually match those in the report - i.e., via
189    a pipe-delimited list specify which tickets to include ('=') or not
190    include ('!=') in the analysis."""
191
192    implements(IAnalysis)
193
194    reports1 = ListOption('analyze', 'queue_reports', default=[],
195            doc="Reports that can be queue dependency analyzed.")
196    reports2 = ListOption('queues', 'reports', default=[],
197            doc="Reports that can be queue dependency analyzed.")
198    queue_fields = ListOption('analyze', 'queue_fields', default=[],
199            doc="Ticket fields that define each queue.")
200    audit = ChoiceOption('queues', 'audit', choices=['log','ticket','none'],
201      doc="Record reorderings in log, in ticket, or not at all.")
202
203    def can_analyze(self, report):
204        # fallback to actual queue report list if not made explicit
205        return report in (self.reports1 or self.reports2)
206
207    def _add_args(self, args, report):
208        """Split queue fields into standard and custom."""
209        queue_fields = self.env.config.get('analyze','queue_fields.'+report,
210                        self.queue_fields) # fallback if not report-specific
211        if not isinstance(queue_fields,list):
212            queue_fields = [f.strip() for f in queue_fields.split(',')]
213        args['standard_fields'] = {}
214        args['custom_fields'] = {}
215        for name in queue_fields:
216            vals = None
217            if '=' in name:
218                name,vals = name.split('=',1)
219                not_ = name.endswith('!')
220                if not_:
221                    name = name[:-1]
222                # save 'not' info at end of vals to pop off later
223                vals = [v.strip() for v in vals.split('|')] + [not_]
224            for field in TicketSystem(self.env).get_ticket_fields():
225                if name == field['name']:
226                    if 'custom' in field:
227                        args['custom_fields'][name] = vals
228                    else:
229                        args['standard_fields'][name] = vals
230                    break
231            else:
232                raise Exception("Unknown queue field: %s" % name)
233
234    def get_solutions(self, db, args, report):
235        if not args['col1_value1']:
236            return '',[] # has no position so skip
237        self._add_args(args, report)
238        return queue.get_dependency_solutions(db, args)
239
240    def fix_issue(self, db, data, author):
241        """Honor queues audit config."""
242
243        if not isinstance(data,list):
244            data = [data]
245
246        # find position field
247        for k,v in data[0].items():
248            if k == 'ticket':
249                continue
250            field = k
251            if self.audit == 'ticket' or \
252               field in ('blocking','blockedby') or \
253               any(len(c) != 2 for c in data) or \
254               not self._isint(v): # heuristic for position field
255                return Analysis.fix_issue(self, db, data, author)
256
257        # honor audit config
258        cursor = db.cursor()
259        for changes in data:
260            id = changes['ticket']
261            new_pos = changes[field]
262            cursor.execute("""
263                SELECT value from ticket_custom
264                 WHERE name=%s AND ticket=%s
265                """, (field,id))
266            result = cursor.fetchone()
267            if result:
268                old_pos = result[0]
269                cursor.execute("""
270                    UPDATE ticket_custom SET value=%s
271                     WHERE name=%s AND ticket=%s
272                    """, (new_pos,field,id))
273            else:
274                old_pos = '(none)'
275                cursor.execute("""
276                    INSERT INTO ticket_custom (ticket,name,value)
277                     VALUES (%s,%s,%s)
278                    """, (id,field,new_pos))
279            if self.audit == 'log':
280                self.log.info("%s reordered ticket #%s's %s from %s to %s" \
281                    % (author,id,field,old_pos,new_pos))
282        db.commit()
283
284
285class ProjectQueueAnalysis(QueueDependencyAnalysis):
286    """This analysis builds on mastertickets' blockedby relationships under
287    a parent-child semantics and the queue's position.  This analyzes a
288    report's tickets for three issues:
289
290     1. Detects when dependent tickets are in the wrong queue.
291     2. Detects when dependent tickets have more than one parent.
292     3. Detects when dependent tickets' positions are out of order.
293
294    The last analysis above ensures that the relative ordering of two
295    parents' children match the relative ordering of the parents.  To
296    do this, then children can have only one parent (issue 2 above)
297    else the algorithm will likely thrash.
298
299    Specify which reports can be analyzed with the project_queue option:
300
301     [analyze]
302     project_reports = 12,14
303
304    In the example above, this analysis is available for reports 12 and 14.
305    To differentiate between peer relationships and parent-child
306    relationships of blockedby tickets, parent tickets must have a
307    unique ticket type that is specified in the project_type option (the
308    default is 'epic'):
309
310     [analyze]
311     project_type = epic
312     project_refresh_report = 2
313
314    Tickets listed in project_reports should all be of this type.  If a
315    'project_refresh_report' option is provided, then if there are fixes made,
316    instead of refreshing the current report, it will load the impacted
317    report.  You can also add parameters to that report if desired such as
318    'project_refresh_report = 2?max=1000'.
319    """
320
321    implements(IAnalysis)
322
323    reports = ListOption('analyze', 'project_reports', default=[],
324            doc="Reports that can be queue dependency analyzed.")
325    project_type = Option('analyze', 'project_type', default='epic',
326            doc="Ticket type indicating a project (default is 'epic').")
327    refresh_report = Option('analyze', 'project_refresh_report', default=None,
328            doc="Report being impacted by this report.")
329
330    @property
331    def title(self):
332        title = "%s Queue Analysis" % self._capitalize(self.project_type)
333        return title
334
335    @property
336    def num(self):
337        return 2 # analyze two adjacent projects
338
339    def can_analyze(self, report):
340        return report in self.reports
341
342    def get_refresh_report(self):
343        return self.refresh_report
344
345    def get_solutions(self, db, args, report):
346        if not args['col1_value1'] or not args['col1_value2']:
347            return '',[] # has no position so skip
348        self._add_args(args, report)
349        args['project_type'] = self.project_type
350        args['impacted_report'] = self.project_type
351        return queue.get_project_solutions(db, args)
352
353
354class ProjectRollupAnalysis(Component, Analysis):
355    """This analysis builds on mastertickets' blockedby relationships under
356    a parent-child semantics.  This analysis rolls up field values for each
357    master ticket in a report.  Specify which reports can be analyzed with
358    the rollup_reports option:
359
360     [analyze]
361     rollup_reports = 1,2,3,9
362
363    In the example above, this analysis is available for reports 1, 2, 3
364    and 9.  If no rollup_reports is provided, then the project_reports list
365    is used instead.
366
367    The available rollup stats are sum, min, max, median, mode, and a
368    special 'pivot' analysis.  All but pivot apply to numeric fields, and
369    all but sum apply to select option fields.  Here are several examples
370    of specifying a stat for different fields:
371
372     [analyze]
373     rollup.effort = sum
374     rollup.severity = min
375     rollup.captain = mode
376     rollup.phase = implementation
377
378    In the example above, the project's
379
380     * effort field sums all of its children numeric values
381     * severity field gets set to the minimum (index) value of its children
382     * captain field gets set to the most frequent captain of its children
383     * phase pivots on the 'implementation' select option value
384
385    In brief, the pivot algorithm is as follows (using the option's index):
386
387     * if all values are < the pivot value, then select their max value
388     * else if all are > the pivot value, then select their min value
389     * else select the pivot value
390    """
391
392    implements(IAnalysis)
393
394    reports1 = ListOption('analyze', 'rollup_reports', default=[],
395            doc="Reports that can be project rollup analyzed.")
396    reports2 = ListOption('analyze', 'project_reports', default=[],
397            doc="Reports that can be project rollup analyzed.")
398    project_type = Option('analyze', 'project_type', default='epic',
399            doc="Ticket type indicating a project (default is 'epic').")
400    recurse = BoolOption('analyze', 'rollup_recurse', default=True,
401            doc="Include all dependent tickets recursively in rollups.")
402
403    @property
404    def title(self):
405        title = "%s Rollup Analysis" % self._capitalize(self.project_type)
406        return title
407
408    def can_analyze(self, report):
409        # fallback to project report list if not made explicit
410        return report in (self.reports1 or self.reports2)
411
412    def _add_args(self, args, report):
413        """Process rollup field configs."""
414        args['standard_fields'] = {}
415        args['custom_fields'] = {}
416        for name,stat in self.env.config.options('analyze'):
417            if not name.startswith('rollup.'):
418                continue
419            name = name[7:]
420            rollup = {'stat':stat.strip()}
421            for field in TicketSystem(self.env).get_ticket_fields():
422                if name == field['name']:
423                    rollup['options'] = field.get('options',None)
424                    rollup['numeric'] = True
425                    if rollup['options']:
426                        # check if all options are numeric
427                        for option in rollup['options']:
428                            if not self._isnum(option):
429                                rollup['numeric'] = False
430                                break
431                    if 'custom' in field:
432                        args['custom_fields'][name] = rollup
433                    else:
434                        args['standard_fields'][name] = rollup
435                    break
436            else:
437                raise Exception("Unknown rollup field: %s" % name)
438
439    def get_solutions(self, db, args, report):
440        self._add_args(args, report)
441        if not args['standard_fields'] and not args['custom_fields']:
442            return '',[] # has rollup fields so skip
443        args['project_type'] = self.project_type
444        args['recurse'] = self.recurse
445        return rollup.get_solutions(db, args)
Note: See TracBrowser for help on using the repository browser.