| 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 | |
|---|
| 10 | import re |
|---|
| 11 | from trac.core import * |
|---|
| 12 | from trac.ticket.model import Ticket |
|---|
| 13 | from trac.ticket.api import TicketSystem |
|---|
| 14 | from trac.config import ListOption, Option, ChoiceOption, BoolOption |
|---|
| 15 | from analyze.analyses import milestone, queue, rollup |
|---|
| 16 | |
|---|
| 17 | class 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 | |
|---|
| 52 | class 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 | |
|---|
| 125 | class 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 | |
|---|
| 160 | class 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 | |
|---|
| 285 | class 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 | |
|---|
| 354 | class 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) |
|---|