# # act.py - DITrack 'act' command # # Copyright (c) 2006-2007 The DITrack Project, www.ditrack.org. # # $Id: act.py 2279 2007-10-24 05:41:53Z vss $ # $HeadURL: https://127.0.0.1/ditrack/src/tags/0.7/DITrack/Command/act.py $ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # * Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # import copy import email import os import sys # DITrack modules import DITrack.Command.generic import DITrack.Edit import DITrack.UI import DITrack.Util.common class _InconsistencyError(Exception): """ The action would create a database inconsistency. """ def __init__(self, issue_id, message): self.issue_id = issue_id self.message = message class _Driver: """ Class representing a driver: en entity that generates actions to be performed on the issue(s). """ def __init__(self, globals, dbcfg, issues, future_versions, vsets): """ Initializes the driver environment. Parameters are: DBCFG A database configuration object. FUTURE_VERSIONS A list of version strings that can be used as future versions. May be empty if the issues being dealt with don't have common future versions. GLOBALS Globals object. ISSUES A dictionary of issue objects. Keys are issue ids (strings). VSETS A list of version sets of the issues we deal with. No duplicates allowed. """ self._dbcfg = dbcfg self._future_versions = future_versions self._globals = globals self._issues = issues self._vsets = vsets # # Helper data # self._single_issue = (len(self._issues) == 1) self._issue_numbers = self._issues.keys() self._issue_numbers.sort(lambda x,y: cmp(int(x), int(y))) def _change_issues_due_version(self, version): """ Changes due version of all issues to VERSION. """ for i in self._issues.values(): i.change_due_version(version) def _close_issues(self, resolution): """ Close all issues with specified RESOLUTION. """ assert resolution for id in self._issues: try: self._issues[id].close(resolution) except DITrack.DB.Exceptions.InconsistentActionError, msg: raise _InconsistencyError(id, msg) def _reassign_issues(self, owner): """ Reassign all issues to OWNER. """ for i in self._issues.values(): i.reassign(owner) def _reopen_issues(self): """ Reopen all issues. """ for id in self._issues: try: self._issues[id].reopen() except DITrack.DB.Exceptions.InconsistentActionError, msg: raise _InconsistencyError(id, msg) def run(self): """ Runs the driver. Returns a sorted list of issue numbers (XXX) to try saving. Empty list returned means "don't save the changes". """ raise NotImplementedError class _CmdlineDriver(_Driver): """ Action driver for noninteractive (command line) sessions. """ def __init__(self, actionlist, comment_text, **kv): """ ACTIONLIST is a parameter to '-a' option, as described in the usage note. COMMENT_TEXT is the comment text to add (may be ""). The rest parameters are the same as the base class constructor method accepts. """ _Driver.__init__(self, **kv) if actionlist: self._actions = actionlist.strip().split(",") else: self._actions = [] self.comment_text = comment_text def _invalid_action(self, action, issue, msg): """ Prints out diagnostic message MSG about ACTION on ISSUE (string) and returns an empty list. """ DITrack.Util.common.err( "Can't do '%s' on i#%s: %s" % (action, issue, msg) ) return [] def run(self): for a in self._actions: a = a.split(":") if len(a) == 1: action, arg = a[0], None elif len(a) == 2: action, arg = a else: DITrack.Util.common.err("Invalid action list syntax") if action == "change-due": if (not arg) or (arg not in self._future_versions): DITrack.Util.common.err( "'change-due' requires a valid future version number " "as the argument" ) self._change_issues_due_version(arg) elif action == "close": valid_resolutions = ("dropped", "fixed", "invalid") if (not arg) or (arg not in valid_resolutions): DITrack.Util.common.err( "'close' requires one of %s as the argument" % ", ".join(["'%s'" % x for x in valid_resolutions]) ) try: self._close_issues(arg) except _InconsistencyError, e: return self._invalid_action(action, e.issue_id, e.message) elif action == "reassign": # XXX: this check really belongs to the database layer if (not arg) or (arg not in self._dbcfg.users): DITrack.Util.common.err( "'reassign' requires a valid user name as the argument" ) self._reassign_issues(arg) elif action == "reopen": if arg: DITrack.Util.common.err( "'reopen' doesn't accept arguments" ) try: self._reopen_issues() except _InconsistencyError, e: return self._invalid_action(action, e.issue_id, e.message) else: DITrack.Util.common.err("Invalid action: %s" % action) return self._issue_numbers class _InteractiveDriver(_Driver): """ Action driver for interactive sessions. """ def _list_attaches(self, issue): attaches = issue.attachments() qty = len(attaches) sys.stdout.write( "\n%d file(s) currently attached\n" % qty ) for i in range(qty): flags = "" if attaches[i].is_local: flags = "L" sys.stdout.write( "%3d %-3s %s\n" % (i + 1, flags, attaches[i].name) ) sys.stdout.write("\n") def _manage_attaches(self, issue): mi_abort = DITrack.UI.MenuItem("a", "abandon this menu") mi_new = DITrack.UI.MenuItem("n", "new attach") mi_remove = DITrack.UI.MenuItem("r", "remove attach") menu = DITrack.UI.Menu( "Choose an action to manage attaches", [ mi_abort, mi_new, mi_remove, ]) while 1: # List the attaches self._list_attaches(issue) r = menu.run() if r == mi_abort: break elif r == mi_new: ti = DITrack.UI.TextInput("File to attach (blank to abort)") while 1: fname = ti.run() if not fname: break if not os.path.exists(fname): sys.stdout.write("File doesn't exist: %s\n" % fname) continue if not os.path.isfile(fname): sys.stdout.write("Not a file: %s\n" % fname) continue try: issue.add_attachment(fname) break except ValueError, name: sys.stdout.write( "Attachment named '%s' already exists\n" % name ) except DITrack.DB.Exceptions.BadAttachmentNameError, name: sys.stdout.write( "Attachment named '%s' has been removed within " "this session; can't add another one with the " "same name -- you need to save your changes first" "\n" % name ) elif r == mi_remove: # Create menu with a list of attachments removal_menu = DITrack.UI.EnumMenu( "Choose an attachment to remove", map(lambda x: x.name, issue.attachments()), abort_option=True ) fname = removal_menu.run() if fname is None: continue issue.remove_attachment(fname) # Go straight to the main menu from here return def run(self): """ If changes are to be saved (see the base class method description), the COMMENT_TEXT member contains the comment text for the action upon the return from this method. """ # Build up the menu. mi_abort = DITrack.UI.MenuItem("a", "abort, discarding changes") mi_attaches = DITrack.UI.MenuItem("f", "manage file attaches") mi_ch_due_in = DITrack.UI.MenuItem("d", "change due version") mi_close = DITrack.UI.MenuItem("c", "close the issue") mi_edit_info = DITrack.UI.MenuItem("h", "edit the issue header") mi_edit_text = DITrack.UI.MenuItem("e", "edit comment text") mi_quit = DITrack.UI.MenuItem("q", "quit, saving changes") mi_reassign = DITrack.UI.MenuItem("o", "reassign the issue owner") mi_reopen = DITrack.UI.MenuItem("r", "reopen the issue") menu = DITrack.UI.Menu("Choose an action for the issue(s)", [ mi_abort, mi_attaches, mi_ch_due_in, mi_close, mi_edit_info, mi_edit_text, mi_quit, mi_reassign, mi_reopen ]) save_changes = False self.comment_text = "" mi_ch_due_in.enabled = self._future_versions mi_attaches.enabled = mi_edit_info.enabled = self._single_issue while 1: # Conditionally enable/disable menu items. mi_close.enabled = filter(lambda x: x.info["Status"] == "open", self._issues.itervalues()) mi_reopen.enabled = filter( lambda x: x.info["Status"] == "closed", self._issues.itervalues()) sys.stdout.write("\nActing on:\n") output = dict([(x, "") for x in self._vsets]) for id in self._issue_numbers: vset = self._dbcfg.category[ self._issues[id].info["Category"] ].version_set output[vset] += "i#%s: %s\n" % ( id, self._issues[id].info["Title"] ) sys.stdout.write("\n") for vset in self._vsets: sys.stdout.write("[%s]:\n%s\n" % (vset, output[vset])) sys.stdout.write("\n") r = menu.run() if r == mi_abort: break elif r == mi_attaches: assert len(self._issue_numbers) == 1 self._manage_attaches(self._issues[self._issue_numbers[0]]) elif r == mi_close: mi_c_abort = DITrack.UI.MenuItem("a", "abort closing") mi_c_dropped = DITrack.UI.MenuItem("d", "dropped") mi_c_fixed = DITrack.UI.MenuItem("f", "fixed") mi_c_invalid = DITrack.UI.MenuItem("i", "invalid") resolution_menu = DITrack.UI.Menu( "Choose the resolution", [ mi_c_abort, mi_c_dropped, mi_c_fixed, mi_c_invalid ]) r = resolution_menu.run() if r != mi_c_abort: if r == mi_c_dropped: resolution = "dropped" elif r == mi_c_fixed: resolution = "fixed" elif r == mi_c_invalid: resolution = "invalid" self._close_issues(resolution) elif r == mi_ch_due_in: assert self._future_versions any_issue = self._issues[self._issue_numbers[0]] if self._single_issue: sys.stdout.write("Current due version: %s\n" % any_issue.info["Due-in"]) due_menu = DITrack.UI.EnumMenu("Choose new due version", self._future_versions) v = due_menu.run() if not v: break self._change_issues_due_version(v) elif r == mi_edit_info: id = self._issue_numbers[0] info = email.Message.Message() for k, v in self._issues[id].info.iteritems(): info.add_header(k, v) header = DITrack.Edit.edit_text( self._globals, info.as_string() ) self._issues[id].info = email.message_from_string(header) elif r == mi_edit_text: self.comment_text = DITrack.Edit.edit_text(self._globals, self.comment_text ) elif r == mi_quit: save_changes = True break elif r == mi_reassign: if self._single_issue: sys.stdout.write("Current issue owner: %s\n" % self._issues[self._issue_numbers[0]].info["Owned-by"] ) users = self._dbcfg.users.keys() users.sort() owner_menu = DITrack.UI.EnumMenu("Choose new issue owner", users) v = owner_menu.run() if not v: break self._reassign_issues(v) elif r == mi_reopen: self._reopen_issues() else: raise NotImplementedError if save_changes: return self._issue_numbers else: return [] class Handler(DITrack.Command.generic.Handler): canonical_name = "act" # XXX: replace ISSUENUM with ISSUEID later description = """Perform actions on an issue (or multiple issues). usage: %s ISSUENUM [ISSUENUM...]""" % canonical_name description_ps = """ ACTIONLIST is a comma-separated list of one of more of the following actions: close:{dropped, fixed, invalid} - close the issue(s) with specified resolution. change-due:VERSION - change the due version to VERSION. reassign:USER - reassign the issue(s) to USER. reopen - reopen the issue(s). Each action can occur in the ACTIONLIST once at the most. Any of -a, -F or -m implies non-interactive mode. -F and -m are mutually exclusive. """ def run(self, opts, globals): self.check_options(opts) if len(opts.fixed) < 2: self.print_help(globals) sys.exit(1) # We'll need an editor. globals.get_editor() db = DITrack.Util.common.open_db(globals, opts, "w") present_vsets = {} issue = {} prev_issue = {} for id in opts.fixed[1:]: id = id.upper() try: issue[id] = db.issue_by_id(id) except (KeyError, ValueError): # Diagnostics printed by issue_by_id(). pass if db.is_valid_issue_name(id): DITrack.Util.common.err( "Non-local identifier expected '%s'" % id ) prev_issue[id] = copy.deepcopy(issue[id]) present_vsets[ db.cfg.category[ issue[id].info["Category"] ].version_set] = 1 # Scan through version sets we are dealing with and find an # intersection of all future versions. i = 0 common_versions = [] for vset in present_vsets: if not i: common_versions = db.cfg.versions[vset].future i += 1 else: intersected_versions = [] for version in db.cfg.versions[vset].future: if version in common_versions: intersected_versions.append(version) if len(intersected_versions) == 0: break common_versions = intersected_versions common_versions.sort() if ("actions" in opts.var) or ("comment_file" in opts.var) or \ ("comment_message" in opts.var): if "actions" in opts.var: actions = opts.var["actions"] else: actions = "" if "comment_file" in opts.var: comment_text = open(opts.var["comment_file"]).read() elif "comment_message" in opts.var: comment_text = "%s\n" % opts.var["comment_message"] else: comment_text = "" driver = _CmdlineDriver( actions, comment_text, globals=globals, dbcfg=db.cfg, issues=issue, future_versions=common_versions, vsets=present_vsets.keys() ) else: driver = _InteractiveDriver( globals, db.cfg, issues=issue, future_versions=common_versions, vsets=present_vsets.keys() ) issue_numbers = driver.run() if issue_numbers: # We need to save the changes local_names = [] for id in issue_numbers: try: name, comment = db.new_comment(id, prev_issue[id], issue[id], driver.comment_text, globals.username, globals.fmt_timestamp()) local_names.append((id, name)) # XXX: should embrace only db.new_comment() except DITrack.DB.Exceptions.NoDifferenceCondition: continue sys.stdout.write("Comment %s added to issue %s\n" % (name, id)) if not opts.var["no_commits"]: # Now commit newly added comments. We do it in a separate step # to simplify the solution for now. If something goes wrong # (like no disk space or connectivity issues), a user may # choose to commit the changes later. for issue_id, comment_name in local_names: # XXX: for now we don't deal with commenting local issues. assert db.is_valid_issue_number(issue_id) firm_id = db.commit_comment(issue_id, comment_name) # XXX: print 'Local ... in r234'. sys.stdout.write("Local i#%s.%s committed as i#%s.%s\n" % \ (issue_id, comment_name, issue_id, firm_id))