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): |
|
0 ignored issues
–
show
Duplication
introduced
by
Loading history...
|
|||
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 |