def editor(self, filename): """Spawn the default editor ($EDITOR env var or editor configuration item).""" if not self.config_editor: raise exceptions.FatalError("no editor configured (EDITOR " "environment variable or editor " "configuration item)") if subprocess.call([self.config_editor, filename]) != 0: raise exceptions.FatalError("there was a problem running the " "editor")
def post(self, query_string, data=None, handle_errors=True): """Generates a POST query on the target Trac system. This also alters the given data to include the form token stored on the cookies. Without this token, Trac will refuse form submissions. :param query_string: Starts with a slash, part of the URL between the domain and the parameters (before the ?). :param data: Dictionary of parameters to encode and transmit to the target page. :param handle_errors: Crash with a proper exception according to the HTTP return code (default: True). """ if data: data["__FORM_TOKEN"] = self.get_form_token() r = self.session.post(self.base_url + query_string, data=data) if r.status_code >= 400 and handle_errors: message = text.extract_message(r.text) if not message: message = "{} returned {}".format(self.base_url, r) raise exceptions.FatalError(message) return r
def get(self, query_string, data=None, handle_errors=True): """Generates a GET query on the target Trac system. TODO: extract all the possible error elements as message. :param query_string: Starts with a slash, part of the URL between the domain and the parameters (before the ?). :param data: Dictionary of parameters to encode at the end of the ``query_string``. :param handle_errors: Crash with a proper exception according to the HTTP return code (default: True). """ r = self.session.get(self.base_url + query_string, data=data) if r.status_code >= 400 and handle_errors: message = text.extract_message(r.text) if not message: message = "{} returned {}".format(self.base_url, r) raise exceptions.FatalError(message) # Check the version if we can. self.check_version(r.text) return r
def run_change(self, ticket_id, *values): """Make change to the given ticket_id. This command does not return anything if successful. TODO: support spawning an editor to change field values. usage: cm change ticket_id field=value [field=value...] """ ticket_id = text.validate_id(ticket_id) if not values: raise exceptions.InvalidParameter("should provide at least one " "field change") fields_data = {} for v in values: s = v.split('=', 1) if len(s) != 2: raise exceptions.InvalidParameter( "invalid value '{}', should be a field=value " "pair".format(v)) field = s[0].strip() value = s[1] fields_data["field_" + field] = value self.login() # Load the timestamps from the ticket page. r = self.get("/ticket/{}".format(ticket_id)) timestamps = self._extract_timestamps(r.text) if self.message: comment = self.message elif self.add_comment: comment = self._read_comment() else: comment = "" data = { "action": "leave", "comment": comment, "submit": "Submit changes", } data.update(timestamps) data.update(fields_data) r = self.post("/ticket/{}".format(ticket_id), data) # Starting from 1.0+, the system-message element is always on the page, # only the style is changed. if self.trac_version >= (1, 0): token = 'system-message" style=""' else: token = "system-message" if token in r.text or r.status_code != 200: raise exceptions.FatalError("unable to save change")
def run_comment(self, ticket_id): """Add a comment to the given ticket_id. This command does not return anything if successful. Command is cancelled if the content of the comment is empty. usage: cm comment ticket_id """ ticket_id = text.validate_id(ticket_id) if self.message: comment = self.message else: comment = self._read_comment() if not comment.strip(): raise exceptions.FatalError("empty comment, cancelling") self.login() # Load the timestamps from the ticket page. r = self.get("/ticket/{}".format(ticket_id)) timestamps = self._extract_timestamps(r.text) data = { "comment": comment, "action": "leave", "submit": "Submit changes", } data.update(timestamps) r = self.post("/ticket/{}".format(ticket_id), data) # Starting from 1.0+, the system-message element is always on the page, # only the style is changed. if self.trac_version >= (1, 0): token = 'system-message" style=""' else: token = "system-message" if token in r.text or r.status_code != 200: raise exceptions.FatalError("unable to save comment")
def extract_timestamps_common(token, raw_html): """Given a dump of HTML data, extract the timestamp and return it as a string value. :param raw_html: Dump from the ticket page. """ regex = r"""name="{}" value="([^"]+)""".format(token) m = re.search(regex, raw_html, re.MULTILINE) if m: timestamp = m.group(1) else: raise exceptions.FatalError("unable to fetch timestamp") return timestamp
def run_status(self, ticket_id, status=None): """Updates the status of a ticket. usage: cm status ticket_id [new_status] """ output = [] ticket_id = text.validate_id(ticket_id) self.login() # Get all the available actions for this ticket r = self.get("/ticket/{}".format(ticket_id)) statuses = text.extract_statuses(r.text) # Just display current status. if not status: status = self.extract_status_from_ticket_page(r.text) output.append("Current status: {}".format(status)) if statuses: output.append("Available statuses: {}".format( ", ".join(statuses))) return output if not status: raise exceptions.FatalError("bad status (acceptable: {})".format( ", ".join(statuses))) if self.message: comment = self.message elif self.add_comment: comment = self._read_comment() else: comment = "" # Not having a value for submit causes Trac to ignore the request. data = { "action": status, "comment": comment, "submit": "anything", } data.update(self._extract_timestamps(r.text)) r = self.post("/ticket/{}".format(ticket_id), data)
def extract_status_from_ticket_page_common(re_status, raw_html): """Given a dump of the HTML ticket page, extract the current status of a ticket. TODO: return resolution and display it if any. :param raw_html: Dump for the ticket page. """ m = re.search(re_status, raw_html, re.MULTILINE) if m: status = m.group(1) # task_type = m.group(2) # resolution = m.group(3) else: raise exceptions.FatalError("unable to fetch ticket status") return status
def run_new(self, owner=None): """Create a new ticket and return its id if successful. usage: cm new [owner] """ template = self.resolve_template() if not template: if self.message_file: if self.message_file == "-": template = sys.stdin.read() else: with open(self.message_file) as fp: template = fp.read() else: template = DEFAULT_TEMPLATE # Parse the template ahead of time, allowing us to insert the Owner/To. ep = email.parser.Parser() em = ep.parsestr(template) body = em.get_payload() headers = OrderedDict(em.items()) # The owner specified on the command line always prevails. if owner: headers["To"] = owner # If all else fail, assign it to yourself. if not headers["To"]: headers["To"] = self.username self.login() valid = False while not valid: # Get the properties at each iteration, in case an admin updated # the list in the mean time. options = self.get_property_options() # Assume the user will produce a valid ticket valid = True # Load the current values in a temp file for editing (fd, filename) = tempfile.mkstemp(suffix=".cm.ticket") fp = os.fdopen(fd, "w") fp.write(self._format_headers(headers)) fp.write("\n\n") fp.write(body) fp.close() # When reading the message from stdin, we can't edit, skip editor. if not self.message_file: self.editor(filename) # Use the email parser to get the headers. ep = email.parser.Parser() with open(filename, "r") as fp: em = ep.parse(fp) os.unlink(filename) body = em.get_payload() headers = OrderedDict(em.items()) errors = [] fuzzy_match_fields = ("Milestone", "Component", "Type", "Version", "Priority") # Ensures all the required fields are filled-in for key in self.required_fields: if key in fuzzy_match_fields: continue if not headers.get(key) or "**ERROR**" in headers[key]: errors.append("Invalid '{}': cannot be blank".format(key)) # Some fields are tolerant to incomplete values, this is where we # try to complete them. for key in fuzzy_match_fields: lkey = key.lower() if lkey not in options: continue valid_options = options[lkey] # The specified value is not available in the multi-choice. if key in headers and headers[key] not in valid_options: m = text.fuzzy_find(headers[key], valid_options) if m: # We found a close match, update the value with it. headers[key] = m else: # We didn't find a close match. If the user entered # something explicitly or if this field is required, # this is an error, else just wipe the value and move # on. if headers[key] or key in self.required_fields: joined_options = ", ".join(valid_options) errors.append(u"Invalid '{}': expected: {}".format( key, joined_options)) else: headers[key] = "" if errors: valid = False print("\nFound the following errors:") for error in errors: print(u" - {}".format(error)) try: if not self.message_file: self.input("\n-- Hit Enter to return to editor, " "^C to abort --\n") except KeyboardInterrupt: raise exceptions.FatalError("ticket creation interrupted") # There is no editor loop when reading message from stdin, just # print the errors and exit. if self.message_file: break # Since the body is expected to be using CRLF line termination, we # replace newlines by CRLF if no CRLF is found. if "\r\n" not in body: body = body.replace("\n", "\r\n") fields_data = { "field_summary": headers.get("Subject", ""), "field_type": headers.get("Type", ""), "field_version": headers.get("Version", ""), "field_description": body, "field_milestone": headers.get("Milestone", ""), "field_component": headers.get("Component", ""), "field_owner": headers.get("To", ""), "field_keywords": headers.get("Keywords", ""), "field_cc": headers.get("Cc", ""), "field_attachment": "", } # Assume anything outside of the original headers it to be included as # fields. for key, value in headers.items(): field_name = "field_" + key.lower() if field_name not in fields_data: fields_data[field_name] = value r = self.post("/newticket", fields_data) if r.status_code != 200: message = text.extract_message(r.text) if not message: message = "unable to create new ticket" raise exceptions.RequestException(message) try: ticket_id = int(r.url.split("/")[-1]) except: raise exceptions.RequestException("returned ticket_id is invalid.") self.open_in_browser_on_request(ticket_id) return ["ticket #{} created".format(ticket_id)]