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 tempfile |
10
|
|
|
from urllib.parse import urlencode |
11
|
|
|
|
12
|
|
|
import jira |
13
|
|
|
import github |
14
|
|
|
import bugzilla |
15
|
|
|
import gitlab |
16
|
|
|
|
17
|
|
|
from django.conf import settings |
18
|
|
|
|
19
|
|
|
from tcms.issuetracker import bugzilla_integration |
20
|
|
|
from tcms.issuetracker import jira_integration |
21
|
|
|
from tcms.issuetracker import github_integration |
22
|
|
|
from tcms.issuetracker import gitlab_integration |
23
|
|
|
|
24
|
|
|
|
25
|
|
|
class IssueTrackerType: |
26
|
|
|
""" |
27
|
|
|
Represents actions which can be performed with issue trackers. |
28
|
|
|
This is a common interface for all issue trackers that Kiwi TCMS |
29
|
|
|
supports! |
30
|
|
|
""" |
31
|
|
|
|
32
|
|
|
def __init__(self, tracker): |
33
|
|
|
""" |
34
|
|
|
:tracker: - BugSystem object |
35
|
|
|
""" |
36
|
|
|
self.tracker = tracker |
37
|
|
|
|
38
|
|
|
@classmethod |
39
|
|
|
def from_name(cls, name): |
40
|
|
|
""" |
41
|
|
|
Return the class which matches ``name`` if it exists inside this |
42
|
|
|
module or raise an exception. |
43
|
|
|
""" |
44
|
|
|
if name not in globals(): |
45
|
|
|
raise NotImplementedError('IT of type %s is not supported' % name) |
46
|
|
|
return globals()[name] |
47
|
|
|
|
48
|
|
|
def report_issue_from_testcase(self, caserun): |
49
|
|
|
""" |
50
|
|
|
When marking Test Case results inside a Test Run there is a |
51
|
|
|
`Report` link. When the `Report` link is clicked this method is called |
52
|
|
|
to help the user report an issue in the IT. |
53
|
|
|
|
54
|
|
|
This is implemented by constructing an URL string which will pre-fill |
55
|
|
|
bug details like steps to reproduce, product, version, etc from the |
56
|
|
|
test case. Then we open this URL into another browser window! |
57
|
|
|
|
58
|
|
|
:caserun: - TestCaseRun object |
59
|
|
|
:return: - string - URL |
60
|
|
|
""" |
61
|
|
|
raise NotImplementedError() |
62
|
|
|
|
63
|
|
|
def add_testcase_to_issue(self, testcases, issue): |
64
|
|
|
""" |
65
|
|
|
When adding issues to Test Execution results there is a |
66
|
|
|
'Check to add test cases to Issue tracker' checkbox. If |
67
|
|
|
selected this method is called to link the bug report to the |
68
|
|
|
test case which was used to discover the bug. |
69
|
|
|
|
70
|
|
|
Usually this is implemented by adding a new comment pointing |
71
|
|
|
back to the test case via the internal RPC object. |
72
|
|
|
|
73
|
|
|
:testcases: - list of TestCase objects |
74
|
|
|
:issue: - Bug object |
75
|
|
|
""" |
76
|
|
|
raise NotImplementedError() |
77
|
|
|
|
78
|
|
|
# pylint: disable = invalid-name, no-self-use |
79
|
|
|
# todo: we should allow this method to raise and the specific error |
80
|
|
|
# message must be returned to the caller instead of generic one. |
81
|
|
|
# as it is LinkOnly tracker doesn't have any integrations but the error |
82
|
|
|
# message is misleading |
83
|
|
|
def is_adding_testcase_to_issue_disabled(self): |
84
|
|
|
""" |
85
|
|
|
When is linking a TC to a Bug report disabled? |
86
|
|
|
Usually when all the required credentials are provided. |
87
|
|
|
|
88
|
|
|
:return: - boolean |
89
|
|
|
""" |
90
|
|
|
return not (self.tracker.api_url |
91
|
|
|
and self.tracker.api_username |
92
|
|
|
and self.tracker.api_password) |
93
|
|
|
|
94
|
|
|
def all_issues_link(self, _ids): |
95
|
|
|
""" |
96
|
|
|
Used in testruns.views.get() aka run/report.html to produce |
97
|
|
|
a single URL which will open all reported issues into a single |
98
|
|
|
page in the Issue tracker. For example Bugzilla supports listing |
99
|
|
|
multiple bugs into a table. GitHub on the other hand doesn't |
100
|
|
|
support this functionality. |
101
|
|
|
|
102
|
|
|
:ids: - list of issues reported against test executions |
103
|
|
|
|
104
|
|
|
:return: - None if not suported or string representing the URL |
105
|
|
|
""" |
106
|
|
|
|
107
|
|
|
|
108
|
|
|
class Bugzilla(IssueTrackerType): |
109
|
|
|
""" |
110
|
|
|
Support for Bugzilla. Requires: |
111
|
|
|
|
112
|
|
|
:api_url: - the XML-RPC URL for your Bugzilla instance |
113
|
|
|
:api_username: - a username registered in Bugzilla |
114
|
|
|
:api_password: - the password for this username |
115
|
|
|
|
116
|
|
|
You can also provide the ``BUGZILLA_AUTH_CACHE_DIR`` setting (in ``product.py``) |
117
|
|
|
to control where authentication cookies for Bugzilla will be saved. If this |
118
|
|
|
is not provided a temporary directory will be used each time we try to login |
119
|
|
|
into Bugzilla! |
120
|
|
|
""" |
121
|
|
|
|
122
|
|
|
def __init__(self, tracker): |
123
|
|
|
super().__init__(tracker) |
124
|
|
|
|
125
|
|
|
# directory for Bugzilla credentials |
126
|
|
|
self._bugzilla_cache_dir = getattr( |
127
|
|
|
settings, |
128
|
|
|
"BUGZILLA_AUTH_CACHE_DIR", |
129
|
|
|
tempfile.mkdtemp(prefix='.bugzilla-') |
130
|
|
|
) |
131
|
|
|
if not os.path.exists(self._bugzilla_cache_dir): |
132
|
|
|
os.makedirs(self._bugzilla_cache_dir, 0o700) |
133
|
|
|
|
134
|
|
|
self._rpc = None |
135
|
|
|
|
136
|
|
|
@property |
137
|
|
|
def rpc(self): |
138
|
|
|
if self._rpc is None: |
139
|
|
|
self._rpc = bugzilla.Bugzilla( |
140
|
|
|
self.tracker.api_url, |
141
|
|
|
user=self.tracker.api_username, |
142
|
|
|
password=self.tracker.api_password, |
143
|
|
|
cookiefile=self._bugzilla_cache_dir + 'cookie', |
144
|
|
|
tokenfile=self._bugzilla_cache_dir + 'token', |
145
|
|
|
) |
146
|
|
|
return self._rpc |
147
|
|
|
|
148
|
|
|
def add_testcase_to_issue(self, testcases, issue): |
149
|
|
|
for case in testcases: |
150
|
|
|
bugzilla_integration.BugzillaThread(self.rpc, case, issue).start() |
151
|
|
|
|
152
|
|
|
def all_issues_link(self, ids): |
153
|
|
|
if not self.tracker.base_url: |
154
|
|
|
return None |
155
|
|
|
|
156
|
|
|
if not self.tracker.base_url.endswith('/'): |
157
|
|
|
self.tracker.base_url += '/' |
158
|
|
|
|
159
|
|
|
return self.tracker.base_url + 'buglist.cgi?bugidtype=include&bug_id=%s' % ','.join(ids) |
160
|
|
|
|
161
|
|
|
def report_issue_from_testcase(self, caserun): |
162
|
|
|
args = {} |
163
|
|
|
args['cf_build_id'] = caserun.run.build.name |
164
|
|
|
|
165
|
|
|
txt = caserun.case.get_text_with_version(case_text_version=caserun.case_text_version) |
166
|
|
|
|
167
|
|
|
comment = "Filed from caserun %s\n\n" % caserun.get_full_url() |
168
|
|
|
comment += "Version-Release number of selected " \ |
169
|
|
|
"component (if applicable):\n" |
170
|
|
|
comment += '%s\n\n' % caserun.build.name |
171
|
|
|
comment += "Steps to Reproduce: \n%s\n\n" % txt |
172
|
|
|
comment += "Actual results: \n<describe what happened>\n\n" |
173
|
|
|
|
174
|
|
|
args['comment'] = comment |
175
|
|
|
args['component'] = caserun.case.component.values_list('name', |
176
|
|
|
flat=True) |
177
|
|
|
args['product'] = caserun.run.plan.product.name |
178
|
|
|
args['short_desc'] = 'Test case failure: %s' % caserun.case.summary |
179
|
|
|
args['version'] = caserun.run.product_version |
180
|
|
|
|
181
|
|
|
url = self.tracker.base_url |
182
|
|
|
if not url.endswith('/'): |
183
|
|
|
url += '/' |
184
|
|
|
|
185
|
|
|
return url + 'enter_bug.cgi?' + urlencode(args, True) |
186
|
|
|
|
187
|
|
|
|
188
|
|
|
class JIRA(IssueTrackerType): |
189
|
|
|
""" |
190
|
|
|
Support for JIRA. Requires: |
191
|
|
|
|
192
|
|
|
:api_url: - the API URL for your JIRA instance |
193
|
|
|
:api_username: - a username registered in JIRA |
194
|
|
|
:api_password: - the password for this username |
195
|
|
|
|
196
|
|
|
Additional control can be applied via the ``JIRA_OPTIONS`` configuration |
197
|
|
|
setting (in ``product.py``). By default this setting is not provided and |
198
|
|
|
the code uses ``jira.JIRA.DEFAULT_OPTIONS`` from the ``jira`` Python module! |
199
|
|
|
""" |
200
|
|
|
|
201
|
|
|
def __init__(self, tracker): |
202
|
|
|
super(JIRA, self).__init__(tracker) |
203
|
|
|
|
204
|
|
|
if hasattr(settings, 'JIRA_OPTIONS'): |
205
|
|
|
options = settings.JIRA_OPTIONS |
206
|
|
|
else: |
207
|
|
|
options = None |
208
|
|
|
|
209
|
|
|
# b/c jira.JIRA tries to connect when object is created |
210
|
|
|
# see https://github.com/kiwitcms/Kiwi/issues/100 |
211
|
|
|
if not self.is_adding_testcase_to_issue_disabled(): |
212
|
|
|
self.rpc = jira.JIRA( |
213
|
|
|
tracker.api_url, |
214
|
|
|
basic_auth=(self.tracker.api_username, self.tracker.api_password), |
215
|
|
|
options=options, |
216
|
|
|
) |
217
|
|
|
|
218
|
|
|
def add_testcase_to_issue(self, testcases, issue): |
219
|
|
|
for case in testcases: |
220
|
|
|
jira_integration.JiraThread(self.rpc, case, issue).start() |
221
|
|
|
|
222
|
|
|
def all_issues_link(self, ids): |
223
|
|
|
if not self.tracker.base_url: |
224
|
|
|
return None |
225
|
|
|
|
226
|
|
|
if not self.tracker.base_url.endswith('/'): |
227
|
|
|
self.tracker.base_url += '/' |
228
|
|
|
|
229
|
|
|
return self.tracker.base_url + 'issues/?jql=issueKey%%20in%%20(%s)' % '%2C%20'.join(ids) |
230
|
|
|
|
231
|
|
|
def report_issue_from_testcase(self, caserun): |
232
|
|
|
""" |
233
|
|
|
For the HTML API description see: |
234
|
|
|
https://confluence.atlassian.com/display/JIRA050/Creating+Issues+via+direct+HTML+links |
235
|
|
|
""" |
236
|
|
|
# note: your jira instance needs to have the same projects |
237
|
|
|
# defined otherwise this will fail! |
238
|
|
|
project = self.rpc.project(caserun.run.plan.product.name) |
239
|
|
|
|
240
|
|
|
try: |
241
|
|
|
issue_type = self.rpc.issue_type_by_name('Bug') |
242
|
|
|
except KeyError: |
243
|
|
|
issue_type = self.rpc.issue_types()[0] |
244
|
|
|
|
245
|
|
|
args = { |
246
|
|
|
'pid': project.id, |
247
|
|
|
'issuetype': issue_type.id, |
248
|
|
|
'summary': 'Failed test: %s' % caserun.case.summary, |
249
|
|
|
} |
250
|
|
|
|
251
|
|
|
try: |
252
|
|
|
# apparently JIRA can't search users via their e-mail so try to |
253
|
|
|
# search by username and hope that it matches |
254
|
|
|
tested_by = caserun.tested_by |
255
|
|
|
if not tested_by: |
256
|
|
|
tested_by = caserun.assignee |
257
|
|
|
|
258
|
|
|
args['reporter'] = self.rpc.user(tested_by.username).key |
259
|
|
|
except jira.JIRAError: |
260
|
|
|
pass |
261
|
|
|
|
262
|
|
|
txt = caserun.case.get_text_with_version(case_text_version=caserun.case_text_version) |
263
|
|
|
|
264
|
|
|
comment = "Filed from caserun %s\n\n" % caserun.get_full_url() |
265
|
|
|
comment += "Product:\n%s\n\n" % caserun.run.plan.product.name |
266
|
|
|
comment += "Component(s):\n%s\n\n" % caserun.case.component.values_list('name', flat=True) |
267
|
|
|
comment += "Version-Release number of selected " \ |
268
|
|
|
"component (if applicable):\n" |
269
|
|
|
comment += "%s\n\n" % caserun.build.name |
270
|
|
|
comment += "Steps to Reproduce: \n%s\n\n" % txt |
271
|
|
|
comment += "Actual results: \n<describe what happened>\n\n" |
272
|
|
|
args['description'] = comment |
273
|
|
|
|
274
|
|
|
url = self.tracker.base_url |
275
|
|
|
if not url.endswith('/'): |
276
|
|
|
url += '/' |
277
|
|
|
|
278
|
|
|
return url + '/secure/CreateIssueDetails!init.jspa?' + urlencode(args, True) |
279
|
|
|
|
280
|
|
|
|
281
|
|
View Code Duplication |
class GitHub(IssueTrackerType): |
|
|
|
|
282
|
|
|
""" |
283
|
|
|
Support for GitHub. Requires: |
284
|
|
|
|
285
|
|
|
:base_url: - URL to a GitHub repository for which we're going to report issues |
286
|
|
|
:api_password: - GitHub API token. |
287
|
|
|
|
288
|
|
|
.. note:: |
289
|
|
|
|
290
|
|
|
You can leave the ``api_url`` and ``api_username`` fields blank because |
291
|
|
|
the integration code doesn't use them! |
292
|
|
|
|
293
|
|
|
.. note:: |
294
|
|
|
|
295
|
|
|
GitHub does not support displaying multiple issues in a table format like |
296
|
|
|
Bugzilla and JIRA do. This means that in Test Execution Report view you will |
297
|
|
|
see GitHub issues listed one by one and there will not be a link to open all |
298
|
|
|
of them inside GitHub's interface! |
299
|
|
|
""" |
300
|
|
|
|
301
|
|
|
def __init__(self, tracker): |
302
|
|
|
super(GitHub, self).__init__(tracker) |
303
|
|
|
|
304
|
|
|
# NOTE: we use an access token so only the password field is required |
305
|
|
|
self.rpc = github.Github(self.tracker.api_password) |
306
|
|
|
|
307
|
|
|
def add_testcase_to_issue(self, testcases, issue): |
308
|
|
|
for case in testcases: |
309
|
|
|
github_integration.GitHubThread(self.rpc, self.tracker, case, issue).start() |
310
|
|
|
|
311
|
|
|
def is_adding_testcase_to_issue_disabled(self): |
312
|
|
|
return not (self.tracker.base_url and self.tracker.api_password) |
313
|
|
|
|
314
|
|
|
def report_issue_from_testcase(self, caserun): |
315
|
|
|
""" |
316
|
|
|
GitHub only supports title and body parameters |
317
|
|
|
""" |
318
|
|
|
args = { |
319
|
|
|
'title': 'Failed test: %s' % caserun.case.summary, |
320
|
|
|
} |
321
|
|
|
|
322
|
|
|
txt = caserun.case.get_text_with_version(case_text_version=caserun.case_text_version) |
323
|
|
|
|
324
|
|
|
comment = "Filed from caserun %s\n\n" % caserun.get_full_url() |
325
|
|
|
comment += "Product:\n%s\n\n" % caserun.run.plan.product.name |
326
|
|
|
comment += "Component(s):\n%s\n\n" % caserun.case.component.values_list('name', flat=True) |
327
|
|
|
comment += "Version-Release number of selected " \ |
328
|
|
|
"component (if applicable):\n" |
329
|
|
|
comment += "%s\n\n" % caserun.build.name |
330
|
|
|
comment += "Steps to Reproduce: \n%s\n\n" % txt |
331
|
|
|
comment += "Actual results: \n<describe what happened>\n\n" |
332
|
|
|
args['body'] = comment |
333
|
|
|
|
334
|
|
|
url = self.tracker.base_url |
335
|
|
|
if not url.endswith('/'): |
336
|
|
|
url += '/' |
337
|
|
|
|
338
|
|
|
return url + '/issues/new?' + urlencode(args, True) |
339
|
|
|
|
340
|
|
|
|
341
|
|
View Code Duplication |
class Gitlab(IssueTrackerType): |
|
|
|
|
342
|
|
|
""" |
343
|
|
|
Support for Gitlab. Requires: |
344
|
|
|
|
345
|
|
|
:base_url: - URL to a Gitlab repository for which we're going to report issues |
346
|
|
|
:api_password: - Gitlab API token. |
347
|
|
|
""" |
348
|
|
|
|
349
|
|
|
def __init__(self, tracker): |
350
|
|
|
super(Gitlab, self).__init__(tracker) |
351
|
|
|
|
352
|
|
|
# we use an access token so only the password field is required |
353
|
|
|
self.rpc = gitlab.Gitlab(self.tracker.api_url, private_token=self.tracker.api_password) |
354
|
|
|
|
355
|
|
|
def add_testcase_to_issue(self, testcases, issue): |
356
|
|
|
for case in testcases: |
357
|
|
|
gitlab_integration.GitlabThread(self.rpc, self.tracker, case, issue).start() |
358
|
|
|
|
359
|
|
|
def is_adding_testcase_to_issue_disabled(self): |
360
|
|
|
return not (self.tracker.base_url and self.tracker.api_password) |
361
|
|
|
|
362
|
|
|
def report_issue_from_testcase(self, caserun): |
363
|
|
|
args = { |
364
|
|
|
'issue[title]': 'Failed test: %s' % caserun.case.summary, |
365
|
|
|
} |
366
|
|
|
|
367
|
|
|
txt = caserun.case.get_text_with_version(case_text_version=caserun.case_text_version) |
368
|
|
|
|
369
|
|
|
comment = "Filed from caserun %s\n\n" % caserun.get_full_url() |
370
|
|
|
comment += "**Product**:\n%s\n\n" % caserun.run.plan.product.name |
371
|
|
|
comment += "**Component(s)**:\n%s\n\n"\ |
372
|
|
|
% caserun.case.component.values_list('name', flat=True) |
373
|
|
|
comment += "Version-Release number of selected " \ |
374
|
|
|
"component (if applicable):\n" |
375
|
|
|
comment += "%s\n\n" % caserun.build.name |
376
|
|
|
comment += "**Steps to Reproduce**: \n%s\n\n" % txt |
377
|
|
|
comment += "**Actual results**: \n<describe what happened>\n\n" |
378
|
|
|
args['issue[description]'] = comment |
379
|
|
|
|
380
|
|
|
url = self.tracker.base_url |
381
|
|
|
if not url.endswith('/'): |
382
|
|
|
url += '/' |
383
|
|
|
|
384
|
|
|
return url + '/issues/new?' + urlencode(args, True) |
385
|
|
|
|
386
|
|
|
|
387
|
|
|
class LinkOnly(IssueTrackerType): |
388
|
|
|
""" |
389
|
|
|
Allow only linking issues to TestExecution records. Can be used when your |
390
|
|
|
issue tracker is not integrated with Kiwi TCMS. |
391
|
|
|
|
392
|
|
|
No additional API integration available! |
393
|
|
|
""" |
394
|
|
|
|
395
|
|
|
def is_adding_testcase_to_issue_disabled(self): |
396
|
|
|
return True |
397
|
|
|
|