Passed
Push — master ( 62d7d9...8230b1 )
by Alexander
03:29
created

tcms/issuetracker/types.py (2 issues)

1
"""
2
    This module implements Kiwi TCMS interface to external issue tracking systems.
3
    :class:`tcms.issuetracker.types.IssueTrackerType` provides the interface
4
    while the rest of the classes in this module implement it! Refer to each
5
    implementor class for integration specifics!
6
"""
7
8
import os
9
import re
10
import tempfile
11
from urllib.parse import urlencode, quote
12
13
import jira
14
import github
15
import bugzilla
16
import gitlab
17
import redminelib
18
19
from django.conf import settings
20
21
from tcms.issuetracker import bugzilla_integration
22
from tcms.issuetracker import jira_integration
23
from tcms.issuetracker import github_integration
24
from tcms.issuetracker import gitlab_integration
25
from tcms.issuetracker import redmine_integration
26
27
28
RE_ENDS_IN_INT = re.compile(r'[\d]+$')
29
30
31
class IssueTrackerType:
32
    """
33
        Represents actions which can be performed with issue trackers.
34
        This is a common interface for all issue trackers that Kiwi TCMS
35
        supports!
36
    """
37
38
    def __init__(self, tracker):
39
        """
40
            :tracker: - BugSystem object
41
        """
42
        self.tracker = tracker
43
44
    @classmethod
45
    def from_name(cls, name):
46
        """
47
            Return the class which matches ``name`` if it exists inside this
48
            module or raise an exception.
49
        """
50
        if name not in globals():
51
            raise NotImplementedError('IT of type %s is not supported' % name)
52
        return globals()[name]
53
54
    @classmethod
55
    def bug_id_from_url(cls, url):
56
        """
57
            Returns a unique identifier for reported defect. This is used by the
58
            underlying integration libraries. Usually that identifier is an
59
            integer number.
60
61
            The default implementation is to leave the last group of numeric
62
            characters at the end of a string!
63
        """
64
        return int(RE_ENDS_IN_INT.search(url.strip()).group(0))
65
66
    def report_issue_from_testcase(self, caserun):
67
        """
68
            When marking Test Case results inside a Test Run there is a
69
            `Report` link. When the `Report` link is clicked this method is called
70
            to help the user report an issue in the IT.
71
72
            This is implemented by constructing an URL string which will pre-fill
73
            bug details like steps to reproduce, product, version, etc from the
74
            test case. Then we open this URL into another browser window!
75
76
            :caserun: - TestExecution object
77
            :return: - string - URL
78
        """
79
        raise NotImplementedError()
80
81
    def add_testcase_to_issue(self, testcases, issue):
82
        """
83
            When adding issues to Test Execution results there is a
84
            'Check to add test cases to Issue tracker' checkbox. If
85
            selected this method is called to link the bug report to the
86
            test case which was used to discover the bug.
87
88
            Usually this is implemented by adding a new comment pointing
89
            back to the test case via the internal RPC object.
90
91
            :testcases: - list of TestCase objects
92
            :issue: - Bug object
93
        """
94
        raise NotImplementedError()
95
96
    # pylint: disable = invalid-name, no-self-use
97
    # todo: we should allow this method to raise and the specific error
98
    # message must be returned to the caller instead of generic one.
99
    # as it is LinkOnly tracker doesn't have any integrations but the error
100
    # message is misleading
101
    def is_adding_testcase_to_issue_disabled(self):
102
        """
103
            When is linking a TC to a Bug report disabled?
104
            Usually when all the required credentials are provided.
105
106
            :return: - boolean
107
        """
108
        return not (self.tracker.api_url
109
                    and self.tracker.api_username
110
                    and self.tracker.api_password)
111
112
    def all_issues_link(self, _ids):
113
        """
114
            Used in testruns.views.get() aka run/report.html to produce
115
            a single URL which will open all reported issues into a single
116
            page in the Issue tracker. For example Bugzilla supports listing
117
            multiple bugs into a table. GitHub on the other hand doesn't
118
            support this functionality.
119
120
            :ids: - list of issues reported against test executions
121
122
            :return: - None if not suported or string representing the URL
123
        """
124
125
126
class Bugzilla(IssueTrackerType):
127
    """
128
        Support for Bugzilla. Requires:
129
130
        :api_url: - the XML-RPC URL for your Bugzilla instance
131
        :api_username: - a username registered in Bugzilla
132
        :api_password: - the password for this username
133
134
        You can also provide the ``BUGZILLA_AUTH_CACHE_DIR`` setting (in ``product.py``)
135
        to control where authentication cookies for Bugzilla will be saved. If this
136
        is not provided a temporary directory will be used each time we try to login
137
        into Bugzilla!
138
    """
139
140
    def __init__(self, tracker):
141
        super().__init__(tracker)
142
143
        # directory for Bugzilla credentials
144
        self._bugzilla_cache_dir = getattr(
145
            settings,
146
            "BUGZILLA_AUTH_CACHE_DIR",
147
            tempfile.mkdtemp(prefix='.bugzilla-')
148
        )
149
        if not os.path.exists(self._bugzilla_cache_dir):
150
            os.makedirs(self._bugzilla_cache_dir, 0o700)
151
152
        self._rpc = None
153
154
    @property
155
    def rpc(self):
156
        if self._rpc is None:
157
            self._rpc = bugzilla.Bugzilla(
158
                self.tracker.api_url,
159
                user=self.tracker.api_username,
160
                password=self.tracker.api_password,
161
                cookiefile=self._bugzilla_cache_dir + 'cookie',
162
                tokenfile=self._bugzilla_cache_dir + 'token',
163
            )
164
        return self._rpc
165
166
    def add_testcase_to_issue(self, testcases, issue):
167
        for case in testcases:
168
            bugzilla_integration.BugzillaThread(self.rpc, case, issue).start()
169
170
    def all_issues_link(self, ids):
171
        if not self.tracker.base_url:
172
            return None
173
174
        if not self.tracker.base_url.endswith('/'):
175
            self.tracker.base_url += '/'
176
177
        return self.tracker.base_url + 'buglist.cgi?bugidtype=include&bug_id=%s' % ','.join(ids)
178
179
    def report_issue_from_testcase(self, caserun):
180
        args = {}
181
        args['cf_build_id'] = caserun.run.build.name
182
183
        txt = caserun.case.get_text_with_version(case_text_version=caserun.case_text_version)
184
185
        comment = "Filed from caserun %s\n\n" % caserun.get_full_url()
186
        comment += "Version-Release number of selected " \
187
                   "component (if applicable):\n"
188
        comment += '%s\n\n' % caserun.build.name
189
        comment += "Steps to Reproduce: \n%s\n\n" % txt
190
        comment += "Actual results: \n<describe what happened>\n\n"
191
192
        args['comment'] = comment
193
        args['component'] = caserun.case.component.values_list('name',
194
                                                               flat=True)
195
        args['product'] = caserun.run.plan.product.name
196
        args['short_desc'] = 'Test case failure: %s' % caserun.case.summary
197
        args['version'] = caserun.run.product_version
198
199
        url = self.tracker.base_url
200
        if not url.endswith('/'):
201
            url += '/'
202
203
        return url + 'enter_bug.cgi?' + urlencode(args, True)
204
205
206
class JIRA(IssueTrackerType):
207
    """
208
        Support for JIRA. Requires:
209
210
        :api_url: - the API URL for your JIRA instance
211
        :api_username: - a username registered in JIRA
212
        :api_password: - the password for this username
213
214
        Additional control can be applied via the ``JIRA_OPTIONS`` configuration
215
        setting (in ``product.py``). By default this setting is not provided and
216
        the code uses ``jira.JIRA.DEFAULT_OPTIONS`` from the ``jira`` Python module!
217
    """
218
219
    def __init__(self, tracker):
220
        super(JIRA, self).__init__(tracker)
221
222
        if hasattr(settings, 'JIRA_OPTIONS'):
223
            options = settings.JIRA_OPTIONS
224
        else:
225
            options = None
226
227
        # b/c jira.JIRA tries to connect when object is created
228
        # see https://github.com/kiwitcms/Kiwi/issues/100
229
        if not self.is_adding_testcase_to_issue_disabled():
230
            self.rpc = jira.JIRA(
231
                tracker.api_url,
232
                basic_auth=(self.tracker.api_username, self.tracker.api_password),
233
                options=options,
234
            )
235
236
    @classmethod
237
    def bug_id_from_url(cls, url):
238
        """
239
            Jira IDs are the last group of chars at the end of the URL.
240
            For example https://issues.jenkins-ci.org/browse/JENKINS-31044
241
        """
242
        return url.strip().split('/')[-1]
243
244
    def add_testcase_to_issue(self, testcases, issue):
245
        for case in testcases:
246
            jira_integration.JiraThread(self.rpc, case, issue).start()
247
248
    def all_issues_link(self, ids):
249
        if not self.tracker.base_url:
250
            return None
251
252
        if not self.tracker.base_url.endswith('/'):
253
            self.tracker.base_url += '/'
254
255
        return self.tracker.base_url + 'issues/?jql=issueKey%%20in%%20(%s)' % '%2C%20'.join(ids)
256
257
    def report_issue_from_testcase(self, caserun):
258
        """
259
            For the HTML API description see:
260
            https://confluence.atlassian.com/display/JIRA050/Creating+Issues+via+direct+HTML+links
261
        """
262
        # note: your jira instance needs to have the same projects
263
        # defined otherwise this will fail!
264
        project = self.rpc.project(caserun.run.plan.product.name)
265
266
        try:
267
            issue_type = self.rpc.issue_type_by_name('Bug')
268
        except KeyError:
269
            issue_type = self.rpc.issue_types()[0]
270
271
        args = {
272
            'pid': project.id,
273
            'issuetype': issue_type.id,
274
            'summary': 'Failed test: %s' % caserun.case.summary,
275
        }
276
277
        try:
278
            # apparently JIRA can't search users via their e-mail so try to
279
            # search by username and hope that it matches
280
            tested_by = caserun.tested_by
281
            if not tested_by:
282
                tested_by = caserun.assignee
283
284
            args['reporter'] = self.rpc.user(tested_by.username).key
285
        except jira.JIRAError:
286
            pass
287
288
        txt = caserun.case.get_text_with_version(case_text_version=caserun.case_text_version)
289
290
        comment = "Filed from caserun %s\n\n" % caserun.get_full_url()
291
        comment += "Product:\n%s\n\n" % caserun.run.plan.product.name
292
        comment += "Component(s):\n%s\n\n" % caserun.case.component.values_list('name', flat=True)
293
        comment += "Version-Release number of selected " \
294
                   "component (if applicable):\n"
295
        comment += "%s\n\n" % caserun.build.name
296
        comment += "Steps to Reproduce: \n%s\n\n" % txt
297
        comment += "Actual results: \n<describe what happened>\n\n"
298
        args['description'] = comment
299
300
        url = self.tracker.base_url
301
        if not url.endswith('/'):
302
            url += '/'
303
304
        return url + '/secure/CreateIssueDetails!init.jspa?' + urlencode(args, True)
305
306
307 View Code Duplication
class GitHub(IssueTrackerType):
0 ignored issues
show
This code seems to be duplicated in your project.
Loading history...
308
    """
309
        Support for GitHub. Requires:
310
311
        :base_url: - URL to a GitHub repository for which we're going to report issues
312
        :api_password: - GitHub API token.
313
314
        .. note::
315
316
            You can leave the ``api_url`` and ``api_username`` fields blank because
317
            the integration code doesn't use them!
318
319
        .. note::
320
321
            GitHub does not support displaying multiple issues in a table format like
322
            Bugzilla and JIRA do. This means that in Test Execution Report view you will
323
            see GitHub issues listed one by one and there will not be a link to open all
324
            of them inside GitHub's interface!
325
    """
326
327
    def __init__(self, tracker):
328
        super(GitHub, self).__init__(tracker)
329
330
        # NOTE: we use an access token so only the password field is required
331
        self.rpc = github.Github(self.tracker.api_password)
332
333
    def add_testcase_to_issue(self, testcases, issue):
334
        for case in testcases:
335
            github_integration.GitHubThread(self.rpc, self.tracker, case, issue).start()
336
337
    def is_adding_testcase_to_issue_disabled(self):
338
        return not (self.tracker.base_url and self.tracker.api_password)
339
340
    def report_issue_from_testcase(self, caserun):
341
        """
342
            GitHub only supports title and body parameters
343
        """
344
        args = {
345
            'title': 'Failed test: %s' % caserun.case.summary,
346
        }
347
348
        txt = caserun.case.get_text_with_version(case_text_version=caserun.case_text_version)
349
350
        comment = "Filed from caserun %s\n\n" % caserun.get_full_url()
351
        comment += "Product:\n%s\n\n" % caserun.run.plan.product.name
352
        comment += "Component(s):\n%s\n\n" % caserun.case.component.values_list('name', flat=True)
353
        comment += "Version-Release number of selected " \
354
                   "component (if applicable):\n"
355
        comment += "%s\n\n" % caserun.build.name
356
        comment += "Steps to Reproduce: \n%s\n\n" % txt
357
        comment += "Actual results: \n<describe what happened>\n\n"
358
        args['body'] = comment
359
360
        url = self.tracker.base_url
361
        if not url.endswith('/'):
362
            url += '/'
363
364
        return url + '/issues/new?' + urlencode(args, True)
365
366
367 View Code Duplication
class Gitlab(IssueTrackerType):
0 ignored issues
show
This code seems to be duplicated in your project.
Loading history...
368
    """
369
        Support for Gitlab. Requires:
370
371
        :base_url: - URL to a Gitlab repository for which we're going to report issues
372
        :api_url: - URL to GitLab instance. Usually gitlab.com!
373
        :api_password: - Gitlab API token.
374
    """
375
376
    def __init__(self, tracker):
377
        super(Gitlab, self).__init__(tracker)
378
379
        # we use an access token so only the password field is required
380
        self.rpc = gitlab.Gitlab(self.tracker.api_url, private_token=self.tracker.api_password)
381
382
    def add_testcase_to_issue(self, testcases, issue):
383
        for case in testcases:
384
            gitlab_integration.GitlabThread(self.rpc, self.tracker, case, issue).start()
385
386
    def is_adding_testcase_to_issue_disabled(self):
387
        return not (self.tracker.base_url and self.tracker.api_password)
388
389
    def report_issue_from_testcase(self, caserun):
390
        args = {
391
            'issue[title]': 'Failed test: %s' % caserun.case.summary,
392
        }
393
394
        txt = caserun.case.get_text_with_version(case_text_version=caserun.case_text_version)
395
396
        comment = "Filed from caserun %s\n\n" % caserun.get_full_url()
397
        comment += "**Product**:\n%s\n\n" % caserun.run.plan.product.name
398
        comment += "**Component(s)**:\n%s\n\n"\
399
                   % caserun.case.component.values_list('name', flat=True)
400
        comment += "Version-Release number of selected " \
401
                   "component (if applicable):\n"
402
        comment += "%s\n\n" % caserun.build.name
403
        comment += "**Steps to Reproduce**: \n%s\n\n" % txt
404
        comment += "**Actual results**: \n<describe what happened>\n\n"
405
        args['issue[description]'] = comment
406
407
        url = self.tracker.base_url
408
        if not url.endswith('/'):
409
            url += '/'
410
411
        return url + '/issues/new?' + urlencode(args, True)
412
413
414
class Redmine(IssueTrackerType):
415
    """
416
        Support for Redmine. Requires:
417
418
        :api_url: - the API URL for your Redmine instance
419
        :api_username: - a username registered in Redmine
420
        :api_password: - the password for this username
421
    """
422
423
    def __init__(self, tracker):
424
        super().__init__(tracker)
425
426
        if not self.is_adding_testcase_to_issue_disabled():
427
            self.rpc = redminelib.Redmine(
428
                self.tracker.api_url,
429
                username=self.tracker.api_username,
430
                password=self.tracker.api_password
431
            )
432
433
    def add_testcase_to_issue(self, testcases, issue):
434
        for case in testcases:
435
            redmine_integration.RedmineThread(self.rpc, case, issue).start()
436
437
    def all_issues_link(self, ids):
438
        if not self.tracker.base_url:
439
            return None
440
441
        if not self.tracker.base_url.endswith('/'):
442
            self.tracker.base_url += '/'
443
444
        query = 'issues?utf8=✓&set_filter=1&sort=id%3Adesc&f%5B%5D=issue_id'
445
        query += '&op%5Bissue_id%5D=%3D&v%5Bissue_id%5D%5B%5D={0}'.format('%2C'.join(ids))
446
        query += '&f%5B%5D=&c%5B%5D=tracker&c%5B%5D=status&c%5B%5D=priority'
447
        query += '&c%5B%5D=subject&c%5B%5D=assigned_to&c%5B%5D=updated_on&group_by=&t%5B%5D='
448
449
        return self.tracker.base_url + query
450
451
    def find_project_by_name(self, name):
452
        """
453
            Return a Redmine project which matches the given product name.
454
455
            .. note::
456
457
                If there is no match then return the first project in Redmine.
458
        """
459
        try:
460
            return self.rpc.project.get(name)
461
        except redminelib.exceptions.ResourceNotFoundError:
462
            projects = self.rpc.project.all()
463
            return projects[0]
464
465
    @staticmethod
466
    def find_issue_type_by_name(project, name):
467
        """
468
            Return a Redmine tracker matching name ('Bug').
469
470
            .. note::
471
472
                If there is no match then return the first one!
473
        """
474
        for trk in project.trackers:
475
            if str(trk).lower() == name.lower():
476
                return trk
477
478
        return project.trackers[0]
479
480
    def report_issue_from_testcase(self, caserun):
481
        project = self.find_project_by_name(caserun.run.plan.product.name)
482
483
        issue_type = self.find_issue_type_by_name(project, 'Bug')
484
485
        query = "issue[tracker_id]=" + str(issue_type.id)
486
        query += "&issue[subject]=" + quote('Failed test:{0}'.format(caserun.case.summary))
487
488
        txt = caserun.case.get_text_with_version(case_text_version=caserun.case_text_version)
489
490
        comment = "Filed from caserun %s\n\n" % caserun.get_full_url()
491
        comment += "Product:\n%s\n\n" % caserun.run.plan.product.name
492
        comment += "Component(s):\n%s\n\n" % caserun.case.component.values_list('name', flat=True)
493
        comment += "Version-Release number of selected " \
494
                   "component (if applicable):\n"
495
        comment += "%s\n\n" % caserun.build.name
496
        comment += "Steps to Reproduce: \n%s\n\n" % txt
497
        comment += "Actual results: \n<describe what happened>\n\n"
498
499
        query += "&issue[description]={0}".format(quote(comment))
500
501
        url = self.tracker.base_url
502
        if not url.endswith('/'):
503
            url += '/'
504
505
        return url + '/projects/{0}/issues/new?'.format(str(project.id)) + query
506
507
508
class LinkOnly(IssueTrackerType):
509
    """
510
        Allow only linking issues to TestExecution records. Can be used when your
511
        issue tracker is not integrated with Kiwi TCMS.
512
513
        No additional API integration available!
514
    """
515
516
    def is_adding_testcase_to_issue_disabled(self):
517
        return True
518