1# -*- coding: utf-8 -*- 2 3# Copyright(C) 2011 Romain Bignon 4# 5# This file is part of weboob. 6# 7# weboob is free software: you can redistribute it and/or modify 8# it under the terms of the GNU Lesser General Public License as published by 9# the Free Software Foundation, either version 3 of the License, or 10# (at your option) any later version. 11# 12# weboob is distributed in the hope that it will be useful, 13# but WITHOUT ANY WARRANTY; without even the implied warranty of 14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15# GNU Lesser General Public License for more details. 16# 17# You should have received a copy of the GNU Lesser General Public License 18# along with weboob. If not, see <http://www.gnu.org/licenses/>. 19 20from __future__ import print_function 21 22from datetime import timedelta 23from email import message_from_string, message_from_file 24from email.header import decode_header 25from email.mime.text import MIMEText 26from smtplib import SMTP 27import os 28import re 29import unicodedata 30 31from weboob.capabilities.base import empty, BaseObject 32from weboob.capabilities.bugtracker import CapBugTracker, Query, Update, Project, Issue, IssueError 33from weboob.tools.application.repl import ReplApplication, defaultcount 34from weboob.tools.application.formatters.iformatter import IFormatter, PrettyFormatter 35from weboob.tools.compat import basestring, unicode 36from weboob.tools.html import html2text 37from weboob.tools.date import parse_french_date 38 39 40__all__ = ['BoobTracker'] 41 42 43try: 44 input = raw_input 45except NameError: 46 pass 47 48 49class IssueFormatter(IFormatter): 50 MANDATORY_FIELDS = ('id', 'project', 'title', 'body', 'author') 51 52 def format_attr(self, obj, attr): 53 if not hasattr(obj, attr) or empty(getattr(obj, attr)): 54 return u'' 55 56 value = getattr(obj, attr) 57 if isinstance(value, BaseObject): 58 value = value.name 59 60 return self.format_key(attr.capitalize(), value) 61 62 def format_key(self, key, value): 63 return '%s %s\n' % (self.colored('%s:' % key, 'green'), 64 value) 65 66 def format_obj(self, obj, alias): 67 result = u'%s %s %s %s %s\n' % (self.colored(obj.project.name, 'blue', 'bold'), 68 self.colored(u'—', 'cyan', 'bold'), 69 self.colored(obj.fullid, 'red', 'bold'), 70 self.colored(u'—', 'cyan', 'bold'), 71 self.colored(obj.title, 'yellow', 'bold')) 72 result += '\n%s\n\n' % obj.body 73 result += self.format_key('Author', '%s (%s)' % (obj.author.name, obj.creation)) 74 result += self.format_attr(obj, 'status') 75 result += self.format_attr(obj, 'priority') 76 result += self.format_attr(obj, 'version') 77 result += self.format_attr(obj, 'tracker') 78 result += self.format_attr(obj, 'category') 79 result += self.format_attr(obj, 'assignee') 80 if hasattr(obj, 'fields') and not empty(obj.fields): 81 for key, value in obj.fields.items(): 82 result += self.format_key(key.capitalize(), value) 83 if hasattr(obj, 'attachments') and obj.attachments: 84 result += '\n%s\n' % self.colored('Attachments:', 'green') 85 for a in obj.attachments: 86 result += '* %s%s%s <%s>\n' % (self.BOLD, a.filename, self.NC, a.url) 87 if hasattr(obj, 'history') and obj.history: 88 result += '\n%s\n' % self.colored('History:', 'green') 89 for u in obj.history: 90 result += '%s %s %s %s\n' % (self.colored('*', 'red', 'bold'), 91 self.colored(u.date, 'yellow', 'bold'), 92 self.colored(u'—', 'cyan', 'bold'), 93 self.colored(u.author.name, 'blue', 'bold')) 94 for change in u.changes: 95 result += ' - %s %s %s %s\n' % (self.colored(change.field, 'green'), 96 change.last, 97 self.colored('->', 'magenta'), change.new) 98 if u.message: 99 result += ' %s\n' % html2text(u.message).strip().replace('\n', '\n ') 100 return result 101 102 103class IssuesListFormatter(PrettyFormatter): 104 MANDATORY_FIELDS = ('id', 'project', 'status', 'title', 'category') 105 106 def get_title(self, obj): 107 return '%s - [%s] %s' % (obj.project.name, obj.status.name, obj.title) 108 109 def get_description(self, obj): 110 return obj.category 111 112 113class BoobTracker(ReplApplication): 114 APPNAME = 'boobtracker' 115 VERSION = '2.0' 116 COPYRIGHT = 'Copyright(C) 2011-YEAR Romain Bignon' 117 DESCRIPTION = "Console application allowing to create, edit, view bug tracking issues." 118 SHORT_DESCRIPTION = "manage bug tracking issues" 119 CAPS = CapBugTracker 120 EXTRA_FORMATTERS = {'issue_info': IssueFormatter, 121 'issues_list': IssuesListFormatter, 122 } 123 COMMANDS_FORMATTERS = {'get': 'issue_info', 124 'post': 'issue_info', 125 'edit': 'issue_info', 126 'search': 'issues_list', 127 'ls': 'issues_list', 128 } 129 COLLECTION_OBJECTS = (Project, Issue, ) 130 131 def add_application_options(self, group): 132 group.add_option('--author') 133 group.add_option('--title') 134 group.add_option('--assignee') 135 group.add_option('--target-version', dest='version') 136 group.add_option('--tracker') 137 group.add_option('--category') 138 group.add_option('--status') 139 group.add_option('--priority') 140 group.add_option('--start') 141 group.add_option('--due') 142 143 @defaultcount(10) 144 def do_search(self, line): 145 """ 146 search PROJECT 147 148 List issues for a project. 149 150 You can use these filters from command line: 151 --author AUTHOR 152 --title TITLE_PATTERN 153 --assignee ASSIGNEE 154 --target-version VERSION 155 --category CATEGORY 156 --status STATUS 157 """ 158 query = Query() 159 160 path = self.working_path.get() 161 backends = [] 162 if line.strip(): 163 query.project, backends = self.parse_id(line, unique_backend=True) 164 elif len(path) > 0: 165 query.project = path[0] 166 else: 167 print('Please enter a project name', file=self.stderr) 168 return 1 169 170 query.author = self.options.author 171 query.title = self.options.title 172 query.assignee = self.options.assignee 173 query.version = self.options.version 174 query.category = self.options.category 175 query.status = self.options.status 176 177 self.change_path([query.project, u'search']) 178 for issue in self.do('iter_issues', query, backends=backends): 179 self.add_object(issue) 180 self.format(issue) 181 182 def complete_get(self, text, line, *ignored): 183 args = line.split(' ') 184 if len(args) == 2: 185 return self._complete_object() 186 187 def do_get(self, line): 188 """ 189 get ISSUE 190 191 Get an issue and display it. 192 """ 193 if not line: 194 print('This command takes an argument: %s' % self.get_command_help('get', short=True), file=self.stderr) 195 return 2 196 197 issue = self.get_object(line, 'get_issue') 198 if not issue: 199 print('Issue not found: %s' % line, file=self.stderr) 200 return 3 201 self.format(issue) 202 203 def complete_comment(self, text, line, *ignored): 204 args = line.split(' ') 205 if len(args) == 2: 206 return self._complete_object() 207 208 def do_comment(self, line): 209 """ 210 comment ISSUE [TEXT] 211 212 Comment an issue. If no text is given, enter it in standard input. 213 """ 214 id, text = self.parse_command_args(line, 2, 1) 215 if text is None: 216 text = self.acquire_input() 217 218 id, backend_name = self.parse_id(id, unique_backend=True) 219 update = Update(0) 220 update.message = text 221 222 self.do('update_issue', id, update, backends=backend_name).wait() 223 224 def do_logtime(self, line): 225 """ 226 logtime ISSUE HOURS [TEXT] 227 228 Log spent time on an issue. 229 """ 230 id, hours, text = self.parse_command_args(line, 3, 2) 231 if text is None: 232 text = self.acquire_input() 233 234 try: 235 hours = float(hours) 236 except ValueError: 237 print('Error: HOURS parameter may be a float', file=self.stderr) 238 return 1 239 240 id, backend_name = self.parse_id(id, unique_backend=True) 241 update = Update(0) 242 update.message = text 243 update.hours = timedelta(hours=hours) 244 245 self.do('update_issue', id, update, backends=backend_name).wait() 246 247 def complete_remove(self, text, line, *ignored): 248 args = line.split(' ') 249 if len(args) == 2: 250 return self._complete_object() 251 252 def do_remove(self, line): 253 """ 254 remove ISSUE 255 256 Remove an issue. 257 """ 258 id, backend_name = self.parse_id(line, unique_backend=True) 259 self.do('remove_issue', id, backends=backend_name).wait() 260 261 ISSUE_FIELDS = (('title', (None, False)), 262 ('assignee', ('members', True)), 263 ('version', ('versions', True)), 264 ('tracker', (None, False)),#XXX 265 ('category', ('categories', False)), 266 ('status', ('statuses', True)), 267 ('priority', (None, False)),#XXX 268 ('start', (None, False)), 269 ('due', (None, False)), 270 ) 271 272 def get_list_item(self, objects_list, name): 273 if name is None: 274 return None 275 276 for obj in objects_list: 277 if obj.name.lower() == name.lower(): 278 return obj 279 280 if not name: 281 return None 282 283 raise ValueError('"%s" is not found' % name) 284 285 def sanitize_key(self, key): 286 if isinstance(key, str): 287 key = unicode(key, "utf8") 288 key = unicodedata.normalize('NFKD', key).encode("ascii", "ignore") 289 return key.replace(' ', '-').capitalize() 290 291 def issue2text(self, issue, backend=None): 292 if backend is not None and 'username' in backend.config: 293 sender = backend.config['username'].get() 294 else: 295 sender = os.environ.get('USERNAME', 'boobtracker') 296 output = u'From: %s\n' % sender 297 for key, (list_name, is_list_object) in self.ISSUE_FIELDS: 298 value = None 299 if not self.interactive: 300 value = getattr(self.options, key) 301 if not value: 302 value = getattr(issue, key) 303 if not value: 304 value = '' 305 elif hasattr(value, 'name'): 306 value = value.name 307 308 if list_name is not None: 309 objects_list = getattr(issue.project, list_name) 310 if len(objects_list) == 0: 311 continue 312 313 output += '%s: %s\n' % (self.sanitize_key(key), value) 314 if list_name is not None: 315 availables = ', '.join(['<%s>' % (o if isinstance(o, basestring) else o.name) 316 for o in objects_list]) 317 output += 'X-Available-%s: %s\n' % (self.sanitize_key(key), availables) 318 319 for key, value in issue.fields.items(): 320 output += '%s: %s\n' % (self.sanitize_key(key), value or '') 321 # TODO: Add X-Available-* for lists 322 323 output += '\n%s' % (issue.body or 'Please write your bug report here.') 324 return output 325 326 def text2issue(self, issue, m): 327 # XXX HACK to support real incoming emails 328 if 'Subject' in m: 329 m['Title'] = m['Subject'] 330 331 for key, (list_name, is_list_object) in self.ISSUE_FIELDS: 332 value = m.get(key) 333 if value is None: 334 continue 335 336 new_value = u'' 337 for part in decode_header(value): 338 if part[1]: 339 new_value += unicode(part[0], part[1]) 340 else: 341 new_value += part[0].decode('utf-8') 342 value = new_value 343 344 if is_list_object: 345 objects_list = getattr(issue.project, list_name) 346 value = self.get_list_item(objects_list, value) 347 348 # FIXME: autodetect 349 if key in ['start', 'due']: 350 if len(value) > 0: 351 #value = datetime.strptime(value, "%Y-%m-%d %H:%M:%S") 352 value = parse_french_date(value) 353 else: 354 value = None 355 356 setattr(issue, key, value) 357 358 for key in issue.fields.keys(): 359 value = m.get(self.sanitize_key(key)) 360 if value is not None: 361 issue.fields[key] = value.decode('utf-8') 362 363 content = u'' 364 for part in m.walk(): 365 if part.get_content_type() == 'text/plain': 366 s = part.get_payload(decode=True) 367 charsets = part.get_charsets() + m.get_charsets() 368 for charset in charsets: 369 try: 370 if charset is not None: 371 content += unicode(s, charset) 372 else: 373 content += unicode(s, encoding='utf-8') 374 except UnicodeError as e: 375 self.logger.warning('Unicode error: %s' % e) 376 continue 377 except Exception as e: 378 self.logger.exception(e) 379 continue 380 else: 381 break 382 383 issue.body = content 384 385 m = re.search('([^< ]+@[^ >]+)', m['From'] or '') 386 if m: 387 return m.group(1) 388 389 def edit_issue(self, issue, edit=True): 390 backend = self.weboob.get_backend(issue.backend) 391 content = self.issue2text(issue, backend) 392 while True: 393 if self.stdin.isatty(): 394 content = self.acquire_input(content, {'vim': "-c 'set ft=mail'"}) 395 m = message_from_string(content.encode('utf-8')) 396 else: 397 m = message_from_file(self.stdin) 398 399 try: 400 email_to = self.text2issue(issue, m) 401 except ValueError as e: 402 if not self.stdin.isatty(): 403 raise 404 input("%s -- Press Enter to continue..." % unicode(e).encode("utf-8")) 405 continue 406 407 try: 408 issue = backend.post_issue(issue) 409 print('Issue %s %s' % (self.formatter.colored(issue.fullid, 'red', 'bold'), 410 'updated' if edit else 'created')) 411 if edit: 412 self.format(issue) 413 elif email_to: 414 self.send_notification(email_to, issue) 415 return 0 416 except IssueError as e: 417 if not self.stdin.isatty(): 418 raise 419 input("%s -- Press Enter to continue..." % unicode(e).encode("utf-8")) 420 421 def send_notification(self, email_to, issue): 422 text = """Hi, 423 424You have successfuly created this ticket on the Weboob tracker: 425 426%s 427 428You can follow your bug report on this page: 429 430https://symlink.me/issues/%s 431 432Regards, 433 434Weboob Team 435""" % (issue.title, issue.id) 436 msg = MIMEText(text, 'plain', 'utf-8') 437 msg['Subject'] = 'Issue #%s reported' % issue.id 438 msg['From'] = 'Weboob <weboob@weboob.org>' 439 msg['To'] = email_to 440 s = SMTP('localhost') 441 s.sendmail('weboob@weboob.org', [email_to], msg.as_string()) 442 s.quit() 443 444 def do_post(self, line): 445 """ 446 post PROJECT 447 448 Post a new issue. 449 450 If you are not in interactive mode, you can use these parameters: 451 --title TITLE 452 --assignee ASSIGNEE 453 --target-version VERSION 454 --category CATEGORY 455 --status STATUS 456 """ 457 if not line.strip(): 458 print('Please give the project name') 459 return 1 460 461 project, backend_name = self.parse_id(line, unique_backend=True) 462 463 backend = self.weboob.get_backend(backend_name) 464 465 issue = backend.create_issue(project) 466 issue.backend = backend.name 467 468 return self.edit_issue(issue, edit=False) 469 470 def complete_edit(self, text, line, *ignored): 471 args = line.split(' ') 472 if len(args) == 2: 473 return self._complete_object() 474 if len(args) == 3: 475 return list(dict(self.ISSUE_FIELDS).keys()) 476 477 def do_edit(self, line): 478 """ 479 edit ISSUE [KEY [VALUE]] 480 481 Edit an issue. 482 If you are not in interactive mode, you can use these parameters: 483 --title TITLE 484 --assignee ASSIGNEE 485 --target-version VERSION 486 --category CATEGORY 487 --status STATUS 488 """ 489 _id, key, value = self.parse_command_args(line, 3, 1) 490 issue = self.get_object(_id, 'get_issue') 491 if not issue: 492 print('Issue not found: %s' % _id, file=self.stderr) 493 return 3 494 495 return self.edit_issue(issue, edit=True) 496 497 def complete_attach(self, text, line, *ignored): 498 args = line.split(' ') 499 if len(args) == 2: 500 return self._complete_object() 501 elif len(args) >= 3: 502 return self.path_completer(args[2]) 503 504 def do_attach(self, line): 505 """ 506 attach ISSUE FILENAME 507 508 Attach a file to an issue (Not implemented yet). 509 """ 510 print('Not implemented yet.', file=self.stderr) 511