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