##// END OF EJS Templates
comments: edit functionality added
wuboo -
r4401:f098a3f9 default
parent child Browse files
Show More

The requested changes are too big and content was truncated. Show full diff

1 NO CONTENT: new file 100644
The requested commit or file is too big and content was truncated. Show full diff
@@ -0,0 +1,35 b''
1 # -*- coding: utf-8 -*-
2
3 import logging
4 from sqlalchemy import *
5
6 from alembic.migration import MigrationContext
7 from alembic.operations import Operations
8 from sqlalchemy import BigInteger
9
10 from rhodecode.lib.dbmigrate.versions import _reset_base
11 from rhodecode.model import init_model_encryption
12
13
14 log = logging.getLogger(__name__)
15
16
17 def upgrade(migrate_engine):
18 """
19 Upgrade operations go here.
20 Don't create your own engine; bind migrate_engine to your metadata
21 """
22 _reset_base(migrate_engine)
23 from rhodecode.lib.dbmigrate.schema import db_4_19_0_2 as db
24
25 init_model_encryption(db)
26 db.ChangesetCommentHistory().__table__.create()
27
28
29 def downgrade(migrate_engine):
30 meta = MetaData()
31 meta.bind = migrate_engine
32
33
34 def fixups(models, _SESSION):
35 pass
@@ -0,0 +1,25 b''
1 # -*- coding: utf-8 -*-
2
3 # Copyright (C) 2020-2020 RhodeCode GmbH
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
21 ## base64 filter e.g ${ example | base64,n }
22 def base64(text):
23 import base64
24 from rhodecode.lib.helpers import safe_str
25 return base64.encodestring(safe_str(text))
1 NO CONTENT: new file 100644
The requested commit or file is too big and content was truncated. Show full diff
@@ -1,60 +1,60 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import os
22 22 from collections import OrderedDict
23 23
24 24 import sys
25 25 import platform
26 26
27 27 VERSION = tuple(open(os.path.join(
28 28 os.path.dirname(__file__), 'VERSION')).read().split('.'))
29 29
30 30 BACKENDS = OrderedDict()
31 31
32 32 BACKENDS['hg'] = 'Mercurial repository'
33 33 BACKENDS['git'] = 'Git repository'
34 34 BACKENDS['svn'] = 'Subversion repository'
35 35
36 36
37 37 CELERY_ENABLED = False
38 38 CELERY_EAGER = False
39 39
40 40 # link to config for pyramid
41 41 CONFIG = {}
42 42
43 43 # Populated with the settings dictionary from application init in
44 44 # rhodecode.conf.environment.load_pyramid_environment
45 45 PYRAMID_SETTINGS = {}
46 46
47 47 # Linked module for extensions
48 48 EXTENSIONS = {}
49 49
50 50 __version__ = ('.'.join((str(each) for each in VERSION[:3])))
51 __dbversion__ = 107 # defines current db version for migrations
51 __dbversion__ = 108 # defines current db version for migrations
52 52 __platform__ = platform.system()
53 53 __license__ = 'AGPLv3, and Commercial License'
54 54 __author__ = 'RhodeCode GmbH'
55 55 __url__ = 'https://code.rhodecode.com'
56 56
57 57 is_windows = __platform__ in ['Windows']
58 58 is_unix = not is_windows
59 59 is_test = False
60 60 disable_error_handler = False
@@ -1,520 +1,533 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2016-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20 from rhodecode.apps._base import add_route_with_slash
21 21
22 22
23 23 def includeme(config):
24 24
25 25 # repo creating checks, special cases that aren't repo routes
26 26 config.add_route(
27 27 name='repo_creating',
28 28 pattern='/{repo_name:.*?[^/]}/repo_creating')
29 29
30 30 config.add_route(
31 31 name='repo_creating_check',
32 32 pattern='/{repo_name:.*?[^/]}/repo_creating_check')
33 33
34 34 # Summary
35 35 # NOTE(marcink): one additional route is defined in very bottom, catch
36 36 # all pattern
37 37 config.add_route(
38 38 name='repo_summary_explicit',
39 39 pattern='/{repo_name:.*?[^/]}/summary', repo_route=True)
40 40 config.add_route(
41 41 name='repo_summary_commits',
42 42 pattern='/{repo_name:.*?[^/]}/summary-commits', repo_route=True)
43 43
44 44 # Commits
45 45 config.add_route(
46 46 name='repo_commit',
47 47 pattern='/{repo_name:.*?[^/]}/changeset/{commit_id}', repo_route=True)
48 48
49 49 config.add_route(
50 50 name='repo_commit_children',
51 51 pattern='/{repo_name:.*?[^/]}/changeset_children/{commit_id}', repo_route=True)
52 52
53 53 config.add_route(
54 54 name='repo_commit_parents',
55 55 pattern='/{repo_name:.*?[^/]}/changeset_parents/{commit_id}', repo_route=True)
56 56
57 57 config.add_route(
58 58 name='repo_commit_raw',
59 59 pattern='/{repo_name:.*?[^/]}/changeset-diff/{commit_id}', repo_route=True)
60 60
61 61 config.add_route(
62 62 name='repo_commit_patch',
63 63 pattern='/{repo_name:.*?[^/]}/changeset-patch/{commit_id}', repo_route=True)
64 64
65 65 config.add_route(
66 66 name='repo_commit_download',
67 67 pattern='/{repo_name:.*?[^/]}/changeset-download/{commit_id}', repo_route=True)
68 68
69 69 config.add_route(
70 70 name='repo_commit_data',
71 71 pattern='/{repo_name:.*?[^/]}/changeset-data/{commit_id}', repo_route=True)
72 72
73 73 config.add_route(
74 74 name='repo_commit_comment_create',
75 75 pattern='/{repo_name:.*?[^/]}/changeset/{commit_id}/comment/create', repo_route=True)
76 76
77 77 config.add_route(
78 78 name='repo_commit_comment_preview',
79 79 pattern='/{repo_name:.*?[^/]}/changeset/{commit_id}/comment/preview', repo_route=True)
80 80
81 81 config.add_route(
82 name='repo_commit_comment_history_view',
83 pattern='/{repo_name:.*?[^/]}/changeset/{commit_id}/comment/{comment_history_id}/history_view', repo_route=True)
84
85 config.add_route(
82 86 name='repo_commit_comment_attachment_upload',
83 87 pattern='/{repo_name:.*?[^/]}/changeset/{commit_id}/comment/attachment_upload', repo_route=True)
84 88
85 89 config.add_route(
86 90 name='repo_commit_comment_delete',
87 91 pattern='/{repo_name:.*?[^/]}/changeset/{commit_id}/comment/{comment_id}/delete', repo_route=True)
88 92
93 config.add_route(
94 name='repo_commit_comment_edit',
95 pattern='/{repo_name:.*?[^/]}/changeset/{commit_id}/comment/{comment_id}/edit', repo_route=True)
96
89 97 # still working url for backward compat.
90 98 config.add_route(
91 99 name='repo_commit_raw_deprecated',
92 100 pattern='/{repo_name:.*?[^/]}/raw-changeset/{commit_id}', repo_route=True)
93 101
94 102 # Files
95 103 config.add_route(
96 104 name='repo_archivefile',
97 105 pattern='/{repo_name:.*?[^/]}/archive/{fname:.*}', repo_route=True)
98 106
99 107 config.add_route(
100 108 name='repo_files_diff',
101 109 pattern='/{repo_name:.*?[^/]}/diff/{f_path:.*}', repo_route=True)
102 110 config.add_route( # legacy route to make old links work
103 111 name='repo_files_diff_2way_redirect',
104 112 pattern='/{repo_name:.*?[^/]}/diff-2way/{f_path:.*}', repo_route=True)
105 113
106 114 config.add_route(
107 115 name='repo_files',
108 116 pattern='/{repo_name:.*?[^/]}/files/{commit_id}/{f_path:.*}', repo_route=True)
109 117 config.add_route(
110 118 name='repo_files:default_path',
111 119 pattern='/{repo_name:.*?[^/]}/files/{commit_id}/', repo_route=True)
112 120 config.add_route(
113 121 name='repo_files:default_commit',
114 122 pattern='/{repo_name:.*?[^/]}/files', repo_route=True)
115 123
116 124 config.add_route(
117 125 name='repo_files:rendered',
118 126 pattern='/{repo_name:.*?[^/]}/render/{commit_id}/{f_path:.*}', repo_route=True)
119 127
120 128 config.add_route(
121 129 name='repo_files:annotated',
122 130 pattern='/{repo_name:.*?[^/]}/annotate/{commit_id}/{f_path:.*}', repo_route=True)
123 131 config.add_route(
124 132 name='repo_files:annotated_previous',
125 133 pattern='/{repo_name:.*?[^/]}/annotate-previous/{commit_id}/{f_path:.*}', repo_route=True)
126 134
127 135 config.add_route(
128 136 name='repo_nodetree_full',
129 137 pattern='/{repo_name:.*?[^/]}/nodetree_full/{commit_id}/{f_path:.*}', repo_route=True)
130 138 config.add_route(
131 139 name='repo_nodetree_full:default_path',
132 140 pattern='/{repo_name:.*?[^/]}/nodetree_full/{commit_id}/', repo_route=True)
133 141
134 142 config.add_route(
135 143 name='repo_files_nodelist',
136 144 pattern='/{repo_name:.*?[^/]}/nodelist/{commit_id}/{f_path:.*}', repo_route=True)
137 145
138 146 config.add_route(
139 147 name='repo_file_raw',
140 148 pattern='/{repo_name:.*?[^/]}/raw/{commit_id}/{f_path:.*}', repo_route=True)
141 149
142 150 config.add_route(
143 151 name='repo_file_download',
144 152 pattern='/{repo_name:.*?[^/]}/download/{commit_id}/{f_path:.*}', repo_route=True)
145 153 config.add_route( # backward compat to keep old links working
146 154 name='repo_file_download:legacy',
147 155 pattern='/{repo_name:.*?[^/]}/rawfile/{commit_id}/{f_path:.*}',
148 156 repo_route=True)
149 157
150 158 config.add_route(
151 159 name='repo_file_history',
152 160 pattern='/{repo_name:.*?[^/]}/history/{commit_id}/{f_path:.*}', repo_route=True)
153 161
154 162 config.add_route(
155 163 name='repo_file_authors',
156 164 pattern='/{repo_name:.*?[^/]}/authors/{commit_id}/{f_path:.*}', repo_route=True)
157 165
158 166 config.add_route(
159 167 name='repo_files_check_head',
160 168 pattern='/{repo_name:.*?[^/]}/check_head/{commit_id}/{f_path:.*}',
161 169 repo_route=True)
162 170 config.add_route(
163 171 name='repo_files_remove_file',
164 172 pattern='/{repo_name:.*?[^/]}/remove_file/{commit_id}/{f_path:.*}',
165 173 repo_route=True)
166 174 config.add_route(
167 175 name='repo_files_delete_file',
168 176 pattern='/{repo_name:.*?[^/]}/delete_file/{commit_id}/{f_path:.*}',
169 177 repo_route=True)
170 178 config.add_route(
171 179 name='repo_files_edit_file',
172 180 pattern='/{repo_name:.*?[^/]}/edit_file/{commit_id}/{f_path:.*}',
173 181 repo_route=True)
174 182 config.add_route(
175 183 name='repo_files_update_file',
176 184 pattern='/{repo_name:.*?[^/]}/update_file/{commit_id}/{f_path:.*}',
177 185 repo_route=True)
178 186 config.add_route(
179 187 name='repo_files_add_file',
180 188 pattern='/{repo_name:.*?[^/]}/add_file/{commit_id}/{f_path:.*}',
181 189 repo_route=True)
182 190 config.add_route(
183 191 name='repo_files_upload_file',
184 192 pattern='/{repo_name:.*?[^/]}/upload_file/{commit_id}/{f_path:.*}',
185 193 repo_route=True)
186 194 config.add_route(
187 195 name='repo_files_create_file',
188 196 pattern='/{repo_name:.*?[^/]}/create_file/{commit_id}/{f_path:.*}',
189 197 repo_route=True)
190 198
191 199 # Refs data
192 200 config.add_route(
193 201 name='repo_refs_data',
194 202 pattern='/{repo_name:.*?[^/]}/refs-data', repo_route=True)
195 203
196 204 config.add_route(
197 205 name='repo_refs_changelog_data',
198 206 pattern='/{repo_name:.*?[^/]}/refs-data-changelog', repo_route=True)
199 207
200 208 config.add_route(
201 209 name='repo_stats',
202 210 pattern='/{repo_name:.*?[^/]}/repo_stats/{commit_id}', repo_route=True)
203 211
204 212 # Commits
205 213 config.add_route(
206 214 name='repo_commits',
207 215 pattern='/{repo_name:.*?[^/]}/commits', repo_route=True)
208 216 config.add_route(
209 217 name='repo_commits_file',
210 218 pattern='/{repo_name:.*?[^/]}/commits/{commit_id}/{f_path:.*}', repo_route=True)
211 219 config.add_route(
212 220 name='repo_commits_elements',
213 221 pattern='/{repo_name:.*?[^/]}/commits_elements', repo_route=True)
214 222 config.add_route(
215 223 name='repo_commits_elements_file',
216 224 pattern='/{repo_name:.*?[^/]}/commits_elements/{commit_id}/{f_path:.*}', repo_route=True)
217 225
218 226 # Changelog (old deprecated name for commits page)
219 227 config.add_route(
220 228 name='repo_changelog',
221 229 pattern='/{repo_name:.*?[^/]}/changelog', repo_route=True)
222 230 config.add_route(
223 231 name='repo_changelog_file',
224 232 pattern='/{repo_name:.*?[^/]}/changelog/{commit_id}/{f_path:.*}', repo_route=True)
225 233
226 234 # Compare
227 235 config.add_route(
228 236 name='repo_compare_select',
229 237 pattern='/{repo_name:.*?[^/]}/compare', repo_route=True)
230 238
231 239 config.add_route(
232 240 name='repo_compare',
233 241 pattern='/{repo_name:.*?[^/]}/compare/{source_ref_type}@{source_ref:.*?}...{target_ref_type}@{target_ref:.*?}', repo_route=True)
234 242
235 243 # Tags
236 244 config.add_route(
237 245 name='tags_home',
238 246 pattern='/{repo_name:.*?[^/]}/tags', repo_route=True)
239 247
240 248 # Branches
241 249 config.add_route(
242 250 name='branches_home',
243 251 pattern='/{repo_name:.*?[^/]}/branches', repo_route=True)
244 252
245 253 # Bookmarks
246 254 config.add_route(
247 255 name='bookmarks_home',
248 256 pattern='/{repo_name:.*?[^/]}/bookmarks', repo_route=True)
249 257
250 258 # Forks
251 259 config.add_route(
252 260 name='repo_fork_new',
253 261 pattern='/{repo_name:.*?[^/]}/fork', repo_route=True,
254 262 repo_forbid_when_archived=True,
255 263 repo_accepted_types=['hg', 'git'])
256 264
257 265 config.add_route(
258 266 name='repo_fork_create',
259 267 pattern='/{repo_name:.*?[^/]}/fork/create', repo_route=True,
260 268 repo_forbid_when_archived=True,
261 269 repo_accepted_types=['hg', 'git'])
262 270
263 271 config.add_route(
264 272 name='repo_forks_show_all',
265 273 pattern='/{repo_name:.*?[^/]}/forks', repo_route=True,
266 274 repo_accepted_types=['hg', 'git'])
267 275 config.add_route(
268 276 name='repo_forks_data',
269 277 pattern='/{repo_name:.*?[^/]}/forks/data', repo_route=True,
270 278 repo_accepted_types=['hg', 'git'])
271 279
272 280 # Pull Requests
273 281 config.add_route(
274 282 name='pullrequest_show',
275 283 pattern='/{repo_name:.*?[^/]}/pull-request/{pull_request_id:\d+}',
276 284 repo_route=True)
277 285
278 286 config.add_route(
279 287 name='pullrequest_show_all',
280 288 pattern='/{repo_name:.*?[^/]}/pull-request',
281 289 repo_route=True, repo_accepted_types=['hg', 'git'])
282 290
283 291 config.add_route(
284 292 name='pullrequest_show_all_data',
285 293 pattern='/{repo_name:.*?[^/]}/pull-request-data',
286 294 repo_route=True, repo_accepted_types=['hg', 'git'])
287 295
288 296 config.add_route(
289 297 name='pullrequest_repo_refs',
290 298 pattern='/{repo_name:.*?[^/]}/pull-request/refs/{target_repo_name:.*?[^/]}',
291 299 repo_route=True)
292 300
293 301 config.add_route(
294 302 name='pullrequest_repo_targets',
295 303 pattern='/{repo_name:.*?[^/]}/pull-request/repo-targets',
296 304 repo_route=True)
297 305
298 306 config.add_route(
299 307 name='pullrequest_new',
300 308 pattern='/{repo_name:.*?[^/]}/pull-request/new',
301 309 repo_route=True, repo_accepted_types=['hg', 'git'],
302 310 repo_forbid_when_archived=True)
303 311
304 312 config.add_route(
305 313 name='pullrequest_create',
306 314 pattern='/{repo_name:.*?[^/]}/pull-request/create',
307 315 repo_route=True, repo_accepted_types=['hg', 'git'],
308 316 repo_forbid_when_archived=True)
309 317
310 318 config.add_route(
311 319 name='pullrequest_update',
312 320 pattern='/{repo_name:.*?[^/]}/pull-request/{pull_request_id:\d+}/update',
313 321 repo_route=True, repo_forbid_when_archived=True)
314 322
315 323 config.add_route(
316 324 name='pullrequest_merge',
317 325 pattern='/{repo_name:.*?[^/]}/pull-request/{pull_request_id:\d+}/merge',
318 326 repo_route=True, repo_forbid_when_archived=True)
319 327
320 328 config.add_route(
321 329 name='pullrequest_delete',
322 330 pattern='/{repo_name:.*?[^/]}/pull-request/{pull_request_id:\d+}/delete',
323 331 repo_route=True, repo_forbid_when_archived=True)
324 332
325 333 config.add_route(
326 334 name='pullrequest_comment_create',
327 335 pattern='/{repo_name:.*?[^/]}/pull-request/{pull_request_id:\d+}/comment',
328 336 repo_route=True)
329 337
330 338 config.add_route(
339 name='pullrequest_comment_edit',
340 pattern='/{repo_name:.*?[^/]}/pull-request/{pull_request_id:\d+}/comment/{comment_id}/edit',
341 repo_route=True, repo_accepted_types=['hg', 'git'])
342
343 config.add_route(
331 344 name='pullrequest_comment_delete',
332 345 pattern='/{repo_name:.*?[^/]}/pull-request/{pull_request_id:\d+}/comment/{comment_id}/delete',
333 346 repo_route=True, repo_accepted_types=['hg', 'git'])
334 347
335 348 # Artifacts, (EE feature)
336 349 config.add_route(
337 350 name='repo_artifacts_list',
338 351 pattern='/{repo_name:.*?[^/]}/artifacts', repo_route=True)
339 352
340 353 # Settings
341 354 config.add_route(
342 355 name='edit_repo',
343 356 pattern='/{repo_name:.*?[^/]}/settings', repo_route=True)
344 357 # update is POST on edit_repo
345 358
346 359 # Settings advanced
347 360 config.add_route(
348 361 name='edit_repo_advanced',
349 362 pattern='/{repo_name:.*?[^/]}/settings/advanced', repo_route=True)
350 363 config.add_route(
351 364 name='edit_repo_advanced_archive',
352 365 pattern='/{repo_name:.*?[^/]}/settings/advanced/archive', repo_route=True)
353 366 config.add_route(
354 367 name='edit_repo_advanced_delete',
355 368 pattern='/{repo_name:.*?[^/]}/settings/advanced/delete', repo_route=True)
356 369 config.add_route(
357 370 name='edit_repo_advanced_locking',
358 371 pattern='/{repo_name:.*?[^/]}/settings/advanced/locking', repo_route=True)
359 372 config.add_route(
360 373 name='edit_repo_advanced_journal',
361 374 pattern='/{repo_name:.*?[^/]}/settings/advanced/journal', repo_route=True)
362 375 config.add_route(
363 376 name='edit_repo_advanced_fork',
364 377 pattern='/{repo_name:.*?[^/]}/settings/advanced/fork', repo_route=True)
365 378
366 379 config.add_route(
367 380 name='edit_repo_advanced_hooks',
368 381 pattern='/{repo_name:.*?[^/]}/settings/advanced/hooks', repo_route=True)
369 382
370 383 # Caches
371 384 config.add_route(
372 385 name='edit_repo_caches',
373 386 pattern='/{repo_name:.*?[^/]}/settings/caches', repo_route=True)
374 387
375 388 # Permissions
376 389 config.add_route(
377 390 name='edit_repo_perms',
378 391 pattern='/{repo_name:.*?[^/]}/settings/permissions', repo_route=True)
379 392
380 393 config.add_route(
381 394 name='edit_repo_perms_set_private',
382 395 pattern='/{repo_name:.*?[^/]}/settings/permissions/set_private', repo_route=True)
383 396
384 397 # Permissions Branch (EE feature)
385 398 config.add_route(
386 399 name='edit_repo_perms_branch',
387 400 pattern='/{repo_name:.*?[^/]}/settings/branch_permissions', repo_route=True)
388 401 config.add_route(
389 402 name='edit_repo_perms_branch_delete',
390 403 pattern='/{repo_name:.*?[^/]}/settings/branch_permissions/{rule_id}/delete',
391 404 repo_route=True)
392 405
393 406 # Maintenance
394 407 config.add_route(
395 408 name='edit_repo_maintenance',
396 409 pattern='/{repo_name:.*?[^/]}/settings/maintenance', repo_route=True)
397 410
398 411 config.add_route(
399 412 name='edit_repo_maintenance_execute',
400 413 pattern='/{repo_name:.*?[^/]}/settings/maintenance/execute', repo_route=True)
401 414
402 415 # Fields
403 416 config.add_route(
404 417 name='edit_repo_fields',
405 418 pattern='/{repo_name:.*?[^/]}/settings/fields', repo_route=True)
406 419 config.add_route(
407 420 name='edit_repo_fields_create',
408 421 pattern='/{repo_name:.*?[^/]}/settings/fields/create', repo_route=True)
409 422 config.add_route(
410 423 name='edit_repo_fields_delete',
411 424 pattern='/{repo_name:.*?[^/]}/settings/fields/{field_id}/delete', repo_route=True)
412 425
413 426 # Locking
414 427 config.add_route(
415 428 name='repo_edit_toggle_locking',
416 429 pattern='/{repo_name:.*?[^/]}/settings/toggle_locking', repo_route=True)
417 430
418 431 # Remote
419 432 config.add_route(
420 433 name='edit_repo_remote',
421 434 pattern='/{repo_name:.*?[^/]}/settings/remote', repo_route=True)
422 435 config.add_route(
423 436 name='edit_repo_remote_pull',
424 437 pattern='/{repo_name:.*?[^/]}/settings/remote/pull', repo_route=True)
425 438 config.add_route(
426 439 name='edit_repo_remote_push',
427 440 pattern='/{repo_name:.*?[^/]}/settings/remote/push', repo_route=True)
428 441
429 442 # Statistics
430 443 config.add_route(
431 444 name='edit_repo_statistics',
432 445 pattern='/{repo_name:.*?[^/]}/settings/statistics', repo_route=True)
433 446 config.add_route(
434 447 name='edit_repo_statistics_reset',
435 448 pattern='/{repo_name:.*?[^/]}/settings/statistics/update', repo_route=True)
436 449
437 450 # Issue trackers
438 451 config.add_route(
439 452 name='edit_repo_issuetracker',
440 453 pattern='/{repo_name:.*?[^/]}/settings/issue_trackers', repo_route=True)
441 454 config.add_route(
442 455 name='edit_repo_issuetracker_test',
443 456 pattern='/{repo_name:.*?[^/]}/settings/issue_trackers/test', repo_route=True)
444 457 config.add_route(
445 458 name='edit_repo_issuetracker_delete',
446 459 pattern='/{repo_name:.*?[^/]}/settings/issue_trackers/delete', repo_route=True)
447 460 config.add_route(
448 461 name='edit_repo_issuetracker_update',
449 462 pattern='/{repo_name:.*?[^/]}/settings/issue_trackers/update', repo_route=True)
450 463
451 464 # VCS Settings
452 465 config.add_route(
453 466 name='edit_repo_vcs',
454 467 pattern='/{repo_name:.*?[^/]}/settings/vcs', repo_route=True)
455 468 config.add_route(
456 469 name='edit_repo_vcs_update',
457 470 pattern='/{repo_name:.*?[^/]}/settings/vcs/update', repo_route=True)
458 471
459 472 # svn pattern
460 473 config.add_route(
461 474 name='edit_repo_vcs_svn_pattern_delete',
462 475 pattern='/{repo_name:.*?[^/]}/settings/vcs/svn_pattern/delete', repo_route=True)
463 476
464 477 # Repo Review Rules (EE feature)
465 478 config.add_route(
466 479 name='repo_reviewers',
467 480 pattern='/{repo_name:.*?[^/]}/settings/review/rules', repo_route=True)
468 481
469 482 config.add_route(
470 483 name='repo_default_reviewers_data',
471 484 pattern='/{repo_name:.*?[^/]}/settings/review/default-reviewers', repo_route=True)
472 485
473 486 # Repo Automation (EE feature)
474 487 config.add_route(
475 488 name='repo_automation',
476 489 pattern='/{repo_name:.*?[^/]}/settings/automation', repo_route=True)
477 490
478 491 # Strip
479 492 config.add_route(
480 493 name='edit_repo_strip',
481 494 pattern='/{repo_name:.*?[^/]}/settings/strip', repo_route=True)
482 495
483 496 config.add_route(
484 497 name='strip_check',
485 498 pattern='/{repo_name:.*?[^/]}/settings/strip_check', repo_route=True)
486 499
487 500 config.add_route(
488 501 name='strip_execute',
489 502 pattern='/{repo_name:.*?[^/]}/settings/strip_execute', repo_route=True)
490 503
491 504 # Audit logs
492 505 config.add_route(
493 506 name='edit_repo_audit_logs',
494 507 pattern='/{repo_name:.*?[^/]}/settings/audit_logs', repo_route=True)
495 508
496 509 # ATOM/RSS Feed, shouldn't contain slashes for outlook compatibility
497 510 config.add_route(
498 511 name='rss_feed_home',
499 512 pattern='/{repo_name:.*?[^/]}/feed-rss', repo_route=True)
500 513
501 514 config.add_route(
502 515 name='atom_feed_home',
503 516 pattern='/{repo_name:.*?[^/]}/feed-atom', repo_route=True)
504 517
505 518 config.add_route(
506 519 name='rss_feed_home_old',
507 520 pattern='/{repo_name:.*?[^/]}/feed/rss', repo_route=True)
508 521
509 522 config.add_route(
510 523 name='atom_feed_home_old',
511 524 pattern='/{repo_name:.*?[^/]}/feed/atom', repo_route=True)
512 525
513 526 # NOTE(marcink): needs to be at the end for catch-all
514 527 add_route_with_slash(
515 528 config,
516 529 name='repo_summary',
517 530 pattern='/{repo_name:.*?[^/]}', repo_route=True)
518 531
519 532 # Scan module for configuration decorators.
520 533 config.scan('.views', ignore='.tests')
@@ -1,348 +1,507 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import pytest
22 22
23 23 from rhodecode.tests import TestController
24 24
25 25 from rhodecode.model.db import ChangesetComment, Notification
26 26 from rhodecode.model.meta import Session
27 27 from rhodecode.lib import helpers as h
28 28
29 29
30 30 def route_path(name, params=None, **kwargs):
31 31 import urllib
32 32
33 33 base_url = {
34 34 'repo_commit': '/{repo_name}/changeset/{commit_id}',
35 35 'repo_commit_comment_create': '/{repo_name}/changeset/{commit_id}/comment/create',
36 36 'repo_commit_comment_preview': '/{repo_name}/changeset/{commit_id}/comment/preview',
37 37 'repo_commit_comment_delete': '/{repo_name}/changeset/{commit_id}/comment/{comment_id}/delete',
38 'repo_commit_comment_edit': '/{repo_name}/changeset/{commit_id}/comment/{comment_id}/edit',
38 39 }[name].format(**kwargs)
39 40
40 41 if params:
41 42 base_url = '{}?{}'.format(base_url, urllib.urlencode(params))
42 43 return base_url
43 44
44 45
45 46 @pytest.mark.backends("git", "hg", "svn")
46 47 class TestRepoCommitCommentsView(TestController):
47 48
48 49 @pytest.fixture(autouse=True)
49 50 def prepare(self, request, baseapp):
50 51 for x in ChangesetComment.query().all():
51 52 Session().delete(x)
52 53 Session().commit()
53 54
54 55 for x in Notification.query().all():
55 56 Session().delete(x)
56 57 Session().commit()
57 58
58 59 request.addfinalizer(self.cleanup)
59 60
60 61 def cleanup(self):
61 62 for x in ChangesetComment.query().all():
62 63 Session().delete(x)
63 64 Session().commit()
64 65
65 66 for x in Notification.query().all():
66 67 Session().delete(x)
67 68 Session().commit()
68 69
69 70 @pytest.mark.parametrize('comment_type', ChangesetComment.COMMENT_TYPES)
70 71 def test_create(self, comment_type, backend):
71 72 self.log_user()
72 73 commit = backend.repo.get_commit('300')
73 74 commit_id = commit.raw_id
74 75 text = u'CommentOnCommit'
75 76
76 77 params = {'text': text, 'csrf_token': self.csrf_token,
77 78 'comment_type': comment_type}
78 79 self.app.post(
79 80 route_path('repo_commit_comment_create',
80 81 repo_name=backend.repo_name, commit_id=commit_id),
81 82 params=params)
82 83
83 84 response = self.app.get(
84 85 route_path('repo_commit',
85 86 repo_name=backend.repo_name, commit_id=commit_id))
86 87
87 88 # test DB
88 89 assert ChangesetComment.query().count() == 1
89 90 assert_comment_links(response, ChangesetComment.query().count(), 0)
90 91
91 92 assert Notification.query().count() == 1
92 93 assert ChangesetComment.query().count() == 1
93 94
94 95 notification = Notification.query().all()[0]
95 96
96 97 comment_id = ChangesetComment.query().first().comment_id
97 98 assert notification.type_ == Notification.TYPE_CHANGESET_COMMENT
98 99
99 100 author = notification.created_by_user.username_and_name
100 101 sbj = '@{0} left a {1} on commit `{2}` in the `{3}` repository'.format(
101 102 author, comment_type, h.show_id(commit), backend.repo_name)
102 103 assert sbj == notification.subject
103 104
104 105 lnk = (u'/{0}/changeset/{1}#comment-{2}'.format(
105 106 backend.repo_name, commit_id, comment_id))
106 107 assert lnk in notification.body
107 108
108 109 @pytest.mark.parametrize('comment_type', ChangesetComment.COMMENT_TYPES)
109 110 def test_create_inline(self, comment_type, backend):
110 111 self.log_user()
111 112 commit = backend.repo.get_commit('300')
112 113 commit_id = commit.raw_id
113 114 text = u'CommentOnCommit'
114 115 f_path = 'vcs/web/simplevcs/views/repository.py'
115 116 line = 'n1'
116 117
117 118 params = {'text': text, 'f_path': f_path, 'line': line,
118 119 'comment_type': comment_type,
119 120 'csrf_token': self.csrf_token}
120 121
121 122 self.app.post(
122 123 route_path('repo_commit_comment_create',
123 124 repo_name=backend.repo_name, commit_id=commit_id),
124 125 params=params)
125 126
126 127 response = self.app.get(
127 128 route_path('repo_commit',
128 129 repo_name=backend.repo_name, commit_id=commit_id))
129 130
130 131 # test DB
131 132 assert ChangesetComment.query().count() == 1
132 133 assert_comment_links(response, 0, ChangesetComment.query().count())
133 134
134 135 if backend.alias == 'svn':
135 136 response.mustcontain(
136 137 '''data-f-path="vcs/commands/summary.py" '''
137 138 '''data-anchor-id="c-300-ad05457a43f8"'''
138 139 )
139 140 if backend.alias == 'git':
140 141 response.mustcontain(
141 142 '''data-f-path="vcs/backends/hg.py" '''
142 143 '''data-anchor-id="c-883e775e89ea-9c390eb52cd6"'''
143 144 )
144 145
145 146 if backend.alias == 'hg':
146 147 response.mustcontain(
147 148 '''data-f-path="vcs/backends/hg.py" '''
148 149 '''data-anchor-id="c-e58d85a3973b-9c390eb52cd6"'''
149 150 )
150 151
151 152 assert Notification.query().count() == 1
152 153 assert ChangesetComment.query().count() == 1
153 154
154 155 notification = Notification.query().all()[0]
155 156 comment = ChangesetComment.query().first()
156 157 assert notification.type_ == Notification.TYPE_CHANGESET_COMMENT
157 158
158 159 assert comment.revision == commit_id
159 160
160 161 author = notification.created_by_user.username_and_name
161 162 sbj = '@{0} left a {1} on file `{2}` in commit `{3}` in the `{4}` repository'.format(
162 163 author, comment_type, f_path, h.show_id(commit), backend.repo_name)
163 164
164 165 assert sbj == notification.subject
165 166
166 167 lnk = (u'/{0}/changeset/{1}#comment-{2}'.format(
167 168 backend.repo_name, commit_id, comment.comment_id))
168 169 assert lnk in notification.body
169 170 assert 'on line n1' in notification.body
170 171
171 172 def test_create_with_mention(self, backend):
172 173 self.log_user()
173 174
174 175 commit_id = backend.repo.get_commit('300').raw_id
175 176 text = u'@test_regular check CommentOnCommit'
176 177
177 178 params = {'text': text, 'csrf_token': self.csrf_token}
178 179 self.app.post(
179 180 route_path('repo_commit_comment_create',
180 181 repo_name=backend.repo_name, commit_id=commit_id),
181 182 params=params)
182 183
183 184 response = self.app.get(
184 185 route_path('repo_commit',
185 186 repo_name=backend.repo_name, commit_id=commit_id))
186 187 # test DB
187 188 assert ChangesetComment.query().count() == 1
188 189 assert_comment_links(response, ChangesetComment.query().count(), 0)
189 190
190 191 notification = Notification.query().one()
191 192
192 193 assert len(notification.recipients) == 2
193 194 users = [x.username for x in notification.recipients]
194 195
195 196 # test_regular gets notification by @mention
196 197 assert sorted(users) == [u'test_admin', u'test_regular']
197 198
198 199 def test_create_with_status_change(self, backend):
199 200 self.log_user()
200 201 commit = backend.repo.get_commit('300')
201 202 commit_id = commit.raw_id
202 203 text = u'CommentOnCommit'
203 204 f_path = 'vcs/web/simplevcs/views/repository.py'
204 205 line = 'n1'
205 206
206 207 params = {'text': text, 'changeset_status': 'approved',
207 208 'csrf_token': self.csrf_token}
208 209
209 210 self.app.post(
210 211 route_path(
211 212 'repo_commit_comment_create',
212 213 repo_name=backend.repo_name, commit_id=commit_id),
213 214 params=params)
214 215
215 216 response = self.app.get(
216 217 route_path('repo_commit',
217 218 repo_name=backend.repo_name, commit_id=commit_id))
218 219
219 220 # test DB
220 221 assert ChangesetComment.query().count() == 1
221 222 assert_comment_links(response, ChangesetComment.query().count(), 0)
222 223
223 224 assert Notification.query().count() == 1
224 225 assert ChangesetComment.query().count() == 1
225 226
226 227 notification = Notification.query().all()[0]
227 228
228 229 comment_id = ChangesetComment.query().first().comment_id
229 230 assert notification.type_ == Notification.TYPE_CHANGESET_COMMENT
230 231
231 232 author = notification.created_by_user.username_and_name
232 233 sbj = '[status: Approved] @{0} left a note on commit `{1}` in the `{2}` repository'.format(
233 234 author, h.show_id(commit), backend.repo_name)
234 235 assert sbj == notification.subject
235 236
236 237 lnk = (u'/{0}/changeset/{1}#comment-{2}'.format(
237 238 backend.repo_name, commit_id, comment_id))
238 239 assert lnk in notification.body
239 240
240 241 def test_delete(self, backend):
241 242 self.log_user()
242 243 commit_id = backend.repo.get_commit('300').raw_id
243 244 text = u'CommentOnCommit'
244 245
245 246 params = {'text': text, 'csrf_token': self.csrf_token}
246 247 self.app.post(
247 248 route_path(
248 249 'repo_commit_comment_create',
249 250 repo_name=backend.repo_name, commit_id=commit_id),
250 251 params=params)
251 252
252 253 comments = ChangesetComment.query().all()
253 254 assert len(comments) == 1
254 255 comment_id = comments[0].comment_id
255 256
256 257 self.app.post(
257 258 route_path('repo_commit_comment_delete',
258 259 repo_name=backend.repo_name,
259 260 commit_id=commit_id,
260 261 comment_id=comment_id),
261 262 params={'csrf_token': self.csrf_token})
262 263
263 264 comments = ChangesetComment.query().all()
264 265 assert len(comments) == 0
265 266
266 267 response = self.app.get(
267 268 route_path('repo_commit',
268 269 repo_name=backend.repo_name, commit_id=commit_id))
269 270 assert_comment_links(response, 0, 0)
270 271
272 def test_edit(self, backend):
273 self.log_user()
274 commit_id = backend.repo.get_commit('300').raw_id
275 text = u'CommentOnCommit'
276
277 params = {'text': text, 'csrf_token': self.csrf_token}
278 self.app.post(
279 route_path(
280 'repo_commit_comment_create',
281 repo_name=backend.repo_name, commit_id=commit_id),
282 params=params)
283
284 comments = ChangesetComment.query().all()
285 assert len(comments) == 1
286 comment_id = comments[0].comment_id
287 test_text = 'test_text'
288 self.app.post(
289 route_path(
290 'repo_commit_comment_edit',
291 repo_name=backend.repo_name,
292 commit_id=commit_id,
293 comment_id=comment_id,
294 ),
295 params={
296 'csrf_token': self.csrf_token,
297 'text': test_text,
298 'version': '0',
299 })
300
301 text_form_db = ChangesetComment.query().filter(
302 ChangesetComment.comment_id == comment_id).first().text
303 assert test_text == text_form_db
304
305 def test_edit_without_change(self, backend):
306 self.log_user()
307 commit_id = backend.repo.get_commit('300').raw_id
308 text = u'CommentOnCommit'
309
310 params = {'text': text, 'csrf_token': self.csrf_token}
311 self.app.post(
312 route_path(
313 'repo_commit_comment_create',
314 repo_name=backend.repo_name, commit_id=commit_id),
315 params=params)
316
317 comments = ChangesetComment.query().all()
318 assert len(comments) == 1
319 comment_id = comments[0].comment_id
320
321 response = self.app.post(
322 route_path(
323 'repo_commit_comment_edit',
324 repo_name=backend.repo_name,
325 commit_id=commit_id,
326 comment_id=comment_id,
327 ),
328 params={
329 'csrf_token': self.csrf_token,
330 'text': text,
331 'version': '0',
332 },
333 status=404,
334 )
335 assert response.status_int == 404
336
337 def test_edit_try_edit_already_edited(self, backend):
338 self.log_user()
339 commit_id = backend.repo.get_commit('300').raw_id
340 text = u'CommentOnCommit'
341
342 params = {'text': text, 'csrf_token': self.csrf_token}
343 self.app.post(
344 route_path(
345 'repo_commit_comment_create',
346 repo_name=backend.repo_name, commit_id=commit_id
347 ),
348 params=params,
349 )
350
351 comments = ChangesetComment.query().all()
352 assert len(comments) == 1
353 comment_id = comments[0].comment_id
354 test_text = 'test_text'
355 self.app.post(
356 route_path(
357 'repo_commit_comment_edit',
358 repo_name=backend.repo_name,
359 commit_id=commit_id,
360 comment_id=comment_id,
361 ),
362 params={
363 'csrf_token': self.csrf_token,
364 'text': test_text,
365 'version': '0',
366 }
367 )
368 test_text_v2 = 'test_v2'
369 response = self.app.post(
370 route_path(
371 'repo_commit_comment_edit',
372 repo_name=backend.repo_name,
373 commit_id=commit_id,
374 comment_id=comment_id,
375 ),
376 params={
377 'csrf_token': self.csrf_token,
378 'text': test_text_v2,
379 'version': '0',
380 },
381 status=404,
382 )
383 assert response.status_int == 404
384
385 text_form_db = ChangesetComment.query().filter(
386 ChangesetComment.comment_id == comment_id).first().text
387
388 assert test_text == text_form_db
389 assert test_text_v2 != text_form_db
390
391 def test_edit_forbidden_for_immutable_comments(self, backend):
392 self.log_user()
393 commit_id = backend.repo.get_commit('300').raw_id
394 text = u'CommentOnCommit'
395
396 params = {'text': text, 'csrf_token': self.csrf_token, 'version': '0'}
397 self.app.post(
398 route_path(
399 'repo_commit_comment_create',
400 repo_name=backend.repo_name,
401 commit_id=commit_id,
402 ),
403 params=params
404 )
405
406 comments = ChangesetComment.query().all()
407 assert len(comments) == 1
408 comment_id = comments[0].comment_id
409
410 comment = ChangesetComment.get(comment_id)
411 comment.immutable_state = ChangesetComment.OP_IMMUTABLE
412 Session().add(comment)
413 Session().commit()
414
415 response = self.app.post(
416 route_path(
417 'repo_commit_comment_edit',
418 repo_name=backend.repo_name,
419 commit_id=commit_id,
420 comment_id=comment_id,
421 ),
422 params={
423 'csrf_token': self.csrf_token,
424 'text': 'test_text',
425 },
426 status=403,
427 )
428 assert response.status_int == 403
429
271 430 def test_delete_forbidden_for_immutable_comments(self, backend):
272 431 self.log_user()
273 432 commit_id = backend.repo.get_commit('300').raw_id
274 433 text = u'CommentOnCommit'
275 434
276 435 params = {'text': text, 'csrf_token': self.csrf_token}
277 436 self.app.post(
278 437 route_path(
279 438 'repo_commit_comment_create',
280 439 repo_name=backend.repo_name, commit_id=commit_id),
281 440 params=params)
282 441
283 442 comments = ChangesetComment.query().all()
284 443 assert len(comments) == 1
285 444 comment_id = comments[0].comment_id
286 445
287 446 comment = ChangesetComment.get(comment_id)
288 447 comment.immutable_state = ChangesetComment.OP_IMMUTABLE
289 448 Session().add(comment)
290 449 Session().commit()
291 450
292 451 self.app.post(
293 452 route_path('repo_commit_comment_delete',
294 453 repo_name=backend.repo_name,
295 454 commit_id=commit_id,
296 455 comment_id=comment_id),
297 456 params={'csrf_token': self.csrf_token},
298 457 status=403)
299 458
300 459 @pytest.mark.parametrize('renderer, text_input, output', [
301 460 ('rst', 'plain text', '<p>plain text</p>'),
302 461 ('rst', 'header\n======', '<h1 class="title">header</h1>'),
303 462 ('rst', '*italics*', '<em>italics</em>'),
304 463 ('rst', '**bold**', '<strong>bold</strong>'),
305 464 ('markdown', 'plain text', '<p>plain text</p>'),
306 465 ('markdown', '# header', '<h1>header</h1>'),
307 466 ('markdown', '*italics*', '<em>italics</em>'),
308 467 ('markdown', '**bold**', '<strong>bold</strong>'),
309 468 ], ids=['rst-plain', 'rst-header', 'rst-italics', 'rst-bold', 'md-plain',
310 469 'md-header', 'md-italics', 'md-bold', ])
311 470 def test_preview(self, renderer, text_input, output, backend, xhr_header):
312 471 self.log_user()
313 472 params = {
314 473 'renderer': renderer,
315 474 'text': text_input,
316 475 'csrf_token': self.csrf_token
317 476 }
318 477 commit_id = '0' * 16 # fake this for tests
319 478 response = self.app.post(
320 479 route_path('repo_commit_comment_preview',
321 480 repo_name=backend.repo_name, commit_id=commit_id,),
322 481 params=params,
323 482 extra_environ=xhr_header)
324 483
325 484 response.mustcontain(output)
326 485
327 486
328 487 def assert_comment_links(response, comments, inline_comments):
329 488 if comments == 1:
330 489 comments_text = "%d General" % comments
331 490 else:
332 491 comments_text = "%d General" % comments
333 492
334 493 if inline_comments == 1:
335 494 inline_comments_text = "%d Inline" % inline_comments
336 495 else:
337 496 inline_comments_text = "%d Inline" % inline_comments
338 497
339 498 if comments:
340 499 response.mustcontain('<a href="#comments">%s</a>,' % comments_text)
341 500 else:
342 501 response.mustcontain(comments_text)
343 502
344 503 if inline_comments:
345 504 response.mustcontain(
346 505 'id="inline-comments-counter">%s' % inline_comments_text)
347 506 else:
348 507 response.mustcontain(inline_comments_text)
@@ -1,1217 +1,1427 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20 import mock
21 21 import pytest
22 22
23 23 import rhodecode
24 24 from rhodecode.lib.vcs.backends.base import MergeResponse, MergeFailureReason
25 25 from rhodecode.lib.vcs.nodes import FileNode
26 26 from rhodecode.lib import helpers as h
27 27 from rhodecode.model.changeset_status import ChangesetStatusModel
28 28 from rhodecode.model.db import (
29 29 PullRequest, ChangesetStatus, UserLog, Notification, ChangesetComment, Repository)
30 30 from rhodecode.model.meta import Session
31 31 from rhodecode.model.pull_request import PullRequestModel
32 32 from rhodecode.model.user import UserModel
33 from rhodecode.model.comment import CommentsModel
33 34 from rhodecode.tests import (
34 35 assert_session_flash, TEST_USER_ADMIN_LOGIN, TEST_USER_REGULAR_LOGIN)
35 36
36 37
37 38 def route_path(name, params=None, **kwargs):
38 39 import urllib
39 40
40 41 base_url = {
41 42 'repo_changelog': '/{repo_name}/changelog',
42 43 'repo_changelog_file': '/{repo_name}/changelog/{commit_id}/{f_path}',
43 44 'repo_commits': '/{repo_name}/commits',
44 45 'repo_commits_file': '/{repo_name}/commits/{commit_id}/{f_path}',
45 46 'pullrequest_show': '/{repo_name}/pull-request/{pull_request_id}',
46 47 'pullrequest_show_all': '/{repo_name}/pull-request',
47 48 'pullrequest_show_all_data': '/{repo_name}/pull-request-data',
48 49 'pullrequest_repo_refs': '/{repo_name}/pull-request/refs/{target_repo_name:.*?[^/]}',
49 50 'pullrequest_repo_targets': '/{repo_name}/pull-request/repo-destinations',
50 51 'pullrequest_new': '/{repo_name}/pull-request/new',
51 52 'pullrequest_create': '/{repo_name}/pull-request/create',
52 53 'pullrequest_update': '/{repo_name}/pull-request/{pull_request_id}/update',
53 54 'pullrequest_merge': '/{repo_name}/pull-request/{pull_request_id}/merge',
54 55 'pullrequest_delete': '/{repo_name}/pull-request/{pull_request_id}/delete',
55 56 'pullrequest_comment_create': '/{repo_name}/pull-request/{pull_request_id}/comment',
56 57 'pullrequest_comment_delete': '/{repo_name}/pull-request/{pull_request_id}/comment/{comment_id}/delete',
58 'pullrequest_comment_edit': '/{repo_name}/pull-request/{pull_request_id}/comment/{comment_id}/edit',
57 59 }[name].format(**kwargs)
58 60
59 61 if params:
60 62 base_url = '{}?{}'.format(base_url, urllib.urlencode(params))
61 63 return base_url
62 64
63 65
64 66 @pytest.mark.usefixtures('app', 'autologin_user')
65 67 @pytest.mark.backends("git", "hg")
66 68 class TestPullrequestsView(object):
67 69
68 70 def test_index(self, backend):
69 71 self.app.get(route_path(
70 72 'pullrequest_new',
71 73 repo_name=backend.repo_name))
72 74
73 75 def test_option_menu_create_pull_request_exists(self, backend):
74 76 repo_name = backend.repo_name
75 77 response = self.app.get(h.route_path('repo_summary', repo_name=repo_name))
76 78
77 79 create_pr_link = '<a href="%s">Create Pull Request</a>' % route_path(
78 80 'pullrequest_new', repo_name=repo_name)
79 81 response.mustcontain(create_pr_link)
80 82
81 83 def test_create_pr_form_with_raw_commit_id(self, backend):
82 84 repo = backend.repo
83 85
84 86 self.app.get(
85 87 route_path('pullrequest_new', repo_name=repo.repo_name,
86 88 commit=repo.get_commit().raw_id),
87 89 status=200)
88 90
89 91 @pytest.mark.parametrize('pr_merge_enabled', [True, False])
90 92 @pytest.mark.parametrize('range_diff', ["0", "1"])
91 93 def test_show(self, pr_util, pr_merge_enabled, range_diff):
92 94 pull_request = pr_util.create_pull_request(
93 95 mergeable=pr_merge_enabled, enable_notifications=False)
94 96
95 97 response = self.app.get(route_path(
96 98 'pullrequest_show',
97 99 repo_name=pull_request.target_repo.scm_instance().name,
98 100 pull_request_id=pull_request.pull_request_id,
99 101 params={'range-diff': range_diff}))
100 102
101 103 for commit_id in pull_request.revisions:
102 104 response.mustcontain(commit_id)
103 105
104 106 response.mustcontain(pull_request.target_ref_parts.type)
105 107 response.mustcontain(pull_request.target_ref_parts.name)
106 108
107 109 response.mustcontain('class="pull-request-merge"')
108 110
109 111 if pr_merge_enabled:
110 112 response.mustcontain('Pull request reviewer approval is pending')
111 113 else:
112 114 response.mustcontain('Server-side pull request merging is disabled.')
113 115
114 116 if range_diff == "1":
115 117 response.mustcontain('Turn off: Show the diff as commit range')
116 118
117 119 def test_close_status_visibility(self, pr_util, user_util, csrf_token):
118 120 # Logout
119 121 response = self.app.post(
120 122 h.route_path('logout'),
121 123 params={'csrf_token': csrf_token})
122 124 # Login as regular user
123 125 response = self.app.post(h.route_path('login'),
124 126 {'username': TEST_USER_REGULAR_LOGIN,
125 127 'password': 'test12'})
126 128
127 129 pull_request = pr_util.create_pull_request(
128 130 author=TEST_USER_REGULAR_LOGIN)
129 131
130 132 response = self.app.get(route_path(
131 133 'pullrequest_show',
132 134 repo_name=pull_request.target_repo.scm_instance().name,
133 135 pull_request_id=pull_request.pull_request_id))
134 136
135 137 response.mustcontain('Server-side pull request merging is disabled.')
136 138
137 139 assert_response = response.assert_response()
138 140 # for regular user without a merge permissions, we don't see it
139 141 assert_response.no_element_exists('#close-pull-request-action')
140 142
141 143 user_util.grant_user_permission_to_repo(
142 144 pull_request.target_repo,
143 145 UserModel().get_by_username(TEST_USER_REGULAR_LOGIN),
144 146 'repository.write')
145 147 response = self.app.get(route_path(
146 148 'pullrequest_show',
147 149 repo_name=pull_request.target_repo.scm_instance().name,
148 150 pull_request_id=pull_request.pull_request_id))
149 151
150 152 response.mustcontain('Server-side pull request merging is disabled.')
151 153
152 154 assert_response = response.assert_response()
153 155 # now regular user has a merge permissions, we have CLOSE button
154 156 assert_response.one_element_exists('#close-pull-request-action')
155 157
156 158 def test_show_invalid_commit_id(self, pr_util):
157 159 # Simulating invalid revisions which will cause a lookup error
158 160 pull_request = pr_util.create_pull_request()
159 161 pull_request.revisions = ['invalid']
160 162 Session().add(pull_request)
161 163 Session().commit()
162 164
163 165 response = self.app.get(route_path(
164 166 'pullrequest_show',
165 167 repo_name=pull_request.target_repo.scm_instance().name,
166 168 pull_request_id=pull_request.pull_request_id))
167 169
168 170 for commit_id in pull_request.revisions:
169 171 response.mustcontain(commit_id)
170 172
171 173 def test_show_invalid_source_reference(self, pr_util):
172 174 pull_request = pr_util.create_pull_request()
173 175 pull_request.source_ref = 'branch:b:invalid'
174 176 Session().add(pull_request)
175 177 Session().commit()
176 178
177 179 self.app.get(route_path(
178 180 'pullrequest_show',
179 181 repo_name=pull_request.target_repo.scm_instance().name,
180 182 pull_request_id=pull_request.pull_request_id))
181 183
182 184 def test_edit_title_description(self, pr_util, csrf_token):
183 185 pull_request = pr_util.create_pull_request()
184 186 pull_request_id = pull_request.pull_request_id
185 187
186 188 response = self.app.post(
187 189 route_path('pullrequest_update',
188 190 repo_name=pull_request.target_repo.repo_name,
189 191 pull_request_id=pull_request_id),
190 192 params={
191 193 'edit_pull_request': 'true',
192 194 'title': 'New title',
193 195 'description': 'New description',
194 196 'csrf_token': csrf_token})
195 197
196 198 assert_session_flash(
197 199 response, u'Pull request title & description updated.',
198 200 category='success')
199 201
200 202 pull_request = PullRequest.get(pull_request_id)
201 203 assert pull_request.title == 'New title'
202 204 assert pull_request.description == 'New description'
203 205
204 206 def test_edit_title_description_closed(self, pr_util, csrf_token):
205 207 pull_request = pr_util.create_pull_request()
206 208 pull_request_id = pull_request.pull_request_id
207 209 repo_name = pull_request.target_repo.repo_name
208 210 pr_util.close()
209 211
210 212 response = self.app.post(
211 213 route_path('pullrequest_update',
212 214 repo_name=repo_name, pull_request_id=pull_request_id),
213 215 params={
214 216 'edit_pull_request': 'true',
215 217 'title': 'New title',
216 218 'description': 'New description',
217 219 'csrf_token': csrf_token}, status=200)
218 220 assert_session_flash(
219 221 response, u'Cannot update closed pull requests.',
220 222 category='error')
221 223
222 224 def test_update_invalid_source_reference(self, pr_util, csrf_token):
223 225 from rhodecode.lib.vcs.backends.base import UpdateFailureReason
224 226
225 227 pull_request = pr_util.create_pull_request()
226 228 pull_request.source_ref = 'branch:invalid-branch:invalid-commit-id'
227 229 Session().add(pull_request)
228 230 Session().commit()
229 231
230 232 pull_request_id = pull_request.pull_request_id
231 233
232 234 response = self.app.post(
233 235 route_path('pullrequest_update',
234 236 repo_name=pull_request.target_repo.repo_name,
235 237 pull_request_id=pull_request_id),
236 238 params={'update_commits': 'true', 'csrf_token': csrf_token})
237 239
238 240 expected_msg = str(PullRequestModel.UPDATE_STATUS_MESSAGES[
239 241 UpdateFailureReason.MISSING_SOURCE_REF])
240 242 assert_session_flash(response, expected_msg, category='error')
241 243
242 244 def test_missing_target_reference(self, pr_util, csrf_token):
243 245 from rhodecode.lib.vcs.backends.base import MergeFailureReason
244 246 pull_request = pr_util.create_pull_request(
245 247 approved=True, mergeable=True)
246 248 unicode_reference = u'branch:invalid-branch:invalid-commit-id'
247 249 pull_request.target_ref = unicode_reference
248 250 Session().add(pull_request)
249 251 Session().commit()
250 252
251 253 pull_request_id = pull_request.pull_request_id
252 254 pull_request_url = route_path(
253 255 'pullrequest_show',
254 256 repo_name=pull_request.target_repo.repo_name,
255 257 pull_request_id=pull_request_id)
256 258
257 259 response = self.app.get(pull_request_url)
258 260 target_ref_id = 'invalid-branch'
259 261 merge_resp = MergeResponse(
260 262 True, True, '', MergeFailureReason.MISSING_TARGET_REF,
261 263 metadata={'target_ref': PullRequest.unicode_to_reference(unicode_reference)})
262 264 response.assert_response().element_contains(
263 265 'div[data-role="merge-message"]', merge_resp.merge_status_message)
264 266
265 267 def test_comment_and_close_pull_request_custom_message_approved(
266 268 self, pr_util, csrf_token, xhr_header):
267 269
268 270 pull_request = pr_util.create_pull_request(approved=True)
269 271 pull_request_id = pull_request.pull_request_id
270 272 author = pull_request.user_id
271 273 repo = pull_request.target_repo.repo_id
272 274
273 275 self.app.post(
274 276 route_path('pullrequest_comment_create',
275 277 repo_name=pull_request.target_repo.scm_instance().name,
276 278 pull_request_id=pull_request_id),
277 279 params={
278 280 'close_pull_request': '1',
279 281 'text': 'Closing a PR',
280 282 'csrf_token': csrf_token},
281 283 extra_environ=xhr_header,)
282 284
283 285 journal = UserLog.query()\
284 286 .filter(UserLog.user_id == author)\
285 287 .filter(UserLog.repository_id == repo) \
286 288 .order_by(UserLog.user_log_id.asc()) \
287 289 .all()
288 290 assert journal[-1].action == 'repo.pull_request.close'
289 291
290 292 pull_request = PullRequest.get(pull_request_id)
291 293 assert pull_request.is_closed()
292 294
293 295 status = ChangesetStatusModel().get_status(
294 296 pull_request.source_repo, pull_request=pull_request)
295 297 assert status == ChangesetStatus.STATUS_APPROVED
296 298 comments = ChangesetComment().query() \
297 299 .filter(ChangesetComment.pull_request == pull_request) \
298 300 .order_by(ChangesetComment.comment_id.asc())\
299 301 .all()
300 302 assert comments[-1].text == 'Closing a PR'
301 303
302 304 def test_comment_force_close_pull_request_rejected(
303 305 self, pr_util, csrf_token, xhr_header):
304 306 pull_request = pr_util.create_pull_request()
305 307 pull_request_id = pull_request.pull_request_id
306 308 PullRequestModel().update_reviewers(
307 309 pull_request_id, [(1, ['reason'], False, []), (2, ['reason2'], False, [])],
308 310 pull_request.author)
309 311 author = pull_request.user_id
310 312 repo = pull_request.target_repo.repo_id
311 313
312 314 self.app.post(
313 315 route_path('pullrequest_comment_create',
314 316 repo_name=pull_request.target_repo.scm_instance().name,
315 317 pull_request_id=pull_request_id),
316 318 params={
317 319 'close_pull_request': '1',
318 320 'csrf_token': csrf_token},
319 321 extra_environ=xhr_header)
320 322
321 323 pull_request = PullRequest.get(pull_request_id)
322 324
323 325 journal = UserLog.query()\
324 326 .filter(UserLog.user_id == author, UserLog.repository_id == repo) \
325 327 .order_by(UserLog.user_log_id.asc()) \
326 328 .all()
327 329 assert journal[-1].action == 'repo.pull_request.close'
328 330
329 331 # check only the latest status, not the review status
330 332 status = ChangesetStatusModel().get_status(
331 333 pull_request.source_repo, pull_request=pull_request)
332 334 assert status == ChangesetStatus.STATUS_REJECTED
333 335
334 336 def test_comment_and_close_pull_request(
335 337 self, pr_util, csrf_token, xhr_header):
336 338 pull_request = pr_util.create_pull_request()
337 339 pull_request_id = pull_request.pull_request_id
338 340
339 341 response = self.app.post(
340 342 route_path('pullrequest_comment_create',
341 repo_name=pull_request.target_repo.scm_instance().name,
342 pull_request_id=pull_request.pull_request_id),
343 repo_name=pull_request.target_repo.scm_instance().name,
344 pull_request_id=pull_request.pull_request_id),
343 345 params={
344 346 'close_pull_request': 'true',
345 347 'csrf_token': csrf_token},
346 348 extra_environ=xhr_header)
347 349
348 350 assert response.json
349 351
350 352 pull_request = PullRequest.get(pull_request_id)
351 353 assert pull_request.is_closed()
352 354
353 355 # check only the latest status, not the review status
354 356 status = ChangesetStatusModel().get_status(
355 357 pull_request.source_repo, pull_request=pull_request)
356 358 assert status == ChangesetStatus.STATUS_REJECTED
357 359
360 def test_comment_and_close_pull_request_try_edit_comment(
361 self, pr_util, csrf_token, xhr_header
362 ):
363 pull_request = pr_util.create_pull_request()
364 pull_request_id = pull_request.pull_request_id
365
366 response = self.app.post(
367 route_path(
368 'pullrequest_comment_create',
369 repo_name=pull_request.target_repo.scm_instance().name,
370 pull_request_id=pull_request.pull_request_id,
371 ),
372 params={
373 'close_pull_request': 'true',
374 'csrf_token': csrf_token,
375 },
376 extra_environ=xhr_header)
377
378 assert response.json
379
380 pull_request = PullRequest.get(pull_request_id)
381 assert pull_request.is_closed()
382
383 # check only the latest status, not the review status
384 status = ChangesetStatusModel().get_status(
385 pull_request.source_repo, pull_request=pull_request)
386 assert status == ChangesetStatus.STATUS_REJECTED
387
388 comment_id = response.json.get('comment_id', None)
389 test_text = 'test'
390 response = self.app.post(
391 route_path(
392 'pullrequest_comment_edit',
393 repo_name=pull_request.target_repo.scm_instance().name,
394 pull_request_id=pull_request.pull_request_id,
395 comment_id=comment_id,
396 ),
397 extra_environ=xhr_header,
398 params={
399 'csrf_token': csrf_token,
400 'text': test_text,
401 },
402 status=403,
403 )
404 assert response.status_int == 403
405
406 def test_comment_and_comment_edit(
407 self, pr_util, csrf_token, xhr_header
408 ):
409 pull_request = pr_util.create_pull_request()
410 response = self.app.post(
411 route_path(
412 'pullrequest_comment_create',
413 repo_name=pull_request.target_repo.scm_instance().name,
414 pull_request_id=pull_request.pull_request_id),
415 params={
416 'csrf_token': csrf_token,
417 'text': 'init',
418 },
419 extra_environ=xhr_header,
420 )
421 assert response.json
422
423 comment_id = response.json.get('comment_id', None)
424 assert comment_id
425 test_text = 'test'
426 self.app.post(
427 route_path(
428 'pullrequest_comment_edit',
429 repo_name=pull_request.target_repo.scm_instance().name,
430 pull_request_id=pull_request.pull_request_id,
431 comment_id=comment_id,
432 ),
433 extra_environ=xhr_header,
434 params={
435 'csrf_token': csrf_token,
436 'text': test_text,
437 'version': '0',
438 },
439
440 )
441 text_form_db = ChangesetComment.query().filter(
442 ChangesetComment.comment_id == comment_id).first().text
443 assert test_text == text_form_db
444
445 def test_comment_and_comment_edit(
446 self, pr_util, csrf_token, xhr_header
447 ):
448 pull_request = pr_util.create_pull_request()
449 response = self.app.post(
450 route_path(
451 'pullrequest_comment_create',
452 repo_name=pull_request.target_repo.scm_instance().name,
453 pull_request_id=pull_request.pull_request_id),
454 params={
455 'csrf_token': csrf_token,
456 'text': 'init',
457 },
458 extra_environ=xhr_header,
459 )
460 assert response.json
461
462 comment_id = response.json.get('comment_id', None)
463 assert comment_id
464 test_text = 'init'
465 response = self.app.post(
466 route_path(
467 'pullrequest_comment_edit',
468 repo_name=pull_request.target_repo.scm_instance().name,
469 pull_request_id=pull_request.pull_request_id,
470 comment_id=comment_id,
471 ),
472 extra_environ=xhr_header,
473 params={
474 'csrf_token': csrf_token,
475 'text': test_text,
476 'version': '0',
477 },
478 status=404,
479
480 )
481 assert response.status_int == 404
482
483 def test_comment_and_try_edit_already_edited(
484 self, pr_util, csrf_token, xhr_header
485 ):
486 pull_request = pr_util.create_pull_request()
487 response = self.app.post(
488 route_path(
489 'pullrequest_comment_create',
490 repo_name=pull_request.target_repo.scm_instance().name,
491 pull_request_id=pull_request.pull_request_id),
492 params={
493 'csrf_token': csrf_token,
494 'text': 'init',
495 },
496 extra_environ=xhr_header,
497 )
498 assert response.json
499 comment_id = response.json.get('comment_id', None)
500 assert comment_id
501 test_text = 'test'
502 response = self.app.post(
503 route_path(
504 'pullrequest_comment_edit',
505 repo_name=pull_request.target_repo.scm_instance().name,
506 pull_request_id=pull_request.pull_request_id,
507 comment_id=comment_id,
508 ),
509 extra_environ=xhr_header,
510 params={
511 'csrf_token': csrf_token,
512 'text': test_text,
513 'version': '0',
514 },
515
516 )
517 test_text_v2 = 'test_v2'
518 response = self.app.post(
519 route_path(
520 'pullrequest_comment_edit',
521 repo_name=pull_request.target_repo.scm_instance().name,
522 pull_request_id=pull_request.pull_request_id,
523 comment_id=comment_id,
524 ),
525 extra_environ=xhr_header,
526 params={
527 'csrf_token': csrf_token,
528 'text': test_text_v2,
529 'version': '0',
530 },
531 status=404,
532 )
533 assert response.status_int == 404
534
535 text_form_db = ChangesetComment.query().filter(
536 ChangesetComment.comment_id == comment_id).first().text
537
538 assert test_text == text_form_db
539 assert test_text_v2 != text_form_db
540
541 def test_comment_and_comment_edit_permissions_forbidden(
542 self, autologin_regular_user, user_regular, user_admin, pr_util,
543 csrf_token, xhr_header):
544 pull_request = pr_util.create_pull_request(
545 author=user_admin.username, enable_notifications=False)
546 comment = CommentsModel().create(
547 text='test',
548 repo=pull_request.target_repo.scm_instance().name,
549 user=user_admin,
550 pull_request=pull_request,
551 )
552 response = self.app.post(
553 route_path(
554 'pullrequest_comment_edit',
555 repo_name=pull_request.target_repo.scm_instance().name,
556 pull_request_id=pull_request.pull_request_id,
557 comment_id=comment.comment_id,
558 ),
559 extra_environ=xhr_header,
560 params={
561 'csrf_token': csrf_token,
562 'text': 'test_text',
563 },
564 status=403,
565 )
566 assert response.status_int == 403
567
358 568 def test_create_pull_request(self, backend, csrf_token):
359 569 commits = [
360 570 {'message': 'ancestor'},
361 571 {'message': 'change'},
362 572 {'message': 'change2'},
363 573 ]
364 574 commit_ids = backend.create_master_repo(commits)
365 575 target = backend.create_repo(heads=['ancestor'])
366 576 source = backend.create_repo(heads=['change2'])
367 577
368 578 response = self.app.post(
369 579 route_path('pullrequest_create', repo_name=source.repo_name),
370 580 [
371 581 ('source_repo', source.repo_name),
372 582 ('source_ref', 'branch:default:' + commit_ids['change2']),
373 583 ('target_repo', target.repo_name),
374 584 ('target_ref', 'branch:default:' + commit_ids['ancestor']),
375 585 ('common_ancestor', commit_ids['ancestor']),
376 586 ('pullrequest_title', 'Title'),
377 587 ('pullrequest_desc', 'Description'),
378 588 ('description_renderer', 'markdown'),
379 589 ('__start__', 'review_members:sequence'),
380 590 ('__start__', 'reviewer:mapping'),
381 591 ('user_id', '1'),
382 592 ('__start__', 'reasons:sequence'),
383 593 ('reason', 'Some reason'),
384 594 ('__end__', 'reasons:sequence'),
385 595 ('__start__', 'rules:sequence'),
386 596 ('__end__', 'rules:sequence'),
387 597 ('mandatory', 'False'),
388 598 ('__end__', 'reviewer:mapping'),
389 599 ('__end__', 'review_members:sequence'),
390 600 ('__start__', 'revisions:sequence'),
391 601 ('revisions', commit_ids['change']),
392 602 ('revisions', commit_ids['change2']),
393 603 ('__end__', 'revisions:sequence'),
394 604 ('user', ''),
395 605 ('csrf_token', csrf_token),
396 606 ],
397 607 status=302)
398 608
399 609 location = response.headers['Location']
400 610 pull_request_id = location.rsplit('/', 1)[1]
401 611 assert pull_request_id != 'new'
402 612 pull_request = PullRequest.get(int(pull_request_id))
403 613
404 614 # check that we have now both revisions
405 615 assert pull_request.revisions == [commit_ids['change2'], commit_ids['change']]
406 616 assert pull_request.source_ref == 'branch:default:' + commit_ids['change2']
407 617 expected_target_ref = 'branch:default:' + commit_ids['ancestor']
408 618 assert pull_request.target_ref == expected_target_ref
409 619
410 620 def test_reviewer_notifications(self, backend, csrf_token):
411 621 # We have to use the app.post for this test so it will create the
412 622 # notifications properly with the new PR
413 623 commits = [
414 624 {'message': 'ancestor',
415 625 'added': [FileNode('file_A', content='content_of_ancestor')]},
416 626 {'message': 'change',
417 627 'added': [FileNode('file_a', content='content_of_change')]},
418 628 {'message': 'change-child'},
419 629 {'message': 'ancestor-child', 'parents': ['ancestor'],
420 630 'added': [
421 631 FileNode('file_B', content='content_of_ancestor_child')]},
422 632 {'message': 'ancestor-child-2'},
423 633 ]
424 634 commit_ids = backend.create_master_repo(commits)
425 635 target = backend.create_repo(heads=['ancestor-child'])
426 636 source = backend.create_repo(heads=['change'])
427 637
428 638 response = self.app.post(
429 639 route_path('pullrequest_create', repo_name=source.repo_name),
430 640 [
431 641 ('source_repo', source.repo_name),
432 642 ('source_ref', 'branch:default:' + commit_ids['change']),
433 643 ('target_repo', target.repo_name),
434 644 ('target_ref', 'branch:default:' + commit_ids['ancestor-child']),
435 645 ('common_ancestor', commit_ids['ancestor']),
436 646 ('pullrequest_title', 'Title'),
437 647 ('pullrequest_desc', 'Description'),
438 648 ('description_renderer', 'markdown'),
439 649 ('__start__', 'review_members:sequence'),
440 650 ('__start__', 'reviewer:mapping'),
441 651 ('user_id', '2'),
442 652 ('__start__', 'reasons:sequence'),
443 653 ('reason', 'Some reason'),
444 654 ('__end__', 'reasons:sequence'),
445 655 ('__start__', 'rules:sequence'),
446 656 ('__end__', 'rules:sequence'),
447 657 ('mandatory', 'False'),
448 658 ('__end__', 'reviewer:mapping'),
449 659 ('__end__', 'review_members:sequence'),
450 660 ('__start__', 'revisions:sequence'),
451 661 ('revisions', commit_ids['change']),
452 662 ('__end__', 'revisions:sequence'),
453 663 ('user', ''),
454 664 ('csrf_token', csrf_token),
455 665 ],
456 666 status=302)
457 667
458 668 location = response.headers['Location']
459 669
460 670 pull_request_id = location.rsplit('/', 1)[1]
461 671 assert pull_request_id != 'new'
462 672 pull_request = PullRequest.get(int(pull_request_id))
463 673
464 674 # Check that a notification was made
465 675 notifications = Notification.query()\
466 676 .filter(Notification.created_by == pull_request.author.user_id,
467 677 Notification.type_ == Notification.TYPE_PULL_REQUEST,
468 678 Notification.subject.contains(
469 679 "requested a pull request review. !%s" % pull_request_id))
470 680 assert len(notifications.all()) == 1
471 681
472 682 # Change reviewers and check that a notification was made
473 683 PullRequestModel().update_reviewers(
474 684 pull_request.pull_request_id, [(1, [], False, [])],
475 685 pull_request.author)
476 686 assert len(notifications.all()) == 2
477 687
478 688 def test_create_pull_request_stores_ancestor_commit_id(self, backend,
479 689 csrf_token):
480 690 commits = [
481 691 {'message': 'ancestor',
482 692 'added': [FileNode('file_A', content='content_of_ancestor')]},
483 693 {'message': 'change',
484 694 'added': [FileNode('file_a', content='content_of_change')]},
485 695 {'message': 'change-child'},
486 696 {'message': 'ancestor-child', 'parents': ['ancestor'],
487 697 'added': [
488 698 FileNode('file_B', content='content_of_ancestor_child')]},
489 699 {'message': 'ancestor-child-2'},
490 700 ]
491 701 commit_ids = backend.create_master_repo(commits)
492 702 target = backend.create_repo(heads=['ancestor-child'])
493 703 source = backend.create_repo(heads=['change'])
494 704
495 705 response = self.app.post(
496 706 route_path('pullrequest_create', repo_name=source.repo_name),
497 707 [
498 708 ('source_repo', source.repo_name),
499 709 ('source_ref', 'branch:default:' + commit_ids['change']),
500 710 ('target_repo', target.repo_name),
501 711 ('target_ref', 'branch:default:' + commit_ids['ancestor-child']),
502 712 ('common_ancestor', commit_ids['ancestor']),
503 713 ('pullrequest_title', 'Title'),
504 714 ('pullrequest_desc', 'Description'),
505 715 ('description_renderer', 'markdown'),
506 716 ('__start__', 'review_members:sequence'),
507 717 ('__start__', 'reviewer:mapping'),
508 718 ('user_id', '1'),
509 719 ('__start__', 'reasons:sequence'),
510 720 ('reason', 'Some reason'),
511 721 ('__end__', 'reasons:sequence'),
512 722 ('__start__', 'rules:sequence'),
513 723 ('__end__', 'rules:sequence'),
514 724 ('mandatory', 'False'),
515 725 ('__end__', 'reviewer:mapping'),
516 726 ('__end__', 'review_members:sequence'),
517 727 ('__start__', 'revisions:sequence'),
518 728 ('revisions', commit_ids['change']),
519 729 ('__end__', 'revisions:sequence'),
520 730 ('user', ''),
521 731 ('csrf_token', csrf_token),
522 732 ],
523 733 status=302)
524 734
525 735 location = response.headers['Location']
526 736
527 737 pull_request_id = location.rsplit('/', 1)[1]
528 738 assert pull_request_id != 'new'
529 739 pull_request = PullRequest.get(int(pull_request_id))
530 740
531 741 # target_ref has to point to the ancestor's commit_id in order to
532 742 # show the correct diff
533 743 expected_target_ref = 'branch:default:' + commit_ids['ancestor']
534 744 assert pull_request.target_ref == expected_target_ref
535 745
536 746 # Check generated diff contents
537 747 response = response.follow()
538 748 response.mustcontain(no=['content_of_ancestor'])
539 749 response.mustcontain(no=['content_of_ancestor-child'])
540 750 response.mustcontain('content_of_change')
541 751
542 752 def test_merge_pull_request_enabled(self, pr_util, csrf_token):
543 753 # Clear any previous calls to rcextensions
544 754 rhodecode.EXTENSIONS.calls.clear()
545 755
546 756 pull_request = pr_util.create_pull_request(
547 757 approved=True, mergeable=True)
548 758 pull_request_id = pull_request.pull_request_id
549 759 repo_name = pull_request.target_repo.scm_instance().name,
550 760
551 761 url = route_path('pullrequest_merge',
552 762 repo_name=str(repo_name[0]),
553 763 pull_request_id=pull_request_id)
554 764 response = self.app.post(url, params={'csrf_token': csrf_token}).follow()
555 765
556 766 pull_request = PullRequest.get(pull_request_id)
557 767
558 768 assert response.status_int == 200
559 769 assert pull_request.is_closed()
560 770 assert_pull_request_status(
561 771 pull_request, ChangesetStatus.STATUS_APPROVED)
562 772
563 773 # Check the relevant log entries were added
564 774 user_logs = UserLog.query().order_by(UserLog.user_log_id.desc()).limit(3)
565 775 actions = [log.action for log in user_logs]
566 776 pr_commit_ids = PullRequestModel()._get_commit_ids(pull_request)
567 777 expected_actions = [
568 778 u'repo.pull_request.close',
569 779 u'repo.pull_request.merge',
570 780 u'repo.pull_request.comment.create'
571 781 ]
572 782 assert actions == expected_actions
573 783
574 784 user_logs = UserLog.query().order_by(UserLog.user_log_id.desc()).limit(4)
575 785 actions = [log for log in user_logs]
576 786 assert actions[-1].action == 'user.push'
577 787 assert actions[-1].action_data['commit_ids'] == pr_commit_ids
578 788
579 789 # Check post_push rcextension was really executed
580 790 push_calls = rhodecode.EXTENSIONS.calls['_push_hook']
581 791 assert len(push_calls) == 1
582 792 unused_last_call_args, last_call_kwargs = push_calls[0]
583 793 assert last_call_kwargs['action'] == 'push'
584 794 assert last_call_kwargs['commit_ids'] == pr_commit_ids
585 795
586 796 def test_merge_pull_request_disabled(self, pr_util, csrf_token):
587 797 pull_request = pr_util.create_pull_request(mergeable=False)
588 798 pull_request_id = pull_request.pull_request_id
589 799 pull_request = PullRequest.get(pull_request_id)
590 800
591 801 response = self.app.post(
592 802 route_path('pullrequest_merge',
593 803 repo_name=pull_request.target_repo.scm_instance().name,
594 804 pull_request_id=pull_request.pull_request_id),
595 805 params={'csrf_token': csrf_token}).follow()
596 806
597 807 assert response.status_int == 200
598 808 response.mustcontain(
599 809 'Merge is not currently possible because of below failed checks.')
600 810 response.mustcontain('Server-side pull request merging is disabled.')
601 811
602 812 @pytest.mark.skip_backends('svn')
603 813 def test_merge_pull_request_not_approved(self, pr_util, csrf_token):
604 814 pull_request = pr_util.create_pull_request(mergeable=True)
605 815 pull_request_id = pull_request.pull_request_id
606 816 repo_name = pull_request.target_repo.scm_instance().name
607 817
608 818 response = self.app.post(
609 819 route_path('pullrequest_merge',
610 820 repo_name=repo_name, pull_request_id=pull_request_id),
611 821 params={'csrf_token': csrf_token}).follow()
612 822
613 823 assert response.status_int == 200
614 824
615 825 response.mustcontain(
616 826 'Merge is not currently possible because of below failed checks.')
617 827 response.mustcontain('Pull request reviewer approval is pending.')
618 828
619 829 def test_merge_pull_request_renders_failure_reason(
620 830 self, user_regular, csrf_token, pr_util):
621 831 pull_request = pr_util.create_pull_request(mergeable=True, approved=True)
622 832 pull_request_id = pull_request.pull_request_id
623 833 repo_name = pull_request.target_repo.scm_instance().name
624 834
625 835 merge_resp = MergeResponse(True, False, 'STUB_COMMIT_ID',
626 836 MergeFailureReason.PUSH_FAILED,
627 837 metadata={'target': 'shadow repo',
628 838 'merge_commit': 'xxx'})
629 839 model_patcher = mock.patch.multiple(
630 840 PullRequestModel,
631 841 merge_repo=mock.Mock(return_value=merge_resp),
632 842 merge_status=mock.Mock(return_value=(None, True, 'WRONG_MESSAGE')))
633 843
634 844 with model_patcher:
635 845 response = self.app.post(
636 846 route_path('pullrequest_merge',
637 847 repo_name=repo_name,
638 848 pull_request_id=pull_request_id),
639 849 params={'csrf_token': csrf_token}, status=302)
640 850
641 851 merge_resp = MergeResponse(True, True, '', MergeFailureReason.PUSH_FAILED,
642 852 metadata={'target': 'shadow repo',
643 853 'merge_commit': 'xxx'})
644 854 assert_session_flash(response, merge_resp.merge_status_message)
645 855
646 856 def test_update_source_revision(self, backend, csrf_token):
647 857 commits = [
648 858 {'message': 'ancestor'},
649 859 {'message': 'change'},
650 860 {'message': 'change-2'},
651 861 ]
652 862 commit_ids = backend.create_master_repo(commits)
653 863 target = backend.create_repo(heads=['ancestor'])
654 864 source = backend.create_repo(heads=['change'])
655 865
656 866 # create pr from a in source to A in target
657 867 pull_request = PullRequest()
658 868
659 869 pull_request.source_repo = source
660 870 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
661 871 branch=backend.default_branch_name, commit_id=commit_ids['change'])
662 872
663 873 pull_request.target_repo = target
664 874 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
665 875 branch=backend.default_branch_name, commit_id=commit_ids['ancestor'])
666 876
667 877 pull_request.revisions = [commit_ids['change']]
668 878 pull_request.title = u"Test"
669 879 pull_request.description = u"Description"
670 880 pull_request.author = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
671 881 pull_request.pull_request_state = PullRequest.STATE_CREATED
672 882 Session().add(pull_request)
673 883 Session().commit()
674 884 pull_request_id = pull_request.pull_request_id
675 885
676 886 # source has ancestor - change - change-2
677 887 backend.pull_heads(source, heads=['change-2'])
678 888
679 889 # update PR
680 890 self.app.post(
681 891 route_path('pullrequest_update',
682 892 repo_name=target.repo_name, pull_request_id=pull_request_id),
683 893 params={'update_commits': 'true', 'csrf_token': csrf_token})
684 894
685 895 response = self.app.get(
686 896 route_path('pullrequest_show',
687 897 repo_name=target.repo_name,
688 898 pull_request_id=pull_request.pull_request_id))
689 899
690 900 assert response.status_int == 200
691 901 response.mustcontain('Pull request updated to')
692 902 response.mustcontain('with 1 added, 0 removed commits.')
693 903
694 904 # check that we have now both revisions
695 905 pull_request = PullRequest.get(pull_request_id)
696 906 assert pull_request.revisions == [commit_ids['change-2'], commit_ids['change']]
697 907
698 908 def test_update_target_revision(self, backend, csrf_token):
699 909 commits = [
700 910 {'message': 'ancestor'},
701 911 {'message': 'change'},
702 912 {'message': 'ancestor-new', 'parents': ['ancestor']},
703 913 {'message': 'change-rebased'},
704 914 ]
705 915 commit_ids = backend.create_master_repo(commits)
706 916 target = backend.create_repo(heads=['ancestor'])
707 917 source = backend.create_repo(heads=['change'])
708 918
709 919 # create pr from a in source to A in target
710 920 pull_request = PullRequest()
711 921
712 922 pull_request.source_repo = source
713 923 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
714 924 branch=backend.default_branch_name, commit_id=commit_ids['change'])
715 925
716 926 pull_request.target_repo = target
717 927 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
718 928 branch=backend.default_branch_name, commit_id=commit_ids['ancestor'])
719 929
720 930 pull_request.revisions = [commit_ids['change']]
721 931 pull_request.title = u"Test"
722 932 pull_request.description = u"Description"
723 933 pull_request.author = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
724 934 pull_request.pull_request_state = PullRequest.STATE_CREATED
725 935
726 936 Session().add(pull_request)
727 937 Session().commit()
728 938 pull_request_id = pull_request.pull_request_id
729 939
730 940 # target has ancestor - ancestor-new
731 941 # source has ancestor - ancestor-new - change-rebased
732 942 backend.pull_heads(target, heads=['ancestor-new'])
733 943 backend.pull_heads(source, heads=['change-rebased'])
734 944
735 945 # update PR
736 946 url = route_path('pullrequest_update',
737 947 repo_name=target.repo_name,
738 948 pull_request_id=pull_request_id)
739 949 self.app.post(url,
740 950 params={'update_commits': 'true', 'csrf_token': csrf_token},
741 951 status=200)
742 952
743 953 # check that we have now both revisions
744 954 pull_request = PullRequest.get(pull_request_id)
745 955 assert pull_request.revisions == [commit_ids['change-rebased']]
746 956 assert pull_request.target_ref == 'branch:{branch}:{commit_id}'.format(
747 957 branch=backend.default_branch_name, commit_id=commit_ids['ancestor-new'])
748 958
749 959 response = self.app.get(
750 960 route_path('pullrequest_show',
751 961 repo_name=target.repo_name,
752 962 pull_request_id=pull_request.pull_request_id))
753 963 assert response.status_int == 200
754 964 response.mustcontain('Pull request updated to')
755 965 response.mustcontain('with 1 added, 1 removed commits.')
756 966
757 967 def test_update_target_revision_with_removal_of_1_commit_git(self, backend_git, csrf_token):
758 968 backend = backend_git
759 969 commits = [
760 970 {'message': 'master-commit-1'},
761 971 {'message': 'master-commit-2-change-1'},
762 972 {'message': 'master-commit-3-change-2'},
763 973
764 974 {'message': 'feat-commit-1', 'parents': ['master-commit-1']},
765 975 {'message': 'feat-commit-2'},
766 976 ]
767 977 commit_ids = backend.create_master_repo(commits)
768 978 target = backend.create_repo(heads=['master-commit-3-change-2'])
769 979 source = backend.create_repo(heads=['feat-commit-2'])
770 980
771 981 # create pr from a in source to A in target
772 982 pull_request = PullRequest()
773 983 pull_request.source_repo = source
774 984
775 985 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
776 986 branch=backend.default_branch_name,
777 987 commit_id=commit_ids['master-commit-3-change-2'])
778 988
779 989 pull_request.target_repo = target
780 990 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
781 991 branch=backend.default_branch_name, commit_id=commit_ids['feat-commit-2'])
782 992
783 993 pull_request.revisions = [
784 994 commit_ids['feat-commit-1'],
785 995 commit_ids['feat-commit-2']
786 996 ]
787 997 pull_request.title = u"Test"
788 998 pull_request.description = u"Description"
789 999 pull_request.author = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
790 1000 pull_request.pull_request_state = PullRequest.STATE_CREATED
791 1001 Session().add(pull_request)
792 1002 Session().commit()
793 1003 pull_request_id = pull_request.pull_request_id
794 1004
795 1005 # PR is created, now we simulate a force-push into target,
796 1006 # that drops a 2 last commits
797 1007 vcsrepo = target.scm_instance()
798 1008 vcsrepo.config.clear_section('hooks')
799 1009 vcsrepo.run_git_command(['reset', '--soft', 'HEAD~2'])
800 1010
801 1011 # update PR
802 1012 url = route_path('pullrequest_update',
803 1013 repo_name=target.repo_name,
804 1014 pull_request_id=pull_request_id)
805 1015 self.app.post(url,
806 1016 params={'update_commits': 'true', 'csrf_token': csrf_token},
807 1017 status=200)
808 1018
809 1019 response = self.app.get(route_path('pullrequest_new', repo_name=target.repo_name))
810 1020 assert response.status_int == 200
811 1021 response.mustcontain('Pull request updated to')
812 1022 response.mustcontain('with 0 added, 0 removed commits.')
813 1023
814 1024 def test_update_of_ancestor_reference(self, backend, csrf_token):
815 1025 commits = [
816 1026 {'message': 'ancestor'},
817 1027 {'message': 'change'},
818 1028 {'message': 'change-2'},
819 1029 {'message': 'ancestor-new', 'parents': ['ancestor']},
820 1030 {'message': 'change-rebased'},
821 1031 ]
822 1032 commit_ids = backend.create_master_repo(commits)
823 1033 target = backend.create_repo(heads=['ancestor'])
824 1034 source = backend.create_repo(heads=['change'])
825 1035
826 1036 # create pr from a in source to A in target
827 1037 pull_request = PullRequest()
828 1038 pull_request.source_repo = source
829 1039
830 1040 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
831 1041 branch=backend.default_branch_name, commit_id=commit_ids['change'])
832 1042 pull_request.target_repo = target
833 1043 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
834 1044 branch=backend.default_branch_name, commit_id=commit_ids['ancestor'])
835 1045 pull_request.revisions = [commit_ids['change']]
836 1046 pull_request.title = u"Test"
837 1047 pull_request.description = u"Description"
838 1048 pull_request.author = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
839 1049 pull_request.pull_request_state = PullRequest.STATE_CREATED
840 1050 Session().add(pull_request)
841 1051 Session().commit()
842 1052 pull_request_id = pull_request.pull_request_id
843 1053
844 1054 # target has ancestor - ancestor-new
845 1055 # source has ancestor - ancestor-new - change-rebased
846 1056 backend.pull_heads(target, heads=['ancestor-new'])
847 1057 backend.pull_heads(source, heads=['change-rebased'])
848 1058
849 1059 # update PR
850 1060 self.app.post(
851 1061 route_path('pullrequest_update',
852 1062 repo_name=target.repo_name, pull_request_id=pull_request_id),
853 1063 params={'update_commits': 'true', 'csrf_token': csrf_token},
854 1064 status=200)
855 1065
856 1066 # Expect the target reference to be updated correctly
857 1067 pull_request = PullRequest.get(pull_request_id)
858 1068 assert pull_request.revisions == [commit_ids['change-rebased']]
859 1069 expected_target_ref = 'branch:{branch}:{commit_id}'.format(
860 1070 branch=backend.default_branch_name,
861 1071 commit_id=commit_ids['ancestor-new'])
862 1072 assert pull_request.target_ref == expected_target_ref
863 1073
864 1074 def test_remove_pull_request_branch(self, backend_git, csrf_token):
865 1075 branch_name = 'development'
866 1076 commits = [
867 1077 {'message': 'initial-commit'},
868 1078 {'message': 'old-feature'},
869 1079 {'message': 'new-feature', 'branch': branch_name},
870 1080 ]
871 1081 repo = backend_git.create_repo(commits)
872 1082 repo_name = repo.repo_name
873 1083 commit_ids = backend_git.commit_ids
874 1084
875 1085 pull_request = PullRequest()
876 1086 pull_request.source_repo = repo
877 1087 pull_request.target_repo = repo
878 1088 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
879 1089 branch=branch_name, commit_id=commit_ids['new-feature'])
880 1090 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
881 1091 branch=backend_git.default_branch_name, commit_id=commit_ids['old-feature'])
882 1092 pull_request.revisions = [commit_ids['new-feature']]
883 1093 pull_request.title = u"Test"
884 1094 pull_request.description = u"Description"
885 1095 pull_request.author = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
886 1096 pull_request.pull_request_state = PullRequest.STATE_CREATED
887 1097 Session().add(pull_request)
888 1098 Session().commit()
889 1099
890 1100 pull_request_id = pull_request.pull_request_id
891 1101
892 1102 vcs = repo.scm_instance()
893 1103 vcs.remove_ref('refs/heads/{}'.format(branch_name))
894 1104 # NOTE(marcink): run GC to ensure the commits are gone
895 1105 vcs.run_gc()
896 1106
897 1107 response = self.app.get(route_path(
898 1108 'pullrequest_show',
899 1109 repo_name=repo_name,
900 1110 pull_request_id=pull_request_id))
901 1111
902 1112 assert response.status_int == 200
903 1113
904 1114 response.assert_response().element_contains(
905 1115 '#changeset_compare_view_content .alert strong',
906 1116 'Missing commits')
907 1117 response.assert_response().element_contains(
908 1118 '#changeset_compare_view_content .alert',
909 1119 'This pull request cannot be displayed, because one or more'
910 1120 ' commits no longer exist in the source repository.')
911 1121
912 1122 def test_strip_commits_from_pull_request(
913 1123 self, backend, pr_util, csrf_token):
914 1124 commits = [
915 1125 {'message': 'initial-commit'},
916 1126 {'message': 'old-feature'},
917 1127 {'message': 'new-feature', 'parents': ['initial-commit']},
918 1128 ]
919 1129 pull_request = pr_util.create_pull_request(
920 1130 commits, target_head='initial-commit', source_head='new-feature',
921 1131 revisions=['new-feature'])
922 1132
923 1133 vcs = pr_util.source_repository.scm_instance()
924 1134 if backend.alias == 'git':
925 1135 vcs.strip(pr_util.commit_ids['new-feature'], branch_name='master')
926 1136 else:
927 1137 vcs.strip(pr_util.commit_ids['new-feature'])
928 1138
929 1139 response = self.app.get(route_path(
930 1140 'pullrequest_show',
931 1141 repo_name=pr_util.target_repository.repo_name,
932 1142 pull_request_id=pull_request.pull_request_id))
933 1143
934 1144 assert response.status_int == 200
935 1145
936 1146 response.assert_response().element_contains(
937 1147 '#changeset_compare_view_content .alert strong',
938 1148 'Missing commits')
939 1149 response.assert_response().element_contains(
940 1150 '#changeset_compare_view_content .alert',
941 1151 'This pull request cannot be displayed, because one or more'
942 1152 ' commits no longer exist in the source repository.')
943 1153 response.assert_response().element_contains(
944 1154 '#update_commits',
945 1155 'Update commits')
946 1156
947 1157 def test_strip_commits_and_update(
948 1158 self, backend, pr_util, csrf_token):
949 1159 commits = [
950 1160 {'message': 'initial-commit'},
951 1161 {'message': 'old-feature'},
952 1162 {'message': 'new-feature', 'parents': ['old-feature']},
953 1163 ]
954 1164 pull_request = pr_util.create_pull_request(
955 1165 commits, target_head='old-feature', source_head='new-feature',
956 1166 revisions=['new-feature'], mergeable=True)
957 1167
958 1168 vcs = pr_util.source_repository.scm_instance()
959 1169 if backend.alias == 'git':
960 1170 vcs.strip(pr_util.commit_ids['new-feature'], branch_name='master')
961 1171 else:
962 1172 vcs.strip(pr_util.commit_ids['new-feature'])
963 1173
964 1174 url = route_path('pullrequest_update',
965 1175 repo_name=pull_request.target_repo.repo_name,
966 1176 pull_request_id=pull_request.pull_request_id)
967 1177 response = self.app.post(url,
968 1178 params={'update_commits': 'true',
969 1179 'csrf_token': csrf_token})
970 1180
971 1181 assert response.status_int == 200
972 1182 assert response.body == '{"response": true, "redirect_url": null}'
973 1183
974 1184 # Make sure that after update, it won't raise 500 errors
975 1185 response = self.app.get(route_path(
976 1186 'pullrequest_show',
977 1187 repo_name=pr_util.target_repository.repo_name,
978 1188 pull_request_id=pull_request.pull_request_id))
979 1189
980 1190 assert response.status_int == 200
981 1191 response.assert_response().element_contains(
982 1192 '#changeset_compare_view_content .alert strong',
983 1193 'Missing commits')
984 1194
985 1195 def test_branch_is_a_link(self, pr_util):
986 1196 pull_request = pr_util.create_pull_request()
987 1197 pull_request.source_ref = 'branch:origin:1234567890abcdef'
988 1198 pull_request.target_ref = 'branch:target:abcdef1234567890'
989 1199 Session().add(pull_request)
990 1200 Session().commit()
991 1201
992 1202 response = self.app.get(route_path(
993 1203 'pullrequest_show',
994 1204 repo_name=pull_request.target_repo.scm_instance().name,
995 1205 pull_request_id=pull_request.pull_request_id))
996 1206 assert response.status_int == 200
997 1207
998 1208 source = response.assert_response().get_element('.pr-source-info')
999 1209 source_parent = source.getparent()
1000 1210 assert len(source_parent) == 1
1001 1211
1002 1212 target = response.assert_response().get_element('.pr-target-info')
1003 1213 target_parent = target.getparent()
1004 1214 assert len(target_parent) == 1
1005 1215
1006 1216 expected_origin_link = route_path(
1007 1217 'repo_commits',
1008 1218 repo_name=pull_request.source_repo.scm_instance().name,
1009 1219 params=dict(branch='origin'))
1010 1220 expected_target_link = route_path(
1011 1221 'repo_commits',
1012 1222 repo_name=pull_request.target_repo.scm_instance().name,
1013 1223 params=dict(branch='target'))
1014 1224 assert source_parent.attrib['href'] == expected_origin_link
1015 1225 assert target_parent.attrib['href'] == expected_target_link
1016 1226
1017 1227 def test_bookmark_is_not_a_link(self, pr_util):
1018 1228 pull_request = pr_util.create_pull_request()
1019 1229 pull_request.source_ref = 'bookmark:origin:1234567890abcdef'
1020 1230 pull_request.target_ref = 'bookmark:target:abcdef1234567890'
1021 1231 Session().add(pull_request)
1022 1232 Session().commit()
1023 1233
1024 1234 response = self.app.get(route_path(
1025 1235 'pullrequest_show',
1026 1236 repo_name=pull_request.target_repo.scm_instance().name,
1027 1237 pull_request_id=pull_request.pull_request_id))
1028 1238 assert response.status_int == 200
1029 1239
1030 1240 source = response.assert_response().get_element('.pr-source-info')
1031 1241 assert source.text.strip() == 'bookmark:origin'
1032 1242 assert source.getparent().attrib.get('href') is None
1033 1243
1034 1244 target = response.assert_response().get_element('.pr-target-info')
1035 1245 assert target.text.strip() == 'bookmark:target'
1036 1246 assert target.getparent().attrib.get('href') is None
1037 1247
1038 1248 def test_tag_is_not_a_link(self, pr_util):
1039 1249 pull_request = pr_util.create_pull_request()
1040 1250 pull_request.source_ref = 'tag:origin:1234567890abcdef'
1041 1251 pull_request.target_ref = 'tag:target:abcdef1234567890'
1042 1252 Session().add(pull_request)
1043 1253 Session().commit()
1044 1254
1045 1255 response = self.app.get(route_path(
1046 1256 'pullrequest_show',
1047 1257 repo_name=pull_request.target_repo.scm_instance().name,
1048 1258 pull_request_id=pull_request.pull_request_id))
1049 1259 assert response.status_int == 200
1050 1260
1051 1261 source = response.assert_response().get_element('.pr-source-info')
1052 1262 assert source.text.strip() == 'tag:origin'
1053 1263 assert source.getparent().attrib.get('href') is None
1054 1264
1055 1265 target = response.assert_response().get_element('.pr-target-info')
1056 1266 assert target.text.strip() == 'tag:target'
1057 1267 assert target.getparent().attrib.get('href') is None
1058 1268
1059 1269 @pytest.mark.parametrize('mergeable', [True, False])
1060 1270 def test_shadow_repository_link(
1061 1271 self, mergeable, pr_util, http_host_only_stub):
1062 1272 """
1063 1273 Check that the pull request summary page displays a link to the shadow
1064 1274 repository if the pull request is mergeable. If it is not mergeable
1065 1275 the link should not be displayed.
1066 1276 """
1067 1277 pull_request = pr_util.create_pull_request(
1068 1278 mergeable=mergeable, enable_notifications=False)
1069 1279 target_repo = pull_request.target_repo.scm_instance()
1070 1280 pr_id = pull_request.pull_request_id
1071 1281 shadow_url = '{host}/{repo}/pull-request/{pr_id}/repository'.format(
1072 1282 host=http_host_only_stub, repo=target_repo.name, pr_id=pr_id)
1073 1283
1074 1284 response = self.app.get(route_path(
1075 1285 'pullrequest_show',
1076 1286 repo_name=target_repo.name,
1077 1287 pull_request_id=pr_id))
1078 1288
1079 1289 if mergeable:
1080 1290 response.assert_response().element_value_contains(
1081 1291 'input.pr-mergeinfo', shadow_url)
1082 1292 response.assert_response().element_value_contains(
1083 1293 'input.pr-mergeinfo ', 'pr-merge')
1084 1294 else:
1085 1295 response.assert_response().no_element_exists('.pr-mergeinfo')
1086 1296
1087 1297
1088 1298 @pytest.mark.usefixtures('app')
1089 1299 @pytest.mark.backends("git", "hg")
1090 1300 class TestPullrequestsControllerDelete(object):
1091 1301 def test_pull_request_delete_button_permissions_admin(
1092 1302 self, autologin_user, user_admin, pr_util):
1093 1303 pull_request = pr_util.create_pull_request(
1094 1304 author=user_admin.username, enable_notifications=False)
1095 1305
1096 1306 response = self.app.get(route_path(
1097 1307 'pullrequest_show',
1098 1308 repo_name=pull_request.target_repo.scm_instance().name,
1099 1309 pull_request_id=pull_request.pull_request_id))
1100 1310
1101 1311 response.mustcontain('id="delete_pullrequest"')
1102 1312 response.mustcontain('Confirm to delete this pull request')
1103 1313
1104 1314 def test_pull_request_delete_button_permissions_owner(
1105 1315 self, autologin_regular_user, user_regular, pr_util):
1106 1316 pull_request = pr_util.create_pull_request(
1107 1317 author=user_regular.username, enable_notifications=False)
1108 1318
1109 1319 response = self.app.get(route_path(
1110 1320 'pullrequest_show',
1111 1321 repo_name=pull_request.target_repo.scm_instance().name,
1112 1322 pull_request_id=pull_request.pull_request_id))
1113 1323
1114 1324 response.mustcontain('id="delete_pullrequest"')
1115 1325 response.mustcontain('Confirm to delete this pull request')
1116 1326
1117 1327 def test_pull_request_delete_button_permissions_forbidden(
1118 1328 self, autologin_regular_user, user_regular, user_admin, pr_util):
1119 1329 pull_request = pr_util.create_pull_request(
1120 1330 author=user_admin.username, enable_notifications=False)
1121 1331
1122 1332 response = self.app.get(route_path(
1123 1333 'pullrequest_show',
1124 1334 repo_name=pull_request.target_repo.scm_instance().name,
1125 1335 pull_request_id=pull_request.pull_request_id))
1126 1336 response.mustcontain(no=['id="delete_pullrequest"'])
1127 1337 response.mustcontain(no=['Confirm to delete this pull request'])
1128 1338
1129 1339 def test_pull_request_delete_button_permissions_can_update_cannot_delete(
1130 1340 self, autologin_regular_user, user_regular, user_admin, pr_util,
1131 1341 user_util):
1132 1342
1133 1343 pull_request = pr_util.create_pull_request(
1134 1344 author=user_admin.username, enable_notifications=False)
1135 1345
1136 1346 user_util.grant_user_permission_to_repo(
1137 1347 pull_request.target_repo, user_regular,
1138 1348 'repository.write')
1139 1349
1140 1350 response = self.app.get(route_path(
1141 1351 'pullrequest_show',
1142 1352 repo_name=pull_request.target_repo.scm_instance().name,
1143 1353 pull_request_id=pull_request.pull_request_id))
1144 1354
1145 1355 response.mustcontain('id="open_edit_pullrequest"')
1146 1356 response.mustcontain('id="delete_pullrequest"')
1147 1357 response.mustcontain(no=['Confirm to delete this pull request'])
1148 1358
1149 1359 def test_delete_comment_returns_404_if_comment_does_not_exist(
1150 1360 self, autologin_user, pr_util, user_admin, csrf_token, xhr_header):
1151 1361
1152 1362 pull_request = pr_util.create_pull_request(
1153 1363 author=user_admin.username, enable_notifications=False)
1154 1364
1155 1365 self.app.post(
1156 1366 route_path(
1157 1367 'pullrequest_comment_delete',
1158 1368 repo_name=pull_request.target_repo.scm_instance().name,
1159 1369 pull_request_id=pull_request.pull_request_id,
1160 1370 comment_id=1024404),
1161 1371 extra_environ=xhr_header,
1162 1372 params={'csrf_token': csrf_token},
1163 1373 status=404
1164 1374 )
1165 1375
1166 1376 def test_delete_comment(
1167 1377 self, autologin_user, pr_util, user_admin, csrf_token, xhr_header):
1168 1378
1169 1379 pull_request = pr_util.create_pull_request(
1170 1380 author=user_admin.username, enable_notifications=False)
1171 1381 comment = pr_util.create_comment()
1172 1382 comment_id = comment.comment_id
1173 1383
1174 1384 response = self.app.post(
1175 1385 route_path(
1176 1386 'pullrequest_comment_delete',
1177 1387 repo_name=pull_request.target_repo.scm_instance().name,
1178 1388 pull_request_id=pull_request.pull_request_id,
1179 1389 comment_id=comment_id),
1180 1390 extra_environ=xhr_header,
1181 1391 params={'csrf_token': csrf_token},
1182 1392 status=200
1183 1393 )
1184 1394 assert response.body == 'true'
1185 1395
1186 1396 @pytest.mark.parametrize('url_type', [
1187 1397 'pullrequest_new',
1188 1398 'pullrequest_create',
1189 1399 'pullrequest_update',
1190 1400 'pullrequest_merge',
1191 1401 ])
1192 1402 def test_pull_request_is_forbidden_on_archived_repo(
1193 1403 self, autologin_user, backend, xhr_header, user_util, url_type):
1194 1404
1195 1405 # create a temporary repo
1196 1406 source = user_util.create_repo(repo_type=backend.alias)
1197 1407 repo_name = source.repo_name
1198 1408 repo = Repository.get_by_repo_name(repo_name)
1199 1409 repo.archived = True
1200 1410 Session().commit()
1201 1411
1202 1412 response = self.app.get(
1203 1413 route_path(url_type, repo_name=repo_name, pull_request_id=1), status=302)
1204 1414
1205 1415 msg = 'Action not supported for archived repository.'
1206 1416 assert_session_flash(response, msg)
1207 1417
1208 1418
1209 1419 def assert_pull_request_status(pull_request, expected_status):
1210 1420 status = ChangesetStatusModel().calculated_review_status(pull_request=pull_request)
1211 1421 assert status == expected_status
1212 1422
1213 1423
1214 1424 @pytest.mark.parametrize('route', ['pullrequest_new', 'pullrequest_create'])
1215 1425 @pytest.mark.usefixtures("autologin_user")
1216 1426 def test_forbidde_to_repo_summary_for_svn_repositories(backend_svn, app, route):
1217 1427 app.get(route_path(route, repo_name=backend_svn.repo_name), status=404)
@@ -1,610 +1,700 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21
22 22 import logging
23 23 import collections
24 24
25 25 from pyramid.httpexceptions import HTTPNotFound, HTTPBadRequest, HTTPFound, HTTPForbidden
26 26 from pyramid.view import view_config
27 27 from pyramid.renderers import render
28 28 from pyramid.response import Response
29 29
30 30 from rhodecode.apps._base import RepoAppView
31 31 from rhodecode.apps.file_store import utils as store_utils
32 32 from rhodecode.apps.file_store.exceptions import FileNotAllowedException, FileOverSizeException
33 33
34 34 from rhodecode.lib import diffs, codeblocks
35 35 from rhodecode.lib.auth import (
36 36 LoginRequired, HasRepoPermissionAnyDecorator, NotAnonymous, CSRFRequired)
37 37
38 38 from rhodecode.lib.compat import OrderedDict
39 39 from rhodecode.lib.diffs import (
40 40 cache_diff, load_cached_diff, diff_cache_exist, get_diff_context,
41 41 get_diff_whitespace_flag)
42 42 from rhodecode.lib.exceptions import StatusChangeOnClosedPullRequestError
43 43 import rhodecode.lib.helpers as h
44 44 from rhodecode.lib.utils2 import safe_unicode, str2bool
45 45 from rhodecode.lib.vcs.backends.base import EmptyCommit
46 46 from rhodecode.lib.vcs.exceptions import (
47 47 RepositoryError, CommitDoesNotExistError)
48 from rhodecode.model.db import ChangesetComment, ChangesetStatus, FileStore
48 from rhodecode.model.db import ChangesetComment, ChangesetStatus, FileStore, \
49 ChangesetCommentHistory
49 50 from rhodecode.model.changeset_status import ChangesetStatusModel
50 51 from rhodecode.model.comment import CommentsModel
51 52 from rhodecode.model.meta import Session
52 53 from rhodecode.model.settings import VcsSettingsModel
53 54
54 55 log = logging.getLogger(__name__)
55 56
56 57
57 58 def _update_with_GET(params, request):
58 59 for k in ['diff1', 'diff2', 'diff']:
59 60 params[k] += request.GET.getall(k)
60 61
61 62
62 63 class RepoCommitsView(RepoAppView):
63 64 def load_default_context(self):
64 65 c = self._get_local_tmpl_context(include_app_defaults=True)
65 66 c.rhodecode_repo = self.rhodecode_vcs_repo
66 67
67 68 return c
68 69
69 70 def _is_diff_cache_enabled(self, target_repo):
70 71 caching_enabled = self._get_general_setting(
71 72 target_repo, 'rhodecode_diff_cache')
72 73 log.debug('Diff caching enabled: %s', caching_enabled)
73 74 return caching_enabled
74 75
75 76 def _commit(self, commit_id_range, method):
76 77 _ = self.request.translate
77 78 c = self.load_default_context()
78 79 c.fulldiff = self.request.GET.get('fulldiff')
79 80
80 81 # fetch global flags of ignore ws or context lines
81 82 diff_context = get_diff_context(self.request)
82 83 hide_whitespace_changes = get_diff_whitespace_flag(self.request)
83 84
84 85 # diff_limit will cut off the whole diff if the limit is applied
85 86 # otherwise it will just hide the big files from the front-end
86 87 diff_limit = c.visual.cut_off_limit_diff
87 88 file_limit = c.visual.cut_off_limit_file
88 89
89 90 # get ranges of commit ids if preset
90 91 commit_range = commit_id_range.split('...')[:2]
91 92
92 93 try:
93 94 pre_load = ['affected_files', 'author', 'branch', 'date',
94 95 'message', 'parents']
95 96 if self.rhodecode_vcs_repo.alias == 'hg':
96 97 pre_load += ['hidden', 'obsolete', 'phase']
97 98
98 99 if len(commit_range) == 2:
99 100 commits = self.rhodecode_vcs_repo.get_commits(
100 101 start_id=commit_range[0], end_id=commit_range[1],
101 102 pre_load=pre_load, translate_tags=False)
102 103 commits = list(commits)
103 104 else:
104 105 commits = [self.rhodecode_vcs_repo.get_commit(
105 106 commit_id=commit_id_range, pre_load=pre_load)]
106 107
107 108 c.commit_ranges = commits
108 109 if not c.commit_ranges:
109 110 raise RepositoryError('The commit range returned an empty result')
110 111 except CommitDoesNotExistError as e:
111 112 msg = _('No such commit exists. Org exception: `{}`').format(e)
112 113 h.flash(msg, category='error')
113 114 raise HTTPNotFound()
114 115 except Exception:
115 116 log.exception("General failure")
116 117 raise HTTPNotFound()
117 118
118 119 c.changes = OrderedDict()
119 120 c.lines_added = 0
120 121 c.lines_deleted = 0
121 122
122 123 # auto collapse if we have more than limit
123 124 collapse_limit = diffs.DiffProcessor._collapse_commits_over
124 125 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
125 126
126 127 c.commit_statuses = ChangesetStatus.STATUSES
127 128 c.inline_comments = []
128 129 c.files = []
129 130
130 131 c.statuses = []
131 132 c.comments = []
132 133 c.unresolved_comments = []
133 134 c.resolved_comments = []
134 135 if len(c.commit_ranges) == 1:
135 136 commit = c.commit_ranges[0]
136 137 c.comments = CommentsModel().get_comments(
137 138 self.db_repo.repo_id,
138 139 revision=commit.raw_id)
139 140 c.statuses.append(ChangesetStatusModel().get_status(
140 141 self.db_repo.repo_id, commit.raw_id))
141 142 # comments from PR
142 143 statuses = ChangesetStatusModel().get_statuses(
143 144 self.db_repo.repo_id, commit.raw_id,
144 145 with_revisions=True)
145 146 prs = set(st.pull_request for st in statuses
146 147 if st.pull_request is not None)
147 148 # from associated statuses, check the pull requests, and
148 149 # show comments from them
149 150 for pr in prs:
150 151 c.comments.extend(pr.comments)
151 152
152 153 c.unresolved_comments = CommentsModel()\
153 154 .get_commit_unresolved_todos(commit.raw_id)
154 155 c.resolved_comments = CommentsModel()\
155 156 .get_commit_resolved_todos(commit.raw_id)
156 157
157 158 diff = None
158 159 # Iterate over ranges (default commit view is always one commit)
159 160 for commit in c.commit_ranges:
160 161 c.changes[commit.raw_id] = []
161 162
162 163 commit2 = commit
163 164 commit1 = commit.first_parent
164 165
165 166 if method == 'show':
166 167 inline_comments = CommentsModel().get_inline_comments(
167 168 self.db_repo.repo_id, revision=commit.raw_id)
168 169 c.inline_cnt = CommentsModel().get_inline_comments_count(
169 170 inline_comments)
170 171 c.inline_comments = inline_comments
171 172
172 173 cache_path = self.rhodecode_vcs_repo.get_create_shadow_cache_pr_path(
173 174 self.db_repo)
174 175 cache_file_path = diff_cache_exist(
175 176 cache_path, 'diff', commit.raw_id,
176 177 hide_whitespace_changes, diff_context, c.fulldiff)
177 178
178 179 caching_enabled = self._is_diff_cache_enabled(self.db_repo)
179 180 force_recache = str2bool(self.request.GET.get('force_recache'))
180 181
181 182 cached_diff = None
182 183 if caching_enabled:
183 184 cached_diff = load_cached_diff(cache_file_path)
184 185
185 186 has_proper_diff_cache = cached_diff and cached_diff.get('diff')
186 187 if not force_recache and has_proper_diff_cache:
187 188 diffset = cached_diff['diff']
188 189 else:
189 190 vcs_diff = self.rhodecode_vcs_repo.get_diff(
190 191 commit1, commit2,
191 192 ignore_whitespace=hide_whitespace_changes,
192 193 context=diff_context)
193 194
194 195 diff_processor = diffs.DiffProcessor(
195 196 vcs_diff, format='newdiff', diff_limit=diff_limit,
196 197 file_limit=file_limit, show_full_diff=c.fulldiff)
197 198
198 199 _parsed = diff_processor.prepare()
199 200
200 201 diffset = codeblocks.DiffSet(
201 202 repo_name=self.db_repo_name,
202 203 source_node_getter=codeblocks.diffset_node_getter(commit1),
203 204 target_node_getter=codeblocks.diffset_node_getter(commit2))
204 205
205 206 diffset = self.path_filter.render_patchset_filtered(
206 207 diffset, _parsed, commit1.raw_id, commit2.raw_id)
207 208
208 209 # save cached diff
209 210 if caching_enabled:
210 211 cache_diff(cache_file_path, diffset, None)
211 212
212 213 c.limited_diff = diffset.limited_diff
213 214 c.changes[commit.raw_id] = diffset
214 215 else:
215 216 # TODO(marcink): no cache usage here...
216 217 _diff = self.rhodecode_vcs_repo.get_diff(
217 218 commit1, commit2,
218 219 ignore_whitespace=hide_whitespace_changes, context=diff_context)
219 220 diff_processor = diffs.DiffProcessor(
220 221 _diff, format='newdiff', diff_limit=diff_limit,
221 222 file_limit=file_limit, show_full_diff=c.fulldiff)
222 223 # downloads/raw we only need RAW diff nothing else
223 224 diff = self.path_filter.get_raw_patch(diff_processor)
224 225 c.changes[commit.raw_id] = [None, None, None, None, diff, None, None]
225 226
226 227 # sort comments by how they were generated
227 228 c.comments = sorted(c.comments, key=lambda x: x.comment_id)
228 229
229 230 if len(c.commit_ranges) == 1:
230 231 c.commit = c.commit_ranges[0]
231 232 c.parent_tmpl = ''.join(
232 233 '# Parent %s\n' % x.raw_id for x in c.commit.parents)
233 234
234 235 if method == 'download':
235 236 response = Response(diff)
236 237 response.content_type = 'text/plain'
237 238 response.content_disposition = (
238 239 'attachment; filename=%s.diff' % commit_id_range[:12])
239 240 return response
240 241 elif method == 'patch':
241 242 c.diff = safe_unicode(diff)
242 243 patch = render(
243 244 'rhodecode:templates/changeset/patch_changeset.mako',
244 245 self._get_template_context(c), self.request)
245 246 response = Response(patch)
246 247 response.content_type = 'text/plain'
247 248 return response
248 249 elif method == 'raw':
249 250 response = Response(diff)
250 251 response.content_type = 'text/plain'
251 252 return response
252 253 elif method == 'show':
253 254 if len(c.commit_ranges) == 1:
254 255 html = render(
255 256 'rhodecode:templates/changeset/changeset.mako',
256 257 self._get_template_context(c), self.request)
257 258 return Response(html)
258 259 else:
259 260 c.ancestor = None
260 261 c.target_repo = self.db_repo
261 262 html = render(
262 263 'rhodecode:templates/changeset/changeset_range.mako',
263 264 self._get_template_context(c), self.request)
264 265 return Response(html)
265 266
266 267 raise HTTPBadRequest()
267 268
268 269 @LoginRequired()
269 270 @HasRepoPermissionAnyDecorator(
270 271 'repository.read', 'repository.write', 'repository.admin')
271 272 @view_config(
272 273 route_name='repo_commit', request_method='GET',
273 274 renderer=None)
274 275 def repo_commit_show(self):
275 276 commit_id = self.request.matchdict['commit_id']
276 277 return self._commit(commit_id, method='show')
277 278
278 279 @LoginRequired()
279 280 @HasRepoPermissionAnyDecorator(
280 281 'repository.read', 'repository.write', 'repository.admin')
281 282 @view_config(
282 283 route_name='repo_commit_raw', request_method='GET',
283 284 renderer=None)
284 285 @view_config(
285 286 route_name='repo_commit_raw_deprecated', request_method='GET',
286 287 renderer=None)
287 288 def repo_commit_raw(self):
288 289 commit_id = self.request.matchdict['commit_id']
289 290 return self._commit(commit_id, method='raw')
290 291
291 292 @LoginRequired()
292 293 @HasRepoPermissionAnyDecorator(
293 294 'repository.read', 'repository.write', 'repository.admin')
294 295 @view_config(
295 296 route_name='repo_commit_patch', request_method='GET',
296 297 renderer=None)
297 298 def repo_commit_patch(self):
298 299 commit_id = self.request.matchdict['commit_id']
299 300 return self._commit(commit_id, method='patch')
300 301
301 302 @LoginRequired()
302 303 @HasRepoPermissionAnyDecorator(
303 304 'repository.read', 'repository.write', 'repository.admin')
304 305 @view_config(
305 306 route_name='repo_commit_download', request_method='GET',
306 307 renderer=None)
307 308 def repo_commit_download(self):
308 309 commit_id = self.request.matchdict['commit_id']
309 310 return self._commit(commit_id, method='download')
310 311
311 312 @LoginRequired()
312 313 @NotAnonymous()
313 314 @HasRepoPermissionAnyDecorator(
314 315 'repository.read', 'repository.write', 'repository.admin')
315 316 @CSRFRequired()
316 317 @view_config(
317 318 route_name='repo_commit_comment_create', request_method='POST',
318 319 renderer='json_ext')
319 320 def repo_commit_comment_create(self):
320 321 _ = self.request.translate
321 322 commit_id = self.request.matchdict['commit_id']
322 323
323 324 c = self.load_default_context()
324 325 status = self.request.POST.get('changeset_status', None)
325 326 text = self.request.POST.get('text')
326 327 comment_type = self.request.POST.get('comment_type')
327 328 resolves_comment_id = self.request.POST.get('resolves_comment_id', None)
328 329
329 330 if status:
330 331 text = text or (_('Status change %(transition_icon)s %(status)s')
331 332 % {'transition_icon': '>',
332 333 'status': ChangesetStatus.get_status_lbl(status)})
333 334
334 335 multi_commit_ids = []
335 336 for _commit_id in self.request.POST.get('commit_ids', '').split(','):
336 337 if _commit_id not in ['', None, EmptyCommit.raw_id]:
337 338 if _commit_id not in multi_commit_ids:
338 339 multi_commit_ids.append(_commit_id)
339 340
340 341 commit_ids = multi_commit_ids or [commit_id]
341 342
342 343 comment = None
343 344 for current_id in filter(None, commit_ids):
344 345 comment = CommentsModel().create(
345 346 text=text,
346 347 repo=self.db_repo.repo_id,
347 348 user=self._rhodecode_db_user.user_id,
348 349 commit_id=current_id,
349 350 f_path=self.request.POST.get('f_path'),
350 351 line_no=self.request.POST.get('line'),
351 352 status_change=(ChangesetStatus.get_status_lbl(status)
352 353 if status else None),
353 354 status_change_type=status,
354 355 comment_type=comment_type,
355 356 resolves_comment_id=resolves_comment_id,
356 357 auth_user=self._rhodecode_user
357 358 )
358 359
359 360 # get status if set !
360 361 if status:
361 362 # if latest status was from pull request and it's closed
362 363 # disallow changing status !
363 364 # dont_allow_on_closed_pull_request = True !
364 365
365 366 try:
366 367 ChangesetStatusModel().set_status(
367 368 self.db_repo.repo_id,
368 369 status,
369 370 self._rhodecode_db_user.user_id,
370 371 comment,
371 372 revision=current_id,
372 373 dont_allow_on_closed_pull_request=True
373 374 )
374 375 except StatusChangeOnClosedPullRequestError:
375 376 msg = _('Changing the status of a commit associated with '
376 377 'a closed pull request is not allowed')
377 378 log.exception(msg)
378 379 h.flash(msg, category='warning')
379 380 raise HTTPFound(h.route_path(
380 381 'repo_commit', repo_name=self.db_repo_name,
381 382 commit_id=current_id))
382 383
383 384 commit = self.db_repo.get_commit(current_id)
384 385 CommentsModel().trigger_commit_comment_hook(
385 386 self.db_repo, self._rhodecode_user, 'create',
386 387 data={'comment': comment, 'commit': commit})
387 388
388 389 # finalize, commit and redirect
389 390 Session().commit()
390 391
391 392 data = {
392 393 'target_id': h.safeid(h.safe_unicode(
393 394 self.request.POST.get('f_path'))),
394 395 }
395 396 if comment:
396 397 c.co = comment
397 398 rendered_comment = render(
398 399 'rhodecode:templates/changeset/changeset_comment_block.mako',
399 400 self._get_template_context(c), self.request)
400 401
401 402 data.update(comment.get_dict())
402 403 data.update({'rendered_text': rendered_comment})
403 404
404 405 return data
405 406
406 407 @LoginRequired()
407 408 @NotAnonymous()
408 409 @HasRepoPermissionAnyDecorator(
409 410 'repository.read', 'repository.write', 'repository.admin')
410 411 @CSRFRequired()
411 412 @view_config(
412 413 route_name='repo_commit_comment_preview', request_method='POST',
413 414 renderer='string', xhr=True)
414 415 def repo_commit_comment_preview(self):
415 416 # Technically a CSRF token is not needed as no state changes with this
416 417 # call. However, as this is a POST is better to have it, so automated
417 418 # tools don't flag it as potential CSRF.
418 419 # Post is required because the payload could be bigger than the maximum
419 420 # allowed by GET.
420 421
421 422 text = self.request.POST.get('text')
422 423 renderer = self.request.POST.get('renderer') or 'rst'
423 424 if text:
424 425 return h.render(text, renderer=renderer, mentions=True,
425 426 repo_name=self.db_repo_name)
426 427 return ''
427 428
428 429 @LoginRequired()
429 430 @NotAnonymous()
430 431 @HasRepoPermissionAnyDecorator(
431 432 'repository.read', 'repository.write', 'repository.admin')
432 433 @CSRFRequired()
433 434 @view_config(
435 route_name='repo_commit_comment_history_view', request_method='POST',
436 renderer='string', xhr=True)
437 def repo_commit_comment_history_view(self):
438 commit_id = self.request.matchdict['commit_id']
439 comment_history_id = self.request.matchdict['comment_history_id']
440 comment_history = ChangesetCommentHistory.get_or_404(comment_history_id)
441 c = self.load_default_context()
442 c.comment_history = comment_history
443
444 rendered_comment = render(
445 'rhodecode:templates/changeset/comment_history.mako',
446 self._get_template_context(c)
447 , self.request)
448 return rendered_comment
449
450 @LoginRequired()
451 @NotAnonymous()
452 @HasRepoPermissionAnyDecorator(
453 'repository.read', 'repository.write', 'repository.admin')
454 @CSRFRequired()
455 @view_config(
434 456 route_name='repo_commit_comment_attachment_upload', request_method='POST',
435 457 renderer='json_ext', xhr=True)
436 458 def repo_commit_comment_attachment_upload(self):
437 459 c = self.load_default_context()
438 460 upload_key = 'attachment'
439 461
440 462 file_obj = self.request.POST.get(upload_key)
441 463
442 464 if file_obj is None:
443 465 self.request.response.status = 400
444 466 return {'store_fid': None,
445 467 'access_path': None,
446 468 'error': '{} data field is missing'.format(upload_key)}
447 469
448 470 if not hasattr(file_obj, 'filename'):
449 471 self.request.response.status = 400
450 472 return {'store_fid': None,
451 473 'access_path': None,
452 474 'error': 'filename cannot be read from the data field'}
453 475
454 476 filename = file_obj.filename
455 477 file_display_name = filename
456 478
457 479 metadata = {
458 480 'user_uploaded': {'username': self._rhodecode_user.username,
459 481 'user_id': self._rhodecode_user.user_id,
460 482 'ip': self._rhodecode_user.ip_addr}}
461 483
462 484 # TODO(marcink): allow .ini configuration for allowed_extensions, and file-size
463 485 allowed_extensions = [
464 486 'gif', '.jpeg', '.jpg', '.png', '.docx', '.gz', '.log', '.pdf',
465 487 '.pptx', '.txt', '.xlsx', '.zip']
466 488 max_file_size = 10 * 1024 * 1024 # 10MB, also validated via dropzone.js
467 489
468 490 try:
469 491 storage = store_utils.get_file_storage(self.request.registry.settings)
470 492 store_uid, metadata = storage.save_file(
471 493 file_obj.file, filename, extra_metadata=metadata,
472 494 extensions=allowed_extensions, max_filesize=max_file_size)
473 495 except FileNotAllowedException:
474 496 self.request.response.status = 400
475 497 permitted_extensions = ', '.join(allowed_extensions)
476 498 error_msg = 'File `{}` is not allowed. ' \
477 499 'Only following extensions are permitted: {}'.format(
478 500 filename, permitted_extensions)
479 501 return {'store_fid': None,
480 502 'access_path': None,
481 503 'error': error_msg}
482 504 except FileOverSizeException:
483 505 self.request.response.status = 400
484 506 limit_mb = h.format_byte_size_binary(max_file_size)
485 507 return {'store_fid': None,
486 508 'access_path': None,
487 509 'error': 'File {} is exceeding allowed limit of {}.'.format(
488 510 filename, limit_mb)}
489 511
490 512 try:
491 513 entry = FileStore.create(
492 514 file_uid=store_uid, filename=metadata["filename"],
493 515 file_hash=metadata["sha256"], file_size=metadata["size"],
494 516 file_display_name=file_display_name,
495 517 file_description=u'comment attachment `{}`'.format(safe_unicode(filename)),
496 518 hidden=True, check_acl=True, user_id=self._rhodecode_user.user_id,
497 519 scope_repo_id=self.db_repo.repo_id
498 520 )
499 521 Session().add(entry)
500 522 Session().commit()
501 523 log.debug('Stored upload in DB as %s', entry)
502 524 except Exception:
503 525 log.exception('Failed to store file %s', filename)
504 526 self.request.response.status = 400
505 527 return {'store_fid': None,
506 528 'access_path': None,
507 529 'error': 'File {} failed to store in DB.'.format(filename)}
508 530
509 531 Session().commit()
510 532
511 533 return {
512 534 'store_fid': store_uid,
513 535 'access_path': h.route_path(
514 536 'download_file', fid=store_uid),
515 537 'fqn_access_path': h.route_url(
516 538 'download_file', fid=store_uid),
517 539 'repo_access_path': h.route_path(
518 540 'repo_artifacts_get', repo_name=self.db_repo_name, uid=store_uid),
519 541 'repo_fqn_access_path': h.route_url(
520 542 'repo_artifacts_get', repo_name=self.db_repo_name, uid=store_uid),
521 543 }
522 544
523 545 @LoginRequired()
524 546 @NotAnonymous()
525 547 @HasRepoPermissionAnyDecorator(
526 548 'repository.read', 'repository.write', 'repository.admin')
527 549 @CSRFRequired()
528 550 @view_config(
529 551 route_name='repo_commit_comment_delete', request_method='POST',
530 552 renderer='json_ext')
531 553 def repo_commit_comment_delete(self):
532 554 commit_id = self.request.matchdict['commit_id']
533 555 comment_id = self.request.matchdict['comment_id']
534 556
535 557 comment = ChangesetComment.get_or_404(comment_id)
536 558 if not comment:
537 559 log.debug('Comment with id:%s not found, skipping', comment_id)
538 560 # comment already deleted in another call probably
539 561 return True
540 562
541 563 if comment.immutable:
542 564 # don't allow deleting comments that are immutable
543 565 raise HTTPForbidden()
544 566
545 567 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
546 568 super_admin = h.HasPermissionAny('hg.admin')()
547 569 comment_owner = (comment.author.user_id == self._rhodecode_db_user.user_id)
548 570 is_repo_comment = comment.repo.repo_name == self.db_repo_name
549 571 comment_repo_admin = is_repo_admin and is_repo_comment
550 572
551 573 if super_admin or comment_owner or comment_repo_admin:
552 574 CommentsModel().delete(comment=comment, auth_user=self._rhodecode_user)
553 575 Session().commit()
554 576 return True
555 577 else:
556 578 log.warning('No permissions for user %s to delete comment_id: %s',
557 579 self._rhodecode_db_user, comment_id)
558 580 raise HTTPNotFound()
559 581
560 582 @LoginRequired()
583 @NotAnonymous()
584 @HasRepoPermissionAnyDecorator(
585 'repository.read', 'repository.write', 'repository.admin')
586 @CSRFRequired()
587 @view_config(
588 route_name='repo_commit_comment_edit', request_method='POST',
589 renderer='json_ext')
590 def repo_commit_comment_edit(self):
591 commit_id = self.request.matchdict['commit_id']
592 comment_id = self.request.matchdict['comment_id']
593
594 comment = ChangesetComment.get_or_404(comment_id)
595
596 if comment.immutable:
597 # don't allow deleting comments that are immutable
598 raise HTTPForbidden()
599
600 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
601 super_admin = h.HasPermissionAny('hg.admin')()
602 comment_owner = (comment.author.user_id == self._rhodecode_db_user.user_id)
603 is_repo_comment = comment.repo.repo_name == self.db_repo_name
604 comment_repo_admin = is_repo_admin and is_repo_comment
605
606 if super_admin or comment_owner or comment_repo_admin:
607 text = self.request.POST.get('text')
608 version = self.request.POST.get('version')
609 if text == comment.text:
610 log.warning(
611 'Comment(repo): '
612 'Trying to create new version '
613 'of existing comment {}'.format(
614 comment_id,
615 )
616 )
617 raise HTTPNotFound()
618 if version.isdigit():
619 version = int(version)
620 else:
621 log.warning(
622 'Comment(repo): Wrong version type {} {} '
623 'for comment {}'.format(
624 version,
625 type(version),
626 comment_id,
627 )
628 )
629 raise HTTPNotFound()
630
631 comment_history = CommentsModel().edit(
632 comment_id=comment_id,
633 text=text,
634 auth_user=self._rhodecode_user,
635 version=version,
636 )
637 if not comment_history:
638 raise HTTPNotFound()
639 Session().commit()
640 return {
641 'comment_history_id': comment_history.comment_history_id,
642 'comment_id': comment.comment_id,
643 'comment_version': comment_history.version,
644 }
645 else:
646 log.warning('No permissions for user %s to edit comment_id: %s',
647 self._rhodecode_db_user, comment_id)
648 raise HTTPNotFound()
649
650 @LoginRequired()
561 651 @HasRepoPermissionAnyDecorator(
562 652 'repository.read', 'repository.write', 'repository.admin')
563 653 @view_config(
564 654 route_name='repo_commit_data', request_method='GET',
565 655 renderer='json_ext', xhr=True)
566 656 def repo_commit_data(self):
567 657 commit_id = self.request.matchdict['commit_id']
568 658 self.load_default_context()
569 659
570 660 try:
571 661 return self.rhodecode_vcs_repo.get_commit(commit_id=commit_id)
572 662 except CommitDoesNotExistError as e:
573 663 return EmptyCommit(message=str(e))
574 664
575 665 @LoginRequired()
576 666 @HasRepoPermissionAnyDecorator(
577 667 'repository.read', 'repository.write', 'repository.admin')
578 668 @view_config(
579 669 route_name='repo_commit_children', request_method='GET',
580 670 renderer='json_ext', xhr=True)
581 671 def repo_commit_children(self):
582 672 commit_id = self.request.matchdict['commit_id']
583 673 self.load_default_context()
584 674
585 675 try:
586 676 commit = self.rhodecode_vcs_repo.get_commit(commit_id=commit_id)
587 677 children = commit.children
588 678 except CommitDoesNotExistError:
589 679 children = []
590 680
591 681 result = {"results": children}
592 682 return result
593 683
594 684 @LoginRequired()
595 685 @HasRepoPermissionAnyDecorator(
596 686 'repository.read', 'repository.write', 'repository.admin')
597 687 @view_config(
598 688 route_name='repo_commit_parents', request_method='GET',
599 689 renderer='json_ext')
600 690 def repo_commit_parents(self):
601 691 commit_id = self.request.matchdict['commit_id']
602 692 self.load_default_context()
603 693
604 694 try:
605 695 commit = self.rhodecode_vcs_repo.get_commit(commit_id=commit_id)
606 696 parents = commit.parents
607 697 except CommitDoesNotExistError:
608 698 parents = []
609 699 result = {"results": parents}
610 700 return result
@@ -1,1520 +1,1607 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import logging
22 22 import collections
23 23
24 24 import formencode
25 25 import formencode.htmlfill
26 26 import peppercorn
27 27 from pyramid.httpexceptions import (
28 28 HTTPFound, HTTPNotFound, HTTPForbidden, HTTPBadRequest)
29 29 from pyramid.view import view_config
30 30 from pyramid.renderers import render
31 31
32 32 from rhodecode.apps._base import RepoAppView, DataGridAppView
33 33
34 34 from rhodecode.lib import helpers as h, diffs, codeblocks, channelstream
35 35 from rhodecode.lib.base import vcs_operation_context
36 36 from rhodecode.lib.diffs import load_cached_diff, cache_diff, diff_cache_exist
37 37 from rhodecode.lib.ext_json import json
38 38 from rhodecode.lib.auth import (
39 39 LoginRequired, HasRepoPermissionAny, HasRepoPermissionAnyDecorator,
40 40 NotAnonymous, CSRFRequired)
41 41 from rhodecode.lib.utils2 import str2bool, safe_str, safe_unicode
42 42 from rhodecode.lib.vcs.backends.base import EmptyCommit, UpdateFailureReason
43 43 from rhodecode.lib.vcs.exceptions import (
44 44 CommitDoesNotExistError, RepositoryRequirementError, EmptyRepositoryError)
45 45 from rhodecode.model.changeset_status import ChangesetStatusModel
46 46 from rhodecode.model.comment import CommentsModel
47 47 from rhodecode.model.db import (
48 48 func, or_, PullRequest, ChangesetComment, ChangesetStatus, Repository)
49 49 from rhodecode.model.forms import PullRequestForm
50 50 from rhodecode.model.meta import Session
51 51 from rhodecode.model.pull_request import PullRequestModel, MergeCheck
52 52 from rhodecode.model.scm import ScmModel
53 53
54 54 log = logging.getLogger(__name__)
55 55
56 56
57 57 class RepoPullRequestsView(RepoAppView, DataGridAppView):
58 58
59 59 def load_default_context(self):
60 60 c = self._get_local_tmpl_context(include_app_defaults=True)
61 61 c.REVIEW_STATUS_APPROVED = ChangesetStatus.STATUS_APPROVED
62 62 c.REVIEW_STATUS_REJECTED = ChangesetStatus.STATUS_REJECTED
63 63 # backward compat., we use for OLD PRs a plain renderer
64 64 c.renderer = 'plain'
65 65 return c
66 66
67 67 def _get_pull_requests_list(
68 68 self, repo_name, source, filter_type, opened_by, statuses):
69 69
70 70 draw, start, limit = self._extract_chunk(self.request)
71 71 search_q, order_by, order_dir = self._extract_ordering(self.request)
72 72 _render = self.request.get_partial_renderer(
73 73 'rhodecode:templates/data_table/_dt_elements.mako')
74 74
75 75 # pagination
76 76
77 77 if filter_type == 'awaiting_review':
78 78 pull_requests = PullRequestModel().get_awaiting_review(
79 79 repo_name, search_q=search_q, source=source, opened_by=opened_by,
80 80 statuses=statuses, offset=start, length=limit,
81 81 order_by=order_by, order_dir=order_dir)
82 82 pull_requests_total_count = PullRequestModel().count_awaiting_review(
83 83 repo_name, search_q=search_q, source=source, statuses=statuses,
84 84 opened_by=opened_by)
85 85 elif filter_type == 'awaiting_my_review':
86 86 pull_requests = PullRequestModel().get_awaiting_my_review(
87 87 repo_name, search_q=search_q, source=source, opened_by=opened_by,
88 88 user_id=self._rhodecode_user.user_id, statuses=statuses,
89 89 offset=start, length=limit, order_by=order_by,
90 90 order_dir=order_dir)
91 91 pull_requests_total_count = PullRequestModel().count_awaiting_my_review(
92 92 repo_name, search_q=search_q, source=source, user_id=self._rhodecode_user.user_id,
93 93 statuses=statuses, opened_by=opened_by)
94 94 else:
95 95 pull_requests = PullRequestModel().get_all(
96 96 repo_name, search_q=search_q, source=source, opened_by=opened_by,
97 97 statuses=statuses, offset=start, length=limit,
98 98 order_by=order_by, order_dir=order_dir)
99 99 pull_requests_total_count = PullRequestModel().count_all(
100 100 repo_name, search_q=search_q, source=source, statuses=statuses,
101 101 opened_by=opened_by)
102 102
103 103 data = []
104 104 comments_model = CommentsModel()
105 105 for pr in pull_requests:
106 106 comments = comments_model.get_all_comments(
107 107 self.db_repo.repo_id, pull_request=pr)
108 108
109 109 data.append({
110 110 'name': _render('pullrequest_name',
111 111 pr.pull_request_id, pr.pull_request_state,
112 112 pr.work_in_progress, pr.target_repo.repo_name),
113 113 'name_raw': pr.pull_request_id,
114 114 'status': _render('pullrequest_status',
115 115 pr.calculated_review_status()),
116 116 'title': _render('pullrequest_title', pr.title, pr.description),
117 117 'description': h.escape(pr.description),
118 118 'updated_on': _render('pullrequest_updated_on',
119 119 h.datetime_to_time(pr.updated_on)),
120 120 'updated_on_raw': h.datetime_to_time(pr.updated_on),
121 121 'created_on': _render('pullrequest_updated_on',
122 122 h.datetime_to_time(pr.created_on)),
123 123 'created_on_raw': h.datetime_to_time(pr.created_on),
124 124 'state': pr.pull_request_state,
125 125 'author': _render('pullrequest_author',
126 126 pr.author.full_contact, ),
127 127 'author_raw': pr.author.full_name,
128 128 'comments': _render('pullrequest_comments', len(comments)),
129 129 'comments_raw': len(comments),
130 130 'closed': pr.is_closed(),
131 131 })
132 132
133 133 data = ({
134 134 'draw': draw,
135 135 'data': data,
136 136 'recordsTotal': pull_requests_total_count,
137 137 'recordsFiltered': pull_requests_total_count,
138 138 })
139 139 return data
140 140
141 141 @LoginRequired()
142 142 @HasRepoPermissionAnyDecorator(
143 143 'repository.read', 'repository.write', 'repository.admin')
144 144 @view_config(
145 145 route_name='pullrequest_show_all', request_method='GET',
146 146 renderer='rhodecode:templates/pullrequests/pullrequests.mako')
147 147 def pull_request_list(self):
148 148 c = self.load_default_context()
149 149
150 150 req_get = self.request.GET
151 151 c.source = str2bool(req_get.get('source'))
152 152 c.closed = str2bool(req_get.get('closed'))
153 153 c.my = str2bool(req_get.get('my'))
154 154 c.awaiting_review = str2bool(req_get.get('awaiting_review'))
155 155 c.awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
156 156
157 157 c.active = 'open'
158 158 if c.my:
159 159 c.active = 'my'
160 160 if c.closed:
161 161 c.active = 'closed'
162 162 if c.awaiting_review and not c.source:
163 163 c.active = 'awaiting'
164 164 if c.source and not c.awaiting_review:
165 165 c.active = 'source'
166 166 if c.awaiting_my_review:
167 167 c.active = 'awaiting_my'
168 168
169 169 return self._get_template_context(c)
170 170
171 171 @LoginRequired()
172 172 @HasRepoPermissionAnyDecorator(
173 173 'repository.read', 'repository.write', 'repository.admin')
174 174 @view_config(
175 175 route_name='pullrequest_show_all_data', request_method='GET',
176 176 renderer='json_ext', xhr=True)
177 177 def pull_request_list_data(self):
178 178 self.load_default_context()
179 179
180 180 # additional filters
181 181 req_get = self.request.GET
182 182 source = str2bool(req_get.get('source'))
183 183 closed = str2bool(req_get.get('closed'))
184 184 my = str2bool(req_get.get('my'))
185 185 awaiting_review = str2bool(req_get.get('awaiting_review'))
186 186 awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
187 187
188 188 filter_type = 'awaiting_review' if awaiting_review \
189 189 else 'awaiting_my_review' if awaiting_my_review \
190 190 else None
191 191
192 192 opened_by = None
193 193 if my:
194 194 opened_by = [self._rhodecode_user.user_id]
195 195
196 196 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
197 197 if closed:
198 198 statuses = [PullRequest.STATUS_CLOSED]
199 199
200 200 data = self._get_pull_requests_list(
201 201 repo_name=self.db_repo_name, source=source,
202 202 filter_type=filter_type, opened_by=opened_by, statuses=statuses)
203 203
204 204 return data
205 205
206 206 def _is_diff_cache_enabled(self, target_repo):
207 207 caching_enabled = self._get_general_setting(
208 208 target_repo, 'rhodecode_diff_cache')
209 209 log.debug('Diff caching enabled: %s', caching_enabled)
210 210 return caching_enabled
211 211
212 212 def _get_diffset(self, source_repo_name, source_repo,
213 213 ancestor_commit,
214 214 source_ref_id, target_ref_id,
215 215 target_commit, source_commit, diff_limit, file_limit,
216 216 fulldiff, hide_whitespace_changes, diff_context):
217 217
218 218 target_ref_id = ancestor_commit.raw_id
219 219 vcs_diff = PullRequestModel().get_diff(
220 220 source_repo, source_ref_id, target_ref_id,
221 221 hide_whitespace_changes, diff_context)
222 222
223 223 diff_processor = diffs.DiffProcessor(
224 224 vcs_diff, format='newdiff', diff_limit=diff_limit,
225 225 file_limit=file_limit, show_full_diff=fulldiff)
226 226
227 227 _parsed = diff_processor.prepare()
228 228
229 229 diffset = codeblocks.DiffSet(
230 230 repo_name=self.db_repo_name,
231 231 source_repo_name=source_repo_name,
232 232 source_node_getter=codeblocks.diffset_node_getter(target_commit),
233 233 target_node_getter=codeblocks.diffset_node_getter(source_commit),
234 234 )
235 235 diffset = self.path_filter.render_patchset_filtered(
236 236 diffset, _parsed, target_commit.raw_id, source_commit.raw_id)
237 237
238 238 return diffset
239 239
240 240 def _get_range_diffset(self, source_scm, source_repo,
241 241 commit1, commit2, diff_limit, file_limit,
242 242 fulldiff, hide_whitespace_changes, diff_context):
243 243 vcs_diff = source_scm.get_diff(
244 244 commit1, commit2,
245 245 ignore_whitespace=hide_whitespace_changes,
246 246 context=diff_context)
247 247
248 248 diff_processor = diffs.DiffProcessor(
249 249 vcs_diff, format='newdiff', diff_limit=diff_limit,
250 250 file_limit=file_limit, show_full_diff=fulldiff)
251 251
252 252 _parsed = diff_processor.prepare()
253 253
254 254 diffset = codeblocks.DiffSet(
255 255 repo_name=source_repo.repo_name,
256 256 source_node_getter=codeblocks.diffset_node_getter(commit1),
257 257 target_node_getter=codeblocks.diffset_node_getter(commit2))
258 258
259 259 diffset = self.path_filter.render_patchset_filtered(
260 260 diffset, _parsed, commit1.raw_id, commit2.raw_id)
261 261
262 262 return diffset
263 263
264 264 @LoginRequired()
265 265 @HasRepoPermissionAnyDecorator(
266 266 'repository.read', 'repository.write', 'repository.admin')
267 267 @view_config(
268 268 route_name='pullrequest_show', request_method='GET',
269 269 renderer='rhodecode:templates/pullrequests/pullrequest_show.mako')
270 270 def pull_request_show(self):
271 271 _ = self.request.translate
272 272 c = self.load_default_context()
273 273
274 274 pull_request = PullRequest.get_or_404(
275 275 self.request.matchdict['pull_request_id'])
276 276 pull_request_id = pull_request.pull_request_id
277 277
278 278 c.state_progressing = pull_request.is_state_changing()
279 279
280 280 _new_state = {
281 281 'created': PullRequest.STATE_CREATED,
282 282 }.get(self.request.GET.get('force_state'))
283 283
284 284 if c.is_super_admin and _new_state:
285 285 with pull_request.set_state(PullRequest.STATE_UPDATING, final_state=_new_state):
286 286 h.flash(
287 287 _('Pull Request state was force changed to `{}`').format(_new_state),
288 288 category='success')
289 289 Session().commit()
290 290
291 291 raise HTTPFound(h.route_path(
292 292 'pullrequest_show', repo_name=self.db_repo_name,
293 293 pull_request_id=pull_request_id))
294 294
295 295 version = self.request.GET.get('version')
296 296 from_version = self.request.GET.get('from_version') or version
297 297 merge_checks = self.request.GET.get('merge_checks')
298 298 c.fulldiff = str2bool(self.request.GET.get('fulldiff'))
299 299
300 300 # fetch global flags of ignore ws or context lines
301 301 diff_context = diffs.get_diff_context(self.request)
302 302 hide_whitespace_changes = diffs.get_diff_whitespace_flag(self.request)
303 303
304 304 force_refresh = str2bool(self.request.GET.get('force_refresh'))
305 305
306 306 (pull_request_latest,
307 307 pull_request_at_ver,
308 308 pull_request_display_obj,
309 309 at_version) = PullRequestModel().get_pr_version(
310 310 pull_request_id, version=version)
311 311 pr_closed = pull_request_latest.is_closed()
312 312
313 313 if pr_closed and (version or from_version):
314 314 # not allow to browse versions
315 315 raise HTTPFound(h.route_path(
316 316 'pullrequest_show', repo_name=self.db_repo_name,
317 317 pull_request_id=pull_request_id))
318 318
319 319 versions = pull_request_display_obj.versions()
320 320 # used to store per-commit range diffs
321 321 c.changes = collections.OrderedDict()
322 322 c.range_diff_on = self.request.GET.get('range-diff') == "1"
323 323
324 324 c.at_version = at_version
325 325 c.at_version_num = (at_version
326 326 if at_version and at_version != 'latest'
327 327 else None)
328 328 c.at_version_pos = ChangesetComment.get_index_from_version(
329 329 c.at_version_num, versions)
330 330
331 331 (prev_pull_request_latest,
332 332 prev_pull_request_at_ver,
333 333 prev_pull_request_display_obj,
334 334 prev_at_version) = PullRequestModel().get_pr_version(
335 335 pull_request_id, version=from_version)
336 336
337 337 c.from_version = prev_at_version
338 338 c.from_version_num = (prev_at_version
339 339 if prev_at_version and prev_at_version != 'latest'
340 340 else None)
341 341 c.from_version_pos = ChangesetComment.get_index_from_version(
342 342 c.from_version_num, versions)
343 343
344 344 # define if we're in COMPARE mode or VIEW at version mode
345 345 compare = at_version != prev_at_version
346 346
347 347 # pull_requests repo_name we opened it against
348 348 # ie. target_repo must match
349 349 if self.db_repo_name != pull_request_at_ver.target_repo.repo_name:
350 350 raise HTTPNotFound()
351 351
352 352 c.shadow_clone_url = PullRequestModel().get_shadow_clone_url(
353 353 pull_request_at_ver)
354 354
355 355 c.pull_request = pull_request_display_obj
356 356 c.renderer = pull_request_at_ver.description_renderer or c.renderer
357 357 c.pull_request_latest = pull_request_latest
358 358
359 359 if compare or (at_version and not at_version == 'latest'):
360 360 c.allowed_to_change_status = False
361 361 c.allowed_to_update = False
362 362 c.allowed_to_merge = False
363 363 c.allowed_to_delete = False
364 364 c.allowed_to_comment = False
365 365 c.allowed_to_close = False
366 366 else:
367 367 can_change_status = PullRequestModel().check_user_change_status(
368 368 pull_request_at_ver, self._rhodecode_user)
369 369 c.allowed_to_change_status = can_change_status and not pr_closed
370 370
371 371 c.allowed_to_update = PullRequestModel().check_user_update(
372 372 pull_request_latest, self._rhodecode_user) and not pr_closed
373 373 c.allowed_to_merge = PullRequestModel().check_user_merge(
374 374 pull_request_latest, self._rhodecode_user) and not pr_closed
375 375 c.allowed_to_delete = PullRequestModel().check_user_delete(
376 376 pull_request_latest, self._rhodecode_user) and not pr_closed
377 377 c.allowed_to_comment = not pr_closed
378 378 c.allowed_to_close = c.allowed_to_merge and not pr_closed
379 379
380 380 c.forbid_adding_reviewers = False
381 381 c.forbid_author_to_review = False
382 382 c.forbid_commit_author_to_review = False
383 383
384 384 if pull_request_latest.reviewer_data and \
385 385 'rules' in pull_request_latest.reviewer_data:
386 386 rules = pull_request_latest.reviewer_data['rules'] or {}
387 387 try:
388 388 c.forbid_adding_reviewers = rules.get(
389 389 'forbid_adding_reviewers')
390 390 c.forbid_author_to_review = rules.get(
391 391 'forbid_author_to_review')
392 392 c.forbid_commit_author_to_review = rules.get(
393 393 'forbid_commit_author_to_review')
394 394 except Exception:
395 395 pass
396 396
397 397 # check merge capabilities
398 398 _merge_check = MergeCheck.validate(
399 399 pull_request_latest, auth_user=self._rhodecode_user,
400 400 translator=self.request.translate,
401 401 force_shadow_repo_refresh=force_refresh)
402 402
403 403 c.pr_merge_errors = _merge_check.error_details
404 404 c.pr_merge_possible = not _merge_check.failed
405 405 c.pr_merge_message = _merge_check.merge_msg
406 406 c.pr_merge_source_commit = _merge_check.source_commit
407 407 c.pr_merge_target_commit = _merge_check.target_commit
408 408
409 409 c.pr_merge_info = MergeCheck.get_merge_conditions(
410 410 pull_request_latest, translator=self.request.translate)
411 411
412 412 c.pull_request_review_status = _merge_check.review_status
413 413 if merge_checks:
414 414 self.request.override_renderer = \
415 415 'rhodecode:templates/pullrequests/pullrequest_merge_checks.mako'
416 416 return self._get_template_context(c)
417 417
418 418 comments_model = CommentsModel()
419 419
420 420 # reviewers and statuses
421 421 c.pull_request_reviewers = pull_request_at_ver.reviewers_statuses()
422 422 allowed_reviewers = [x[0].user_id for x in c.pull_request_reviewers]
423 423
424 424 # GENERAL COMMENTS with versions #
425 425 q = comments_model._all_general_comments_of_pull_request(pull_request_latest)
426 426 q = q.order_by(ChangesetComment.comment_id.asc())
427 427 general_comments = q
428 428
429 429 # pick comments we want to render at current version
430 430 c.comment_versions = comments_model.aggregate_comments(
431 431 general_comments, versions, c.at_version_num)
432 432 c.comments = c.comment_versions[c.at_version_num]['until']
433 433
434 434 # INLINE COMMENTS with versions #
435 435 q = comments_model._all_inline_comments_of_pull_request(pull_request_latest)
436 436 q = q.order_by(ChangesetComment.comment_id.asc())
437 437 inline_comments = q
438 438
439 439 c.inline_versions = comments_model.aggregate_comments(
440 440 inline_comments, versions, c.at_version_num, inline=True)
441 441
442 442 # TODOs
443 443 c.unresolved_comments = CommentsModel() \
444 444 .get_pull_request_unresolved_todos(pull_request)
445 445 c.resolved_comments = CommentsModel() \
446 446 .get_pull_request_resolved_todos(pull_request)
447 447
448 448 # inject latest version
449 449 latest_ver = PullRequest.get_pr_display_object(
450 450 pull_request_latest, pull_request_latest)
451 451
452 452 c.versions = versions + [latest_ver]
453 453
454 454 # if we use version, then do not show later comments
455 455 # than current version
456 456 display_inline_comments = collections.defaultdict(
457 457 lambda: collections.defaultdict(list))
458 458 for co in inline_comments:
459 459 if c.at_version_num:
460 460 # pick comments that are at least UPTO given version, so we
461 461 # don't render comments for higher version
462 462 should_render = co.pull_request_version_id and \
463 463 co.pull_request_version_id <= c.at_version_num
464 464 else:
465 465 # showing all, for 'latest'
466 466 should_render = True
467 467
468 468 if should_render:
469 469 display_inline_comments[co.f_path][co.line_no].append(co)
470 470
471 471 # load diff data into template context, if we use compare mode then
472 472 # diff is calculated based on changes between versions of PR
473 473
474 474 source_repo = pull_request_at_ver.source_repo
475 475 source_ref_id = pull_request_at_ver.source_ref_parts.commit_id
476 476
477 477 target_repo = pull_request_at_ver.target_repo
478 478 target_ref_id = pull_request_at_ver.target_ref_parts.commit_id
479 479
480 480 if compare:
481 481 # in compare switch the diff base to latest commit from prev version
482 482 target_ref_id = prev_pull_request_display_obj.revisions[0]
483 483
484 484 # despite opening commits for bookmarks/branches/tags, we always
485 485 # convert this to rev to prevent changes after bookmark or branch change
486 486 c.source_ref_type = 'rev'
487 487 c.source_ref = source_ref_id
488 488
489 489 c.target_ref_type = 'rev'
490 490 c.target_ref = target_ref_id
491 491
492 492 c.source_repo = source_repo
493 493 c.target_repo = target_repo
494 494
495 495 c.commit_ranges = []
496 496 source_commit = EmptyCommit()
497 497 target_commit = EmptyCommit()
498 498 c.missing_requirements = False
499 499
500 500 source_scm = source_repo.scm_instance()
501 501 target_scm = target_repo.scm_instance()
502 502
503 503 shadow_scm = None
504 504 try:
505 505 shadow_scm = pull_request_latest.get_shadow_repo()
506 506 except Exception:
507 507 log.debug('Failed to get shadow repo', exc_info=True)
508 508 # try first the existing source_repo, and then shadow
509 509 # repo if we can obtain one
510 510 commits_source_repo = source_scm
511 511 if shadow_scm:
512 512 commits_source_repo = shadow_scm
513 513
514 514 c.commits_source_repo = commits_source_repo
515 515 c.ancestor = None # set it to None, to hide it from PR view
516 516
517 517 # empty version means latest, so we keep this to prevent
518 518 # double caching
519 519 version_normalized = version or 'latest'
520 520 from_version_normalized = from_version or 'latest'
521 521
522 522 cache_path = self.rhodecode_vcs_repo.get_create_shadow_cache_pr_path(target_repo)
523 523 cache_file_path = diff_cache_exist(
524 524 cache_path, 'pull_request', pull_request_id, version_normalized,
525 525 from_version_normalized, source_ref_id, target_ref_id,
526 526 hide_whitespace_changes, diff_context, c.fulldiff)
527 527
528 528 caching_enabled = self._is_diff_cache_enabled(c.target_repo)
529 529 force_recache = self.get_recache_flag()
530 530
531 531 cached_diff = None
532 532 if caching_enabled:
533 533 cached_diff = load_cached_diff(cache_file_path)
534 534
535 535 has_proper_commit_cache = (
536 536 cached_diff and cached_diff.get('commits')
537 537 and len(cached_diff.get('commits', [])) == 5
538 538 and cached_diff.get('commits')[0]
539 539 and cached_diff.get('commits')[3])
540 540
541 541 if not force_recache and not c.range_diff_on and has_proper_commit_cache:
542 542 diff_commit_cache = \
543 543 (ancestor_commit, commit_cache, missing_requirements,
544 544 source_commit, target_commit) = cached_diff['commits']
545 545 else:
546 546 # NOTE(marcink): we reach potentially unreachable errors when a PR has
547 547 # merge errors resulting in potentially hidden commits in the shadow repo.
548 548 maybe_unreachable = _merge_check.MERGE_CHECK in _merge_check.error_details \
549 549 and _merge_check.merge_response
550 550 maybe_unreachable = maybe_unreachable \
551 551 and _merge_check.merge_response.metadata.get('unresolved_files')
552 552 log.debug("Using unreachable commits due to MERGE_CHECK in merge simulation")
553 553 diff_commit_cache = \
554 554 (ancestor_commit, commit_cache, missing_requirements,
555 555 source_commit, target_commit) = self.get_commits(
556 556 commits_source_repo,
557 557 pull_request_at_ver,
558 558 source_commit,
559 559 source_ref_id,
560 560 source_scm,
561 561 target_commit,
562 562 target_ref_id,
563 563 target_scm,
564 564 maybe_unreachable=maybe_unreachable)
565 565
566 566 # register our commit range
567 567 for comm in commit_cache.values():
568 568 c.commit_ranges.append(comm)
569 569
570 570 c.missing_requirements = missing_requirements
571 571 c.ancestor_commit = ancestor_commit
572 572 c.statuses = source_repo.statuses(
573 573 [x.raw_id for x in c.commit_ranges])
574 574
575 575 # auto collapse if we have more than limit
576 576 collapse_limit = diffs.DiffProcessor._collapse_commits_over
577 577 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
578 578 c.compare_mode = compare
579 579
580 580 # diff_limit is the old behavior, will cut off the whole diff
581 581 # if the limit is applied otherwise will just hide the
582 582 # big files from the front-end
583 583 diff_limit = c.visual.cut_off_limit_diff
584 584 file_limit = c.visual.cut_off_limit_file
585 585
586 586 c.missing_commits = False
587 587 if (c.missing_requirements
588 588 or isinstance(source_commit, EmptyCommit)
589 589 or source_commit == target_commit):
590 590
591 591 c.missing_commits = True
592 592 else:
593 593 c.inline_comments = display_inline_comments
594 594
595 595 has_proper_diff_cache = cached_diff and cached_diff.get('commits')
596 596 if not force_recache and has_proper_diff_cache:
597 597 c.diffset = cached_diff['diff']
598 598 else:
599 599 c.diffset = self._get_diffset(
600 600 c.source_repo.repo_name, commits_source_repo,
601 601 c.ancestor_commit,
602 602 source_ref_id, target_ref_id,
603 603 target_commit, source_commit,
604 604 diff_limit, file_limit, c.fulldiff,
605 605 hide_whitespace_changes, diff_context)
606 606
607 607 # save cached diff
608 608 if caching_enabled:
609 609 cache_diff(cache_file_path, c.diffset, diff_commit_cache)
610 610
611 611 c.limited_diff = c.diffset.limited_diff
612 612
613 613 # calculate removed files that are bound to comments
614 614 comment_deleted_files = [
615 615 fname for fname in display_inline_comments
616 616 if fname not in c.diffset.file_stats]
617 617
618 618 c.deleted_files_comments = collections.defaultdict(dict)
619 619 for fname, per_line_comments in display_inline_comments.items():
620 620 if fname in comment_deleted_files:
621 621 c.deleted_files_comments[fname]['stats'] = 0
622 622 c.deleted_files_comments[fname]['comments'] = list()
623 623 for lno, comments in per_line_comments.items():
624 624 c.deleted_files_comments[fname]['comments'].extend(comments)
625 625
626 626 # maybe calculate the range diff
627 627 if c.range_diff_on:
628 628 # TODO(marcink): set whitespace/context
629 629 context_lcl = 3
630 630 ign_whitespace_lcl = False
631 631
632 632 for commit in c.commit_ranges:
633 633 commit2 = commit
634 634 commit1 = commit.first_parent
635 635
636 636 range_diff_cache_file_path = diff_cache_exist(
637 637 cache_path, 'diff', commit.raw_id,
638 638 ign_whitespace_lcl, context_lcl, c.fulldiff)
639 639
640 640 cached_diff = None
641 641 if caching_enabled:
642 642 cached_diff = load_cached_diff(range_diff_cache_file_path)
643 643
644 644 has_proper_diff_cache = cached_diff and cached_diff.get('diff')
645 645 if not force_recache and has_proper_diff_cache:
646 646 diffset = cached_diff['diff']
647 647 else:
648 648 diffset = self._get_range_diffset(
649 649 commits_source_repo, source_repo,
650 650 commit1, commit2, diff_limit, file_limit,
651 651 c.fulldiff, ign_whitespace_lcl, context_lcl
652 652 )
653 653
654 654 # save cached diff
655 655 if caching_enabled:
656 656 cache_diff(range_diff_cache_file_path, diffset, None)
657 657
658 658 c.changes[commit.raw_id] = diffset
659 659
660 660 # this is a hack to properly display links, when creating PR, the
661 661 # compare view and others uses different notation, and
662 662 # compare_commits.mako renders links based on the target_repo.
663 663 # We need to swap that here to generate it properly on the html side
664 664 c.target_repo = c.source_repo
665 665
666 666 c.commit_statuses = ChangesetStatus.STATUSES
667 667
668 668 c.show_version_changes = not pr_closed
669 669 if c.show_version_changes:
670 670 cur_obj = pull_request_at_ver
671 671 prev_obj = prev_pull_request_at_ver
672 672
673 673 old_commit_ids = prev_obj.revisions
674 674 new_commit_ids = cur_obj.revisions
675 675 commit_changes = PullRequestModel()._calculate_commit_id_changes(
676 676 old_commit_ids, new_commit_ids)
677 677 c.commit_changes_summary = commit_changes
678 678
679 679 # calculate the diff for commits between versions
680 680 c.commit_changes = []
681 681
682 682 def mark(cs, fw):
683 683 return list(h.itertools.izip_longest([], cs, fillvalue=fw))
684 684
685 685 for c_type, raw_id in mark(commit_changes.added, 'a') \
686 686 + mark(commit_changes.removed, 'r') \
687 687 + mark(commit_changes.common, 'c'):
688 688
689 689 if raw_id in commit_cache:
690 690 commit = commit_cache[raw_id]
691 691 else:
692 692 try:
693 693 commit = commits_source_repo.get_commit(raw_id)
694 694 except CommitDoesNotExistError:
695 695 # in case we fail extracting still use "dummy" commit
696 696 # for display in commit diff
697 697 commit = h.AttributeDict(
698 698 {'raw_id': raw_id,
699 699 'message': 'EMPTY or MISSING COMMIT'})
700 700 c.commit_changes.append([c_type, commit])
701 701
702 702 # current user review statuses for each version
703 703 c.review_versions = {}
704 704 if self._rhodecode_user.user_id in allowed_reviewers:
705 705 for co in general_comments:
706 706 if co.author.user_id == self._rhodecode_user.user_id:
707 707 status = co.status_change
708 708 if status:
709 709 _ver_pr = status[0].comment.pull_request_version_id
710 710 c.review_versions[_ver_pr] = status[0]
711 711
712 712 return self._get_template_context(c)
713 713
714 714 def get_commits(
715 715 self, commits_source_repo, pull_request_at_ver, source_commit,
716 716 source_ref_id, source_scm, target_commit, target_ref_id, target_scm,
717 717 maybe_unreachable=False):
718 718
719 719 commit_cache = collections.OrderedDict()
720 720 missing_requirements = False
721 721
722 722 try:
723 723 pre_load = ["author", "date", "message", "branch", "parents"]
724 724
725 725 pull_request_commits = pull_request_at_ver.revisions
726 726 log.debug('Loading %s commits from %s',
727 727 len(pull_request_commits), commits_source_repo)
728 728
729 729 for rev in pull_request_commits:
730 730 comm = commits_source_repo.get_commit(commit_id=rev, pre_load=pre_load,
731 731 maybe_unreachable=maybe_unreachable)
732 732 commit_cache[comm.raw_id] = comm
733 733
734 734 # Order here matters, we first need to get target, and then
735 735 # the source
736 736 target_commit = commits_source_repo.get_commit(
737 737 commit_id=safe_str(target_ref_id))
738 738
739 739 source_commit = commits_source_repo.get_commit(
740 740 commit_id=safe_str(source_ref_id), maybe_unreachable=True)
741 741 except CommitDoesNotExistError:
742 742 log.warning('Failed to get commit from `{}` repo'.format(
743 743 commits_source_repo), exc_info=True)
744 744 except RepositoryRequirementError:
745 745 log.warning('Failed to get all required data from repo', exc_info=True)
746 746 missing_requirements = True
747 747
748 748 pr_ancestor_id = pull_request_at_ver.common_ancestor_id
749 749
750 750 try:
751 751 ancestor_commit = source_scm.get_commit(pr_ancestor_id)
752 752 except Exception:
753 753 ancestor_commit = None
754 754
755 755 return ancestor_commit, commit_cache, missing_requirements, source_commit, target_commit
756 756
757 757 def assure_not_empty_repo(self):
758 758 _ = self.request.translate
759 759
760 760 try:
761 761 self.db_repo.scm_instance().get_commit()
762 762 except EmptyRepositoryError:
763 763 h.flash(h.literal(_('There are no commits yet')),
764 764 category='warning')
765 765 raise HTTPFound(
766 766 h.route_path('repo_summary', repo_name=self.db_repo.repo_name))
767 767
768 768 @LoginRequired()
769 769 @NotAnonymous()
770 770 @HasRepoPermissionAnyDecorator(
771 771 'repository.read', 'repository.write', 'repository.admin')
772 772 @view_config(
773 773 route_name='pullrequest_new', request_method='GET',
774 774 renderer='rhodecode:templates/pullrequests/pullrequest.mako')
775 775 def pull_request_new(self):
776 776 _ = self.request.translate
777 777 c = self.load_default_context()
778 778
779 779 self.assure_not_empty_repo()
780 780 source_repo = self.db_repo
781 781
782 782 commit_id = self.request.GET.get('commit')
783 783 branch_ref = self.request.GET.get('branch')
784 784 bookmark_ref = self.request.GET.get('bookmark')
785 785
786 786 try:
787 787 source_repo_data = PullRequestModel().generate_repo_data(
788 788 source_repo, commit_id=commit_id,
789 789 branch=branch_ref, bookmark=bookmark_ref,
790 790 translator=self.request.translate)
791 791 except CommitDoesNotExistError as e:
792 792 log.exception(e)
793 793 h.flash(_('Commit does not exist'), 'error')
794 794 raise HTTPFound(
795 795 h.route_path('pullrequest_new', repo_name=source_repo.repo_name))
796 796
797 797 default_target_repo = source_repo
798 798
799 799 if source_repo.parent and c.has_origin_repo_read_perm:
800 800 parent_vcs_obj = source_repo.parent.scm_instance()
801 801 if parent_vcs_obj and not parent_vcs_obj.is_empty():
802 802 # change default if we have a parent repo
803 803 default_target_repo = source_repo.parent
804 804
805 805 target_repo_data = PullRequestModel().generate_repo_data(
806 806 default_target_repo, translator=self.request.translate)
807 807
808 808 selected_source_ref = source_repo_data['refs']['selected_ref']
809 809 title_source_ref = ''
810 810 if selected_source_ref:
811 811 title_source_ref = selected_source_ref.split(':', 2)[1]
812 812 c.default_title = PullRequestModel().generate_pullrequest_title(
813 813 source=source_repo.repo_name,
814 814 source_ref=title_source_ref,
815 815 target=default_target_repo.repo_name
816 816 )
817 817
818 818 c.default_repo_data = {
819 819 'source_repo_name': source_repo.repo_name,
820 820 'source_refs_json': json.dumps(source_repo_data),
821 821 'target_repo_name': default_target_repo.repo_name,
822 822 'target_refs_json': json.dumps(target_repo_data),
823 823 }
824 824 c.default_source_ref = selected_source_ref
825 825
826 826 return self._get_template_context(c)
827 827
828 828 @LoginRequired()
829 829 @NotAnonymous()
830 830 @HasRepoPermissionAnyDecorator(
831 831 'repository.read', 'repository.write', 'repository.admin')
832 832 @view_config(
833 833 route_name='pullrequest_repo_refs', request_method='GET',
834 834 renderer='json_ext', xhr=True)
835 835 def pull_request_repo_refs(self):
836 836 self.load_default_context()
837 837 target_repo_name = self.request.matchdict['target_repo_name']
838 838 repo = Repository.get_by_repo_name(target_repo_name)
839 839 if not repo:
840 840 raise HTTPNotFound()
841 841
842 842 target_perm = HasRepoPermissionAny(
843 843 'repository.read', 'repository.write', 'repository.admin')(
844 844 target_repo_name)
845 845 if not target_perm:
846 846 raise HTTPNotFound()
847 847
848 848 return PullRequestModel().generate_repo_data(
849 849 repo, translator=self.request.translate)
850 850
851 851 @LoginRequired()
852 852 @NotAnonymous()
853 853 @HasRepoPermissionAnyDecorator(
854 854 'repository.read', 'repository.write', 'repository.admin')
855 855 @view_config(
856 856 route_name='pullrequest_repo_targets', request_method='GET',
857 857 renderer='json_ext', xhr=True)
858 858 def pullrequest_repo_targets(self):
859 859 _ = self.request.translate
860 860 filter_query = self.request.GET.get('query')
861 861
862 862 # get the parents
863 863 parent_target_repos = []
864 864 if self.db_repo.parent:
865 865 parents_query = Repository.query() \
866 866 .order_by(func.length(Repository.repo_name)) \
867 867 .filter(Repository.fork_id == self.db_repo.parent.repo_id)
868 868
869 869 if filter_query:
870 870 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
871 871 parents_query = parents_query.filter(
872 872 Repository.repo_name.ilike(ilike_expression))
873 873 parents = parents_query.limit(20).all()
874 874
875 875 for parent in parents:
876 876 parent_vcs_obj = parent.scm_instance()
877 877 if parent_vcs_obj and not parent_vcs_obj.is_empty():
878 878 parent_target_repos.append(parent)
879 879
880 880 # get other forks, and repo itself
881 881 query = Repository.query() \
882 882 .order_by(func.length(Repository.repo_name)) \
883 883 .filter(
884 884 or_(Repository.repo_id == self.db_repo.repo_id, # repo itself
885 885 Repository.fork_id == self.db_repo.repo_id) # forks of this repo
886 886 ) \
887 887 .filter(~Repository.repo_id.in_([x.repo_id for x in parent_target_repos]))
888 888
889 889 if filter_query:
890 890 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
891 891 query = query.filter(Repository.repo_name.ilike(ilike_expression))
892 892
893 893 limit = max(20 - len(parent_target_repos), 5) # not less then 5
894 894 target_repos = query.limit(limit).all()
895 895
896 896 all_target_repos = target_repos + parent_target_repos
897 897
898 898 repos = []
899 899 # This checks permissions to the repositories
900 900 for obj in ScmModel().get_repos(all_target_repos):
901 901 repos.append({
902 902 'id': obj['name'],
903 903 'text': obj['name'],
904 904 'type': 'repo',
905 905 'repo_id': obj['dbrepo']['repo_id'],
906 906 'repo_type': obj['dbrepo']['repo_type'],
907 907 'private': obj['dbrepo']['private'],
908 908
909 909 })
910 910
911 911 data = {
912 912 'more': False,
913 913 'results': [{
914 914 'text': _('Repositories'),
915 915 'children': repos
916 916 }] if repos else []
917 917 }
918 918 return data
919 919
920 920 @LoginRequired()
921 921 @NotAnonymous()
922 922 @HasRepoPermissionAnyDecorator(
923 923 'repository.read', 'repository.write', 'repository.admin')
924 924 @CSRFRequired()
925 925 @view_config(
926 926 route_name='pullrequest_create', request_method='POST',
927 927 renderer=None)
928 928 def pull_request_create(self):
929 929 _ = self.request.translate
930 930 self.assure_not_empty_repo()
931 931 self.load_default_context()
932 932
933 933 controls = peppercorn.parse(self.request.POST.items())
934 934
935 935 try:
936 936 form = PullRequestForm(
937 937 self.request.translate, self.db_repo.repo_id)()
938 938 _form = form.to_python(controls)
939 939 except formencode.Invalid as errors:
940 940 if errors.error_dict.get('revisions'):
941 941 msg = 'Revisions: %s' % errors.error_dict['revisions']
942 942 elif errors.error_dict.get('pullrequest_title'):
943 943 msg = errors.error_dict.get('pullrequest_title')
944 944 else:
945 945 msg = _('Error creating pull request: {}').format(errors)
946 946 log.exception(msg)
947 947 h.flash(msg, 'error')
948 948
949 949 # would rather just go back to form ...
950 950 raise HTTPFound(
951 951 h.route_path('pullrequest_new', repo_name=self.db_repo_name))
952 952
953 953 source_repo = _form['source_repo']
954 954 source_ref = _form['source_ref']
955 955 target_repo = _form['target_repo']
956 956 target_ref = _form['target_ref']
957 957 commit_ids = _form['revisions'][::-1]
958 958 common_ancestor_id = _form['common_ancestor']
959 959
960 960 # find the ancestor for this pr
961 961 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
962 962 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
963 963
964 964 if not (source_db_repo or target_db_repo):
965 965 h.flash(_('source_repo or target repo not found'), category='error')
966 966 raise HTTPFound(
967 967 h.route_path('pullrequest_new', repo_name=self.db_repo_name))
968 968
969 969 # re-check permissions again here
970 970 # source_repo we must have read permissions
971 971
972 972 source_perm = HasRepoPermissionAny(
973 973 'repository.read', 'repository.write', 'repository.admin')(
974 974 source_db_repo.repo_name)
975 975 if not source_perm:
976 976 msg = _('Not Enough permissions to source repo `{}`.'.format(
977 977 source_db_repo.repo_name))
978 978 h.flash(msg, category='error')
979 979 # copy the args back to redirect
980 980 org_query = self.request.GET.mixed()
981 981 raise HTTPFound(
982 982 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
983 983 _query=org_query))
984 984
985 985 # target repo we must have read permissions, and also later on
986 986 # we want to check branch permissions here
987 987 target_perm = HasRepoPermissionAny(
988 988 'repository.read', 'repository.write', 'repository.admin')(
989 989 target_db_repo.repo_name)
990 990 if not target_perm:
991 991 msg = _('Not Enough permissions to target repo `{}`.'.format(
992 992 target_db_repo.repo_name))
993 993 h.flash(msg, category='error')
994 994 # copy the args back to redirect
995 995 org_query = self.request.GET.mixed()
996 996 raise HTTPFound(
997 997 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
998 998 _query=org_query))
999 999
1000 1000 source_scm = source_db_repo.scm_instance()
1001 1001 target_scm = target_db_repo.scm_instance()
1002 1002
1003 1003 source_commit = source_scm.get_commit(source_ref.split(':')[-1])
1004 1004 target_commit = target_scm.get_commit(target_ref.split(':')[-1])
1005 1005
1006 1006 ancestor = source_scm.get_common_ancestor(
1007 1007 source_commit.raw_id, target_commit.raw_id, target_scm)
1008 1008
1009 1009 # recalculate target ref based on ancestor
1010 1010 target_ref_type, target_ref_name, __ = _form['target_ref'].split(':')
1011 1011 target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
1012 1012
1013 1013 get_default_reviewers_data, validate_default_reviewers = \
1014 1014 PullRequestModel().get_reviewer_functions()
1015 1015
1016 1016 # recalculate reviewers logic, to make sure we can validate this
1017 1017 reviewer_rules = get_default_reviewers_data(
1018 1018 self._rhodecode_db_user, source_db_repo,
1019 1019 source_commit, target_db_repo, target_commit)
1020 1020
1021 1021 given_reviewers = _form['review_members']
1022 1022 reviewers = validate_default_reviewers(
1023 1023 given_reviewers, reviewer_rules)
1024 1024
1025 1025 pullrequest_title = _form['pullrequest_title']
1026 1026 title_source_ref = source_ref.split(':', 2)[1]
1027 1027 if not pullrequest_title:
1028 1028 pullrequest_title = PullRequestModel().generate_pullrequest_title(
1029 1029 source=source_repo,
1030 1030 source_ref=title_source_ref,
1031 1031 target=target_repo
1032 1032 )
1033 1033
1034 1034 description = _form['pullrequest_desc']
1035 1035 description_renderer = _form['description_renderer']
1036 1036
1037 1037 try:
1038 1038 pull_request = PullRequestModel().create(
1039 1039 created_by=self._rhodecode_user.user_id,
1040 1040 source_repo=source_repo,
1041 1041 source_ref=source_ref,
1042 1042 target_repo=target_repo,
1043 1043 target_ref=target_ref,
1044 1044 revisions=commit_ids,
1045 1045 common_ancestor_id=common_ancestor_id,
1046 1046 reviewers=reviewers,
1047 1047 title=pullrequest_title,
1048 1048 description=description,
1049 1049 description_renderer=description_renderer,
1050 1050 reviewer_data=reviewer_rules,
1051 1051 auth_user=self._rhodecode_user
1052 1052 )
1053 1053 Session().commit()
1054 1054
1055 1055 h.flash(_('Successfully opened new pull request'),
1056 1056 category='success')
1057 1057 except Exception:
1058 1058 msg = _('Error occurred during creation of this pull request.')
1059 1059 log.exception(msg)
1060 1060 h.flash(msg, category='error')
1061 1061
1062 1062 # copy the args back to redirect
1063 1063 org_query = self.request.GET.mixed()
1064 1064 raise HTTPFound(
1065 1065 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
1066 1066 _query=org_query))
1067 1067
1068 1068 raise HTTPFound(
1069 1069 h.route_path('pullrequest_show', repo_name=target_repo,
1070 1070 pull_request_id=pull_request.pull_request_id))
1071 1071
1072 1072 @LoginRequired()
1073 1073 @NotAnonymous()
1074 1074 @HasRepoPermissionAnyDecorator(
1075 1075 'repository.read', 'repository.write', 'repository.admin')
1076 1076 @CSRFRequired()
1077 1077 @view_config(
1078 1078 route_name='pullrequest_update', request_method='POST',
1079 1079 renderer='json_ext')
1080 1080 def pull_request_update(self):
1081 1081 pull_request = PullRequest.get_or_404(
1082 1082 self.request.matchdict['pull_request_id'])
1083 1083 _ = self.request.translate
1084 1084
1085 1085 self.load_default_context()
1086 1086 redirect_url = None
1087 1087
1088 1088 if pull_request.is_closed():
1089 1089 log.debug('update: forbidden because pull request is closed')
1090 1090 msg = _(u'Cannot update closed pull requests.')
1091 1091 h.flash(msg, category='error')
1092 1092 return {'response': True,
1093 1093 'redirect_url': redirect_url}
1094 1094
1095 1095 is_state_changing = pull_request.is_state_changing()
1096 1096
1097 1097 # only owner or admin can update it
1098 1098 allowed_to_update = PullRequestModel().check_user_update(
1099 1099 pull_request, self._rhodecode_user)
1100 1100 if allowed_to_update:
1101 1101 controls = peppercorn.parse(self.request.POST.items())
1102 1102 force_refresh = str2bool(self.request.POST.get('force_refresh'))
1103 1103
1104 1104 if 'review_members' in controls:
1105 1105 self._update_reviewers(
1106 1106 pull_request, controls['review_members'],
1107 1107 pull_request.reviewer_data)
1108 1108 elif str2bool(self.request.POST.get('update_commits', 'false')):
1109 1109 if is_state_changing:
1110 1110 log.debug('commits update: forbidden because pull request is in state %s',
1111 1111 pull_request.pull_request_state)
1112 1112 msg = _(u'Cannot update pull requests commits in state other than `{}`. '
1113 1113 u'Current state is: `{}`').format(
1114 1114 PullRequest.STATE_CREATED, pull_request.pull_request_state)
1115 1115 h.flash(msg, category='error')
1116 1116 return {'response': True,
1117 1117 'redirect_url': redirect_url}
1118 1118
1119 1119 self._update_commits(pull_request)
1120 1120 if force_refresh:
1121 1121 redirect_url = h.route_path(
1122 1122 'pullrequest_show', repo_name=self.db_repo_name,
1123 1123 pull_request_id=pull_request.pull_request_id,
1124 1124 _query={"force_refresh": 1})
1125 1125 elif str2bool(self.request.POST.get('edit_pull_request', 'false')):
1126 1126 self._edit_pull_request(pull_request)
1127 1127 else:
1128 1128 raise HTTPBadRequest()
1129 1129
1130 1130 return {'response': True,
1131 1131 'redirect_url': redirect_url}
1132 1132 raise HTTPForbidden()
1133 1133
1134 1134 def _edit_pull_request(self, pull_request):
1135 1135 _ = self.request.translate
1136 1136
1137 1137 try:
1138 1138 PullRequestModel().edit(
1139 1139 pull_request,
1140 1140 self.request.POST.get('title'),
1141 1141 self.request.POST.get('description'),
1142 1142 self.request.POST.get('description_renderer'),
1143 1143 self._rhodecode_user)
1144 1144 except ValueError:
1145 1145 msg = _(u'Cannot update closed pull requests.')
1146 1146 h.flash(msg, category='error')
1147 1147 return
1148 1148 else:
1149 1149 Session().commit()
1150 1150
1151 1151 msg = _(u'Pull request title & description updated.')
1152 1152 h.flash(msg, category='success')
1153 1153 return
1154 1154
1155 1155 def _update_commits(self, pull_request):
1156 1156 _ = self.request.translate
1157 1157
1158 1158 with pull_request.set_state(PullRequest.STATE_UPDATING):
1159 1159 resp = PullRequestModel().update_commits(
1160 1160 pull_request, self._rhodecode_db_user)
1161 1161
1162 1162 if resp.executed:
1163 1163
1164 1164 if resp.target_changed and resp.source_changed:
1165 1165 changed = 'target and source repositories'
1166 1166 elif resp.target_changed and not resp.source_changed:
1167 1167 changed = 'target repository'
1168 1168 elif not resp.target_changed and resp.source_changed:
1169 1169 changed = 'source repository'
1170 1170 else:
1171 1171 changed = 'nothing'
1172 1172
1173 1173 msg = _(u'Pull request updated to "{source_commit_id}" with '
1174 1174 u'{count_added} added, {count_removed} removed commits. '
1175 1175 u'Source of changes: {change_source}')
1176 1176 msg = msg.format(
1177 1177 source_commit_id=pull_request.source_ref_parts.commit_id,
1178 1178 count_added=len(resp.changes.added),
1179 1179 count_removed=len(resp.changes.removed),
1180 1180 change_source=changed)
1181 1181 h.flash(msg, category='success')
1182 1182
1183 1183 channel = '/repo${}$/pr/{}'.format(
1184 1184 pull_request.target_repo.repo_name, pull_request.pull_request_id)
1185 1185 message = msg + (
1186 1186 ' - <a onclick="window.location.reload()">'
1187 1187 '<strong>{}</strong></a>'.format(_('Reload page')))
1188 1188 channelstream.post_message(
1189 1189 channel, message, self._rhodecode_user.username,
1190 1190 registry=self.request.registry)
1191 1191 else:
1192 1192 msg = PullRequestModel.UPDATE_STATUS_MESSAGES[resp.reason]
1193 1193 warning_reasons = [
1194 1194 UpdateFailureReason.NO_CHANGE,
1195 1195 UpdateFailureReason.WRONG_REF_TYPE,
1196 1196 ]
1197 1197 category = 'warning' if resp.reason in warning_reasons else 'error'
1198 1198 h.flash(msg, category=category)
1199 1199
1200 1200 @LoginRequired()
1201 1201 @NotAnonymous()
1202 1202 @HasRepoPermissionAnyDecorator(
1203 1203 'repository.read', 'repository.write', 'repository.admin')
1204 1204 @CSRFRequired()
1205 1205 @view_config(
1206 1206 route_name='pullrequest_merge', request_method='POST',
1207 1207 renderer='json_ext')
1208 1208 def pull_request_merge(self):
1209 1209 """
1210 1210 Merge will perform a server-side merge of the specified
1211 1211 pull request, if the pull request is approved and mergeable.
1212 1212 After successful merging, the pull request is automatically
1213 1213 closed, with a relevant comment.
1214 1214 """
1215 1215 pull_request = PullRequest.get_or_404(
1216 1216 self.request.matchdict['pull_request_id'])
1217 1217 _ = self.request.translate
1218 1218
1219 1219 if pull_request.is_state_changing():
1220 1220 log.debug('show: forbidden because pull request is in state %s',
1221 1221 pull_request.pull_request_state)
1222 1222 msg = _(u'Cannot merge pull requests in state other than `{}`. '
1223 1223 u'Current state is: `{}`').format(PullRequest.STATE_CREATED,
1224 1224 pull_request.pull_request_state)
1225 1225 h.flash(msg, category='error')
1226 1226 raise HTTPFound(
1227 1227 h.route_path('pullrequest_show',
1228 1228 repo_name=pull_request.target_repo.repo_name,
1229 1229 pull_request_id=pull_request.pull_request_id))
1230 1230
1231 1231 self.load_default_context()
1232 1232
1233 1233 with pull_request.set_state(PullRequest.STATE_UPDATING):
1234 1234 check = MergeCheck.validate(
1235 1235 pull_request, auth_user=self._rhodecode_user,
1236 1236 translator=self.request.translate)
1237 1237 merge_possible = not check.failed
1238 1238
1239 1239 for err_type, error_msg in check.errors:
1240 1240 h.flash(error_msg, category=err_type)
1241 1241
1242 1242 if merge_possible:
1243 1243 log.debug("Pre-conditions checked, trying to merge.")
1244 1244 extras = vcs_operation_context(
1245 1245 self.request.environ, repo_name=pull_request.target_repo.repo_name,
1246 1246 username=self._rhodecode_db_user.username, action='push',
1247 1247 scm=pull_request.target_repo.repo_type)
1248 1248 with pull_request.set_state(PullRequest.STATE_UPDATING):
1249 1249 self._merge_pull_request(
1250 1250 pull_request, self._rhodecode_db_user, extras)
1251 1251 else:
1252 1252 log.debug("Pre-conditions failed, NOT merging.")
1253 1253
1254 1254 raise HTTPFound(
1255 1255 h.route_path('pullrequest_show',
1256 1256 repo_name=pull_request.target_repo.repo_name,
1257 1257 pull_request_id=pull_request.pull_request_id))
1258 1258
1259 1259 def _merge_pull_request(self, pull_request, user, extras):
1260 1260 _ = self.request.translate
1261 1261 merge_resp = PullRequestModel().merge_repo(pull_request, user, extras=extras)
1262 1262
1263 1263 if merge_resp.executed:
1264 1264 log.debug("The merge was successful, closing the pull request.")
1265 1265 PullRequestModel().close_pull_request(
1266 1266 pull_request.pull_request_id, user)
1267 1267 Session().commit()
1268 1268 msg = _('Pull request was successfully merged and closed.')
1269 1269 h.flash(msg, category='success')
1270 1270 else:
1271 1271 log.debug(
1272 1272 "The merge was not successful. Merge response: %s", merge_resp)
1273 1273 msg = merge_resp.merge_status_message
1274 1274 h.flash(msg, category='error')
1275 1275
1276 1276 def _update_reviewers(self, pull_request, review_members, reviewer_rules):
1277 1277 _ = self.request.translate
1278 1278
1279 1279 get_default_reviewers_data, validate_default_reviewers = \
1280 1280 PullRequestModel().get_reviewer_functions()
1281 1281
1282 1282 try:
1283 1283 reviewers = validate_default_reviewers(review_members, reviewer_rules)
1284 1284 except ValueError as e:
1285 1285 log.error('Reviewers Validation: {}'.format(e))
1286 1286 h.flash(e, category='error')
1287 1287 return
1288 1288
1289 1289 old_calculated_status = pull_request.calculated_review_status()
1290 1290 PullRequestModel().update_reviewers(
1291 1291 pull_request, reviewers, self._rhodecode_user)
1292 1292 h.flash(_('Pull request reviewers updated.'), category='success')
1293 1293 Session().commit()
1294 1294
1295 1295 # trigger status changed if change in reviewers changes the status
1296 1296 calculated_status = pull_request.calculated_review_status()
1297 1297 if old_calculated_status != calculated_status:
1298 1298 PullRequestModel().trigger_pull_request_hook(
1299 1299 pull_request, self._rhodecode_user, 'review_status_change',
1300 1300 data={'status': calculated_status})
1301 1301
1302 1302 @LoginRequired()
1303 1303 @NotAnonymous()
1304 1304 @HasRepoPermissionAnyDecorator(
1305 1305 'repository.read', 'repository.write', 'repository.admin')
1306 1306 @CSRFRequired()
1307 1307 @view_config(
1308 1308 route_name='pullrequest_delete', request_method='POST',
1309 1309 renderer='json_ext')
1310 1310 def pull_request_delete(self):
1311 1311 _ = self.request.translate
1312 1312
1313 1313 pull_request = PullRequest.get_or_404(
1314 1314 self.request.matchdict['pull_request_id'])
1315 1315 self.load_default_context()
1316 1316
1317 1317 pr_closed = pull_request.is_closed()
1318 1318 allowed_to_delete = PullRequestModel().check_user_delete(
1319 1319 pull_request, self._rhodecode_user) and not pr_closed
1320 1320
1321 1321 # only owner can delete it !
1322 1322 if allowed_to_delete:
1323 1323 PullRequestModel().delete(pull_request, self._rhodecode_user)
1324 1324 Session().commit()
1325 1325 h.flash(_('Successfully deleted pull request'),
1326 1326 category='success')
1327 1327 raise HTTPFound(h.route_path('pullrequest_show_all',
1328 1328 repo_name=self.db_repo_name))
1329 1329
1330 1330 log.warning('user %s tried to delete pull request without access',
1331 1331 self._rhodecode_user)
1332 1332 raise HTTPNotFound()
1333 1333
1334 1334 @LoginRequired()
1335 1335 @NotAnonymous()
1336 1336 @HasRepoPermissionAnyDecorator(
1337 1337 'repository.read', 'repository.write', 'repository.admin')
1338 1338 @CSRFRequired()
1339 1339 @view_config(
1340 1340 route_name='pullrequest_comment_create', request_method='POST',
1341 1341 renderer='json_ext')
1342 1342 def pull_request_comment_create(self):
1343 1343 _ = self.request.translate
1344 1344
1345 1345 pull_request = PullRequest.get_or_404(
1346 1346 self.request.matchdict['pull_request_id'])
1347 1347 pull_request_id = pull_request.pull_request_id
1348 1348
1349 1349 if pull_request.is_closed():
1350 1350 log.debug('comment: forbidden because pull request is closed')
1351 1351 raise HTTPForbidden()
1352 1352
1353 1353 allowed_to_comment = PullRequestModel().check_user_comment(
1354 1354 pull_request, self._rhodecode_user)
1355 1355 if not allowed_to_comment:
1356 1356 log.debug(
1357 1357 'comment: forbidden because pull request is from forbidden repo')
1358 1358 raise HTTPForbidden()
1359 1359
1360 1360 c = self.load_default_context()
1361 1361
1362 1362 status = self.request.POST.get('changeset_status', None)
1363 1363 text = self.request.POST.get('text')
1364 1364 comment_type = self.request.POST.get('comment_type')
1365 1365 resolves_comment_id = self.request.POST.get('resolves_comment_id', None)
1366 1366 close_pull_request = self.request.POST.get('close_pull_request')
1367 1367
1368 1368 # the logic here should work like following, if we submit close
1369 1369 # pr comment, use `close_pull_request_with_comment` function
1370 1370 # else handle regular comment logic
1371 1371
1372 1372 if close_pull_request:
1373 1373 # only owner or admin or person with write permissions
1374 1374 allowed_to_close = PullRequestModel().check_user_update(
1375 1375 pull_request, self._rhodecode_user)
1376 1376 if not allowed_to_close:
1377 1377 log.debug('comment: forbidden because not allowed to close '
1378 1378 'pull request %s', pull_request_id)
1379 1379 raise HTTPForbidden()
1380 1380
1381 1381 # This also triggers `review_status_change`
1382 1382 comment, status = PullRequestModel().close_pull_request_with_comment(
1383 1383 pull_request, self._rhodecode_user, self.db_repo, message=text,
1384 1384 auth_user=self._rhodecode_user)
1385 1385 Session().flush()
1386 1386
1387 1387 PullRequestModel().trigger_pull_request_hook(
1388 1388 pull_request, self._rhodecode_user, 'comment',
1389 1389 data={'comment': comment})
1390 1390
1391 1391 else:
1392 1392 # regular comment case, could be inline, or one with status.
1393 1393 # for that one we check also permissions
1394 1394
1395 1395 allowed_to_change_status = PullRequestModel().check_user_change_status(
1396 1396 pull_request, self._rhodecode_user)
1397 1397
1398 1398 if status and allowed_to_change_status:
1399 1399 message = (_('Status change %(transition_icon)s %(status)s')
1400 1400 % {'transition_icon': '>',
1401 1401 'status': ChangesetStatus.get_status_lbl(status)})
1402 1402 text = text or message
1403 1403
1404 1404 comment = CommentsModel().create(
1405 1405 text=text,
1406 1406 repo=self.db_repo.repo_id,
1407 1407 user=self._rhodecode_user.user_id,
1408 1408 pull_request=pull_request,
1409 1409 f_path=self.request.POST.get('f_path'),
1410 1410 line_no=self.request.POST.get('line'),
1411 1411 status_change=(ChangesetStatus.get_status_lbl(status)
1412 1412 if status and allowed_to_change_status else None),
1413 1413 status_change_type=(status
1414 1414 if status and allowed_to_change_status else None),
1415 1415 comment_type=comment_type,
1416 1416 resolves_comment_id=resolves_comment_id,
1417 1417 auth_user=self._rhodecode_user
1418 1418 )
1419 1419
1420 1420 if allowed_to_change_status:
1421 1421 # calculate old status before we change it
1422 1422 old_calculated_status = pull_request.calculated_review_status()
1423 1423
1424 1424 # get status if set !
1425 1425 if status:
1426 1426 ChangesetStatusModel().set_status(
1427 1427 self.db_repo.repo_id,
1428 1428 status,
1429 1429 self._rhodecode_user.user_id,
1430 1430 comment,
1431 1431 pull_request=pull_request
1432 1432 )
1433 1433
1434 1434 Session().flush()
1435 1435 # this is somehow required to get access to some relationship
1436 1436 # loaded on comment
1437 1437 Session().refresh(comment)
1438 1438
1439 1439 PullRequestModel().trigger_pull_request_hook(
1440 1440 pull_request, self._rhodecode_user, 'comment',
1441 1441 data={'comment': comment})
1442 1442
1443 1443 # we now calculate the status of pull request, and based on that
1444 1444 # calculation we set the commits status
1445 1445 calculated_status = pull_request.calculated_review_status()
1446 1446 if old_calculated_status != calculated_status:
1447 1447 PullRequestModel().trigger_pull_request_hook(
1448 1448 pull_request, self._rhodecode_user, 'review_status_change',
1449 1449 data={'status': calculated_status})
1450 1450
1451 1451 Session().commit()
1452 1452
1453 1453 data = {
1454 1454 'target_id': h.safeid(h.safe_unicode(
1455 1455 self.request.POST.get('f_path'))),
1456 1456 }
1457 1457 if comment:
1458 1458 c.co = comment
1459 1459 rendered_comment = render(
1460 1460 'rhodecode:templates/changeset/changeset_comment_block.mako',
1461 1461 self._get_template_context(c), self.request)
1462 1462
1463 1463 data.update(comment.get_dict())
1464 1464 data.update({'rendered_text': rendered_comment})
1465 1465
1466 1466 return data
1467 1467
1468 1468 @LoginRequired()
1469 1469 @NotAnonymous()
1470 1470 @HasRepoPermissionAnyDecorator(
1471 1471 'repository.read', 'repository.write', 'repository.admin')
1472 1472 @CSRFRequired()
1473 1473 @view_config(
1474 1474 route_name='pullrequest_comment_delete', request_method='POST',
1475 1475 renderer='json_ext')
1476 1476 def pull_request_comment_delete(self):
1477 1477 pull_request = PullRequest.get_or_404(
1478 1478 self.request.matchdict['pull_request_id'])
1479 1479
1480 1480 comment = ChangesetComment.get_or_404(
1481 1481 self.request.matchdict['comment_id'])
1482 1482 comment_id = comment.comment_id
1483 1483
1484 1484 if comment.immutable:
1485 1485 # don't allow deleting comments that are immutable
1486 1486 raise HTTPForbidden()
1487 1487
1488 1488 if pull_request.is_closed():
1489 1489 log.debug('comment: forbidden because pull request is closed')
1490 1490 raise HTTPForbidden()
1491 1491
1492 1492 if not comment:
1493 1493 log.debug('Comment with id:%s not found, skipping', comment_id)
1494 1494 # comment already deleted in another call probably
1495 1495 return True
1496 1496
1497 1497 if comment.pull_request.is_closed():
1498 1498 # don't allow deleting comments on closed pull request
1499 1499 raise HTTPForbidden()
1500 1500
1501 1501 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
1502 1502 super_admin = h.HasPermissionAny('hg.admin')()
1503 1503 comment_owner = comment.author.user_id == self._rhodecode_user.user_id
1504 1504 is_repo_comment = comment.repo.repo_name == self.db_repo_name
1505 1505 comment_repo_admin = is_repo_admin and is_repo_comment
1506 1506
1507 1507 if super_admin or comment_owner or comment_repo_admin:
1508 1508 old_calculated_status = comment.pull_request.calculated_review_status()
1509 1509 CommentsModel().delete(comment=comment, auth_user=self._rhodecode_user)
1510 1510 Session().commit()
1511 1511 calculated_status = comment.pull_request.calculated_review_status()
1512 1512 if old_calculated_status != calculated_status:
1513 1513 PullRequestModel().trigger_pull_request_hook(
1514 1514 comment.pull_request, self._rhodecode_user, 'review_status_change',
1515 1515 data={'status': calculated_status})
1516 1516 return True
1517 1517 else:
1518 1518 log.warning('No permissions for user %s to delete comment_id: %s',
1519 1519 self._rhodecode_db_user, comment_id)
1520 1520 raise HTTPNotFound()
1521
1522 @LoginRequired()
1523 @NotAnonymous()
1524 @HasRepoPermissionAnyDecorator(
1525 'repository.read', 'repository.write', 'repository.admin')
1526 @CSRFRequired()
1527 @view_config(
1528 route_name='pullrequest_comment_edit', request_method='POST',
1529 renderer='json_ext')
1530 def pull_request_comment_edit(self):
1531 pull_request = PullRequest.get_or_404(
1532 self.request.matchdict['pull_request_id']
1533 )
1534 comment = ChangesetComment.get_or_404(
1535 self.request.matchdict['comment_id']
1536 )
1537 comment_id = comment.comment_id
1538
1539 if comment.immutable:
1540 # don't allow deleting comments that are immutable
1541 raise HTTPForbidden()
1542
1543 if pull_request.is_closed():
1544 log.debug('comment: forbidden because pull request is closed')
1545 raise HTTPForbidden()
1546
1547 if not comment:
1548 log.debug('Comment with id:%s not found, skipping', comment_id)
1549 # comment already deleted in another call probably
1550 return True
1551
1552 if comment.pull_request.is_closed():
1553 # don't allow deleting comments on closed pull request
1554 raise HTTPForbidden()
1555
1556 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
1557 super_admin = h.HasPermissionAny('hg.admin')()
1558 comment_owner = comment.author.user_id == self._rhodecode_user.user_id
1559 is_repo_comment = comment.repo.repo_name == self.db_repo_name
1560 comment_repo_admin = is_repo_admin and is_repo_comment
1561
1562 if super_admin or comment_owner or comment_repo_admin:
1563 text = self.request.POST.get('text')
1564 version = self.request.POST.get('version')
1565 if text == comment.text:
1566 log.warning(
1567 'Comment(PR): '
1568 'Trying to create new version '
1569 'of existing comment {}'.format(
1570 comment_id,
1571 )
1572 )
1573 raise HTTPNotFound()
1574 if version.isdigit():
1575 version = int(version)
1576 else:
1577 log.warning(
1578 'Comment(PR): Wrong version type {} {} '
1579 'for comment {}'.format(
1580 version,
1581 type(version),
1582 comment_id,
1583 )
1584 )
1585 raise HTTPNotFound()
1586
1587 comment_history = CommentsModel().edit(
1588 comment_id=comment_id,
1589 text=text,
1590 auth_user=self._rhodecode_user,
1591 version=version,
1592 )
1593 if not comment_history:
1594 raise HTTPNotFound()
1595 Session().commit()
1596 return {
1597 'comment_history_id': comment_history.comment_history_id,
1598 'comment_id': comment.comment_id,
1599 'comment_version': comment_history.version,
1600 }
1601 else:
1602 log.warning(
1603 'No permissions for user {} to edit comment_id: {}'.format(
1604 self._rhodecode_db_user, comment_id
1605 )
1606 )
1607 raise HTTPNotFound()
@@ -1,293 +1,295 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2017-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import logging
22 22 import datetime
23 23
24 24 from rhodecode.lib.jsonalchemy import JsonRaw
25 25 from rhodecode.model import meta
26 26 from rhodecode.model.db import User, UserLog, Repository
27 27
28 28
29 29 log = logging.getLogger(__name__)
30 30
31 31 # action as key, and expected action_data as value
32 32 ACTIONS_V1 = {
33 33 'user.login.success': {'user_agent': ''},
34 34 'user.login.failure': {'user_agent': ''},
35 35 'user.logout': {'user_agent': ''},
36 36 'user.register': {},
37 37 'user.password.reset_request': {},
38 38 'user.push': {'user_agent': '', 'commit_ids': []},
39 39 'user.pull': {'user_agent': ''},
40 40
41 41 'user.create': {'data': {}},
42 42 'user.delete': {'old_data': {}},
43 43 'user.edit': {'old_data': {}},
44 44 'user.edit.permissions': {},
45 45 'user.edit.ip.add': {'ip': {}, 'user': {}},
46 46 'user.edit.ip.delete': {'ip': {}, 'user': {}},
47 47 'user.edit.token.add': {'token': {}, 'user': {}},
48 48 'user.edit.token.delete': {'token': {}, 'user': {}},
49 49 'user.edit.email.add': {'email': ''},
50 50 'user.edit.email.delete': {'email': ''},
51 51 'user.edit.ssh_key.add': {'token': {}, 'user': {}},
52 52 'user.edit.ssh_key.delete': {'token': {}, 'user': {}},
53 53 'user.edit.password_reset.enabled': {},
54 54 'user.edit.password_reset.disabled': {},
55 55
56 56 'user_group.create': {'data': {}},
57 57 'user_group.delete': {'old_data': {}},
58 58 'user_group.edit': {'old_data': {}},
59 59 'user_group.edit.permissions': {},
60 60 'user_group.edit.member.add': {'user': {}},
61 61 'user_group.edit.member.delete': {'user': {}},
62 62
63 63 'repo.create': {'data': {}},
64 64 'repo.fork': {'data': {}},
65 65 'repo.edit': {'old_data': {}},
66 66 'repo.edit.permissions': {},
67 67 'repo.edit.permissions.branch': {},
68 68 'repo.archive': {'old_data': {}},
69 69 'repo.delete': {'old_data': {}},
70 70
71 71 'repo.archive.download': {'user_agent': '', 'archive_name': '',
72 72 'archive_spec': '', 'archive_cached': ''},
73 73
74 74 'repo.permissions.branch_rule.create': {},
75 75 'repo.permissions.branch_rule.edit': {},
76 76 'repo.permissions.branch_rule.delete': {},
77 77
78 78 'repo.pull_request.create': '',
79 79 'repo.pull_request.edit': '',
80 80 'repo.pull_request.delete': '',
81 81 'repo.pull_request.close': '',
82 82 'repo.pull_request.merge': '',
83 83 'repo.pull_request.vote': '',
84 84 'repo.pull_request.comment.create': '',
85 'repo.pull_request.comment.edit': '',
85 86 'repo.pull_request.comment.delete': '',
86 87
87 88 'repo.pull_request.reviewer.add': '',
88 89 'repo.pull_request.reviewer.delete': '',
89 90
90 91 'repo.commit.strip': {'commit_id': ''},
91 92 'repo.commit.comment.create': {'data': {}},
92 93 'repo.commit.comment.delete': {'data': {}},
94 'repo.commit.comment.edit': {'data': {}},
93 95 'repo.commit.vote': '',
94 96
95 97 'repo.artifact.add': '',
96 98 'repo.artifact.delete': '',
97 99
98 100 'repo_group.create': {'data': {}},
99 101 'repo_group.edit': {'old_data': {}},
100 102 'repo_group.edit.permissions': {},
101 103 'repo_group.delete': {'old_data': {}},
102 104 }
103 105
104 106 ACTIONS = ACTIONS_V1
105 107
106 108 SOURCE_WEB = 'source_web'
107 109 SOURCE_API = 'source_api'
108 110
109 111
110 112 class UserWrap(object):
111 113 """
112 114 Fake object used to imitate AuthUser
113 115 """
114 116
115 117 def __init__(self, user_id=None, username=None, ip_addr=None):
116 118 self.user_id = user_id
117 119 self.username = username
118 120 self.ip_addr = ip_addr
119 121
120 122
121 123 class RepoWrap(object):
122 124 """
123 125 Fake object used to imitate RepoObject that audit logger requires
124 126 """
125 127
126 128 def __init__(self, repo_id=None, repo_name=None):
127 129 self.repo_id = repo_id
128 130 self.repo_name = repo_name
129 131
130 132
131 133 def _store_log(action_name, action_data, user_id, username, user_data,
132 134 ip_address, repository_id, repository_name):
133 135 user_log = UserLog()
134 136 user_log.version = UserLog.VERSION_2
135 137
136 138 user_log.action = action_name
137 139 user_log.action_data = action_data or JsonRaw(u'{}')
138 140
139 141 user_log.user_ip = ip_address
140 142
141 143 user_log.user_id = user_id
142 144 user_log.username = username
143 145 user_log.user_data = user_data or JsonRaw(u'{}')
144 146
145 147 user_log.repository_id = repository_id
146 148 user_log.repository_name = repository_name
147 149
148 150 user_log.action_date = datetime.datetime.now()
149 151
150 152 return user_log
151 153
152 154
153 155 def store_web(*args, **kwargs):
154 156 action_data = {}
155 157 org_action_data = kwargs.pop('action_data', {})
156 158 action_data.update(org_action_data)
157 159 action_data['source'] = SOURCE_WEB
158 160 kwargs['action_data'] = action_data
159 161
160 162 return store(*args, **kwargs)
161 163
162 164
163 165 def store_api(*args, **kwargs):
164 166 action_data = {}
165 167 org_action_data = kwargs.pop('action_data', {})
166 168 action_data.update(org_action_data)
167 169 action_data['source'] = SOURCE_API
168 170 kwargs['action_data'] = action_data
169 171
170 172 return store(*args, **kwargs)
171 173
172 174
173 175 def store(action, user, action_data=None, user_data=None, ip_addr=None,
174 176 repo=None, sa_session=None, commit=False):
175 177 """
176 178 Audit logger for various actions made by users, typically this
177 179 results in a call such::
178 180
179 181 from rhodecode.lib import audit_logger
180 182
181 183 audit_logger.store(
182 184 'repo.edit', user=self._rhodecode_user)
183 185 audit_logger.store(
184 186 'repo.delete', action_data={'data': repo_data},
185 187 user=audit_logger.UserWrap(username='itried-login', ip_addr='8.8.8.8'))
186 188
187 189 # repo action
188 190 audit_logger.store(
189 191 'repo.delete',
190 192 user=audit_logger.UserWrap(username='itried-login', ip_addr='8.8.8.8'),
191 193 repo=audit_logger.RepoWrap(repo_name='some-repo'))
192 194
193 195 # repo action, when we know and have the repository object already
194 196 audit_logger.store(
195 197 'repo.delete', action_data={'source': audit_logger.SOURCE_WEB, },
196 198 user=self._rhodecode_user,
197 199 repo=repo_object)
198 200
199 201 # alternative wrapper to the above
200 202 audit_logger.store_web(
201 203 'repo.delete', action_data={},
202 204 user=self._rhodecode_user,
203 205 repo=repo_object)
204 206
205 207 # without an user ?
206 208 audit_logger.store(
207 209 'user.login.failure',
208 210 user=audit_logger.UserWrap(
209 211 username=self.request.params.get('username'),
210 212 ip_addr=self.request.remote_addr))
211 213
212 214 """
213 215 from rhodecode.lib.utils2 import safe_unicode
214 216 from rhodecode.lib.auth import AuthUser
215 217
216 218 action_spec = ACTIONS.get(action, None)
217 219 if action_spec is None:
218 220 raise ValueError('Action `{}` is not supported'.format(action))
219 221
220 222 if not sa_session:
221 223 sa_session = meta.Session()
222 224
223 225 try:
224 226 username = getattr(user, 'username', None)
225 227 if not username:
226 228 pass
227 229
228 230 user_id = getattr(user, 'user_id', None)
229 231 if not user_id:
230 232 # maybe we have username ? Try to figure user_id from username
231 233 if username:
232 234 user_id = getattr(
233 235 User.get_by_username(username), 'user_id', None)
234 236
235 237 ip_addr = ip_addr or getattr(user, 'ip_addr', None)
236 238 if not ip_addr:
237 239 pass
238 240
239 241 if not user_data:
240 242 # try to get this from the auth user
241 243 if isinstance(user, AuthUser):
242 244 user_data = {
243 245 'username': user.username,
244 246 'email': user.email,
245 247 }
246 248
247 249 repository_name = getattr(repo, 'repo_name', None)
248 250 repository_id = getattr(repo, 'repo_id', None)
249 251 if not repository_id:
250 252 # maybe we have repo_name ? Try to figure repo_id from repo_name
251 253 if repository_name:
252 254 repository_id = getattr(
253 255 Repository.get_by_repo_name(repository_name), 'repo_id', None)
254 256
255 257 action_name = safe_unicode(action)
256 258 ip_address = safe_unicode(ip_addr)
257 259
258 260 with sa_session.no_autoflush:
259 261 update_user_last_activity(sa_session, user_id)
260 262
261 263 user_log = _store_log(
262 264 action_name=action_name,
263 265 action_data=action_data or {},
264 266 user_id=user_id,
265 267 username=username,
266 268 user_data=user_data or {},
267 269 ip_address=ip_address,
268 270 repository_id=repository_id,
269 271 repository_name=repository_name
270 272 )
271 273
272 274 sa_session.add(user_log)
273 275
274 276 if commit:
275 277 sa_session.commit()
276 278
277 279 entry_id = user_log.entry_id or ''
278 280 log.info('AUDIT[%s]: Logging action: `%s` by user:id:%s[%s] ip:%s',
279 281 entry_id, action_name, user_id, username, ip_address)
280 282
281 283 except Exception:
282 284 log.exception('AUDIT: failed to store audit log')
283 285
284 286
285 287 def update_user_last_activity(sa_session, user_id):
286 288 _last_activity = datetime.datetime.now()
287 289 try:
288 290 sa_session.query(User).filter(User.user_id == user_id).update(
289 291 {"last_activity": _last_activity})
290 292 log.debug(
291 293 'updated user `%s` last activity to:%s', user_id, _last_activity)
292 294 except Exception:
293 295 log.exception("Failed last activity update")
@@ -1,774 +1,829 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 comments model for RhodeCode
23 23 """
24 24
25 25 import logging
26 26 import traceback
27 27 import collections
28 28
29 29 from pyramid.threadlocal import get_current_registry, get_current_request
30 30 from sqlalchemy.sql.expression import null
31 31 from sqlalchemy.sql.functions import coalesce
32 32
33 33 from rhodecode.lib import helpers as h, diffs, channelstream, hooks_utils
34 34 from rhodecode.lib import audit_logger
35 35 from rhodecode.lib.utils2 import extract_mentioned_users, safe_str
36 36 from rhodecode.model import BaseModel
37 37 from rhodecode.model.db import (
38 ChangesetComment, User, Notification, PullRequest, AttributeDict)
38 ChangesetComment,
39 User,
40 Notification,
41 PullRequest,
42 AttributeDict,
43 ChangesetCommentHistory,
44 )
39 45 from rhodecode.model.notification import NotificationModel
40 46 from rhodecode.model.meta import Session
41 47 from rhodecode.model.settings import VcsSettingsModel
42 48 from rhodecode.model.notification import EmailNotificationModel
43 49 from rhodecode.model.validation_schema.schemas import comment_schema
44 50
45 51
46 52 log = logging.getLogger(__name__)
47 53
48 54
49 55 class CommentsModel(BaseModel):
50 56
51 57 cls = ChangesetComment
52 58
53 59 DIFF_CONTEXT_BEFORE = 3
54 60 DIFF_CONTEXT_AFTER = 3
55 61
56 62 def __get_commit_comment(self, changeset_comment):
57 63 return self._get_instance(ChangesetComment, changeset_comment)
58 64
59 65 def __get_pull_request(self, pull_request):
60 66 return self._get_instance(PullRequest, pull_request)
61 67
62 68 def _extract_mentions(self, s):
63 69 user_objects = []
64 70 for username in extract_mentioned_users(s):
65 71 user_obj = User.get_by_username(username, case_insensitive=True)
66 72 if user_obj:
67 73 user_objects.append(user_obj)
68 74 return user_objects
69 75
70 76 def _get_renderer(self, global_renderer='rst', request=None):
71 77 request = request or get_current_request()
72 78
73 79 try:
74 80 global_renderer = request.call_context.visual.default_renderer
75 81 except AttributeError:
76 82 log.debug("Renderer not set, falling back "
77 83 "to default renderer '%s'", global_renderer)
78 84 except Exception:
79 85 log.error(traceback.format_exc())
80 86 return global_renderer
81 87
82 88 def aggregate_comments(self, comments, versions, show_version, inline=False):
83 89 # group by versions, and count until, and display objects
84 90
85 91 comment_groups = collections.defaultdict(list)
86 92 [comment_groups[
87 93 _co.pull_request_version_id].append(_co) for _co in comments]
88 94
89 95 def yield_comments(pos):
90 96 for co in comment_groups[pos]:
91 97 yield co
92 98
93 99 comment_versions = collections.defaultdict(
94 100 lambda: collections.defaultdict(list))
95 101 prev_prvid = -1
96 102 # fake last entry with None, to aggregate on "latest" version which
97 103 # doesn't have an pull_request_version_id
98 104 for ver in versions + [AttributeDict({'pull_request_version_id': None})]:
99 105 prvid = ver.pull_request_version_id
100 106 if prev_prvid == -1:
101 107 prev_prvid = prvid
102 108
103 109 for co in yield_comments(prvid):
104 110 comment_versions[prvid]['at'].append(co)
105 111
106 112 # save until
107 113 current = comment_versions[prvid]['at']
108 114 prev_until = comment_versions[prev_prvid]['until']
109 115 cur_until = prev_until + current
110 116 comment_versions[prvid]['until'].extend(cur_until)
111 117
112 118 # save outdated
113 119 if inline:
114 120 outdated = [x for x in cur_until
115 121 if x.outdated_at_version(show_version)]
116 122 else:
117 123 outdated = [x for x in cur_until
118 124 if x.older_than_version(show_version)]
119 125 display = [x for x in cur_until if x not in outdated]
120 126
121 127 comment_versions[prvid]['outdated'] = outdated
122 128 comment_versions[prvid]['display'] = display
123 129
124 130 prev_prvid = prvid
125 131
126 132 return comment_versions
127 133
128 134 def get_repository_comments(self, repo, comment_type=None, user=None, commit_id=None):
129 135 qry = Session().query(ChangesetComment) \
130 136 .filter(ChangesetComment.repo == repo)
131 137
132 138 if comment_type and comment_type in ChangesetComment.COMMENT_TYPES:
133 139 qry = qry.filter(ChangesetComment.comment_type == comment_type)
134 140
135 141 if user:
136 142 user = self._get_user(user)
137 143 if user:
138 144 qry = qry.filter(ChangesetComment.user_id == user.user_id)
139 145
140 146 if commit_id:
141 147 qry = qry.filter(ChangesetComment.revision == commit_id)
142 148
143 149 qry = qry.order_by(ChangesetComment.created_on)
144 150 return qry.all()
145 151
146 152 def get_repository_unresolved_todos(self, repo):
147 153 todos = Session().query(ChangesetComment) \
148 154 .filter(ChangesetComment.repo == repo) \
149 155 .filter(ChangesetComment.resolved_by == None) \
150 156 .filter(ChangesetComment.comment_type
151 157 == ChangesetComment.COMMENT_TYPE_TODO)
152 158 todos = todos.all()
153 159
154 160 return todos
155 161
156 162 def get_pull_request_unresolved_todos(self, pull_request, show_outdated=True):
157 163
158 164 todos = Session().query(ChangesetComment) \
159 165 .filter(ChangesetComment.pull_request == pull_request) \
160 166 .filter(ChangesetComment.resolved_by == None) \
161 167 .filter(ChangesetComment.comment_type
162 168 == ChangesetComment.COMMENT_TYPE_TODO)
163 169
164 170 if not show_outdated:
165 171 todos = todos.filter(
166 172 coalesce(ChangesetComment.display_state, '') !=
167 173 ChangesetComment.COMMENT_OUTDATED)
168 174
169 175 todos = todos.all()
170 176
171 177 return todos
172 178
173 179 def get_pull_request_resolved_todos(self, pull_request, show_outdated=True):
174 180
175 181 todos = Session().query(ChangesetComment) \
176 182 .filter(ChangesetComment.pull_request == pull_request) \
177 183 .filter(ChangesetComment.resolved_by != None) \
178 184 .filter(ChangesetComment.comment_type
179 185 == ChangesetComment.COMMENT_TYPE_TODO)
180 186
181 187 if not show_outdated:
182 188 todos = todos.filter(
183 189 coalesce(ChangesetComment.display_state, '') !=
184 190 ChangesetComment.COMMENT_OUTDATED)
185 191
186 192 todos = todos.all()
187 193
188 194 return todos
189 195
190 196 def get_commit_unresolved_todos(self, commit_id, show_outdated=True):
191 197
192 198 todos = Session().query(ChangesetComment) \
193 199 .filter(ChangesetComment.revision == commit_id) \
194 200 .filter(ChangesetComment.resolved_by == None) \
195 201 .filter(ChangesetComment.comment_type
196 202 == ChangesetComment.COMMENT_TYPE_TODO)
197 203
198 204 if not show_outdated:
199 205 todos = todos.filter(
200 206 coalesce(ChangesetComment.display_state, '') !=
201 207 ChangesetComment.COMMENT_OUTDATED)
202 208
203 209 todos = todos.all()
204 210
205 211 return todos
206 212
207 213 def get_commit_resolved_todos(self, commit_id, show_outdated=True):
208 214
209 215 todos = Session().query(ChangesetComment) \
210 216 .filter(ChangesetComment.revision == commit_id) \
211 217 .filter(ChangesetComment.resolved_by != None) \
212 218 .filter(ChangesetComment.comment_type
213 219 == ChangesetComment.COMMENT_TYPE_TODO)
214 220
215 221 if not show_outdated:
216 222 todos = todos.filter(
217 223 coalesce(ChangesetComment.display_state, '') !=
218 224 ChangesetComment.COMMENT_OUTDATED)
219 225
220 226 todos = todos.all()
221 227
222 228 return todos
223 229
224 230 def _log_audit_action(self, action, action_data, auth_user, comment):
225 231 audit_logger.store(
226 232 action=action,
227 233 action_data=action_data,
228 234 user=auth_user,
229 235 repo=comment.repo)
230 236
231 237 def create(self, text, repo, user, commit_id=None, pull_request=None,
232 238 f_path=None, line_no=None, status_change=None,
233 239 status_change_type=None, comment_type=None,
234 240 resolves_comment_id=None, closing_pr=False, send_email=True,
235 241 renderer=None, auth_user=None, extra_recipients=None):
236 242 """
237 243 Creates new comment for commit or pull request.
238 244 IF status_change is not none this comment is associated with a
239 245 status change of commit or commit associated with pull request
240 246
241 247 :param text:
242 248 :param repo:
243 249 :param user:
244 250 :param commit_id:
245 251 :param pull_request:
246 252 :param f_path:
247 253 :param line_no:
248 254 :param status_change: Label for status change
249 255 :param comment_type: Type of comment
250 256 :param resolves_comment_id: id of comment which this one will resolve
251 257 :param status_change_type: type of status change
252 258 :param closing_pr:
253 259 :param send_email:
254 260 :param renderer: pick renderer for this comment
255 261 :param auth_user: current authenticated user calling this method
256 262 :param extra_recipients: list of extra users to be added to recipients
257 263 """
258 264
259 265 if not text:
260 266 log.warning('Missing text for comment, skipping...')
261 267 return
262 268 request = get_current_request()
263 269 _ = request.translate
264 270
265 271 if not renderer:
266 272 renderer = self._get_renderer(request=request)
267 273
268 274 repo = self._get_repo(repo)
269 275 user = self._get_user(user)
270 276 auth_user = auth_user or user
271 277
272 278 schema = comment_schema.CommentSchema()
273 279 validated_kwargs = schema.deserialize(dict(
274 280 comment_body=text,
275 281 comment_type=comment_type,
276 282 comment_file=f_path,
277 283 comment_line=line_no,
278 284 renderer_type=renderer,
279 285 status_change=status_change_type,
280 286 resolves_comment_id=resolves_comment_id,
281 287 repo=repo.repo_id,
282 288 user=user.user_id,
283 289 ))
284 290
285 291 comment = ChangesetComment()
286 292 comment.renderer = validated_kwargs['renderer_type']
287 293 comment.text = validated_kwargs['comment_body']
288 294 comment.f_path = validated_kwargs['comment_file']
289 295 comment.line_no = validated_kwargs['comment_line']
290 296 comment.comment_type = validated_kwargs['comment_type']
291 297
292 298 comment.repo = repo
293 299 comment.author = user
294 300 resolved_comment = self.__get_commit_comment(
295 301 validated_kwargs['resolves_comment_id'])
296 302 # check if the comment actually belongs to this PR
297 303 if resolved_comment and resolved_comment.pull_request and \
298 304 resolved_comment.pull_request != pull_request:
299 305 log.warning('Comment tried to resolved unrelated todo comment: %s',
300 306 resolved_comment)
301 307 # comment not bound to this pull request, forbid
302 308 resolved_comment = None
303 309
304 310 elif resolved_comment and resolved_comment.repo and \
305 311 resolved_comment.repo != repo:
306 312 log.warning('Comment tried to resolved unrelated todo comment: %s',
307 313 resolved_comment)
308 314 # comment not bound to this repo, forbid
309 315 resolved_comment = None
310 316
311 317 comment.resolved_comment = resolved_comment
312 318
313 319 pull_request_id = pull_request
314 320
315 321 commit_obj = None
316 322 pull_request_obj = None
317 323
318 324 if commit_id:
319 325 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
320 326 # do a lookup, so we don't pass something bad here
321 327 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
322 328 comment.revision = commit_obj.raw_id
323 329
324 330 elif pull_request_id:
325 331 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
326 332 pull_request_obj = self.__get_pull_request(pull_request_id)
327 333 comment.pull_request = pull_request_obj
328 334 else:
329 335 raise Exception('Please specify commit or pull_request_id')
330 336
331 337 Session().add(comment)
332 338 Session().flush()
333 339 kwargs = {
334 340 'user': user,
335 341 'renderer_type': renderer,
336 342 'repo_name': repo.repo_name,
337 343 'status_change': status_change,
338 344 'status_change_type': status_change_type,
339 345 'comment_body': text,
340 346 'comment_file': f_path,
341 347 'comment_line': line_no,
342 348 'comment_type': comment_type or 'note',
343 349 'comment_id': comment.comment_id
344 350 }
345 351
346 352 if commit_obj:
347 353 recipients = ChangesetComment.get_users(
348 354 revision=commit_obj.raw_id)
349 355 # add commit author if it's in RhodeCode system
350 356 cs_author = User.get_from_cs_author(commit_obj.author)
351 357 if not cs_author:
352 358 # use repo owner if we cannot extract the author correctly
353 359 cs_author = repo.user
354 360 recipients += [cs_author]
355 361
356 362 commit_comment_url = self.get_url(comment, request=request)
357 363 commit_comment_reply_url = self.get_url(
358 364 comment, request=request,
359 365 anchor='comment-{}/?/ReplyToComment'.format(comment.comment_id))
360 366
361 367 target_repo_url = h.link_to(
362 368 repo.repo_name,
363 369 h.route_url('repo_summary', repo_name=repo.repo_name))
364 370
365 371 # commit specifics
366 372 kwargs.update({
367 373 'commit': commit_obj,
368 374 'commit_message': commit_obj.message,
369 375 'commit_target_repo_url': target_repo_url,
370 376 'commit_comment_url': commit_comment_url,
371 377 'commit_comment_reply_url': commit_comment_reply_url
372 378 })
373 379
374 380 elif pull_request_obj:
375 381 # get the current participants of this pull request
376 382 recipients = ChangesetComment.get_users(
377 383 pull_request_id=pull_request_obj.pull_request_id)
378 384 # add pull request author
379 385 recipients += [pull_request_obj.author]
380 386
381 387 # add the reviewers to notification
382 388 recipients += [x.user for x in pull_request_obj.reviewers]
383 389
384 390 pr_target_repo = pull_request_obj.target_repo
385 391 pr_source_repo = pull_request_obj.source_repo
386 392
387 393 pr_comment_url = self.get_url(comment, request=request)
388 394 pr_comment_reply_url = self.get_url(
389 395 comment, request=request,
390 396 anchor='comment-{}/?/ReplyToComment'.format(comment.comment_id))
391 397
392 398 pr_url = h.route_url(
393 399 'pullrequest_show',
394 400 repo_name=pr_target_repo.repo_name,
395 401 pull_request_id=pull_request_obj.pull_request_id, )
396 402
397 403 # set some variables for email notification
398 404 pr_target_repo_url = h.route_url(
399 405 'repo_summary', repo_name=pr_target_repo.repo_name)
400 406
401 407 pr_source_repo_url = h.route_url(
402 408 'repo_summary', repo_name=pr_source_repo.repo_name)
403 409
404 410 # pull request specifics
405 411 kwargs.update({
406 412 'pull_request': pull_request_obj,
407 413 'pr_id': pull_request_obj.pull_request_id,
408 414 'pull_request_url': pr_url,
409 415 'pull_request_target_repo': pr_target_repo,
410 416 'pull_request_target_repo_url': pr_target_repo_url,
411 417 'pull_request_source_repo': pr_source_repo,
412 418 'pull_request_source_repo_url': pr_source_repo_url,
413 419 'pr_comment_url': pr_comment_url,
414 420 'pr_comment_reply_url': pr_comment_reply_url,
415 421 'pr_closing': closing_pr,
416 422 })
417 423
418 424 recipients += [self._get_user(u) for u in (extra_recipients or [])]
419 425
420 426 if send_email:
421 427 # pre-generate the subject for notification itself
422 428 (subject,
423 429 _h, _e, # we don't care about those
424 430 body_plaintext) = EmailNotificationModel().render_email(
425 431 notification_type, **kwargs)
426 432
427 433 mention_recipients = set(
428 434 self._extract_mentions(text)).difference(recipients)
429 435
430 436 # create notification objects, and emails
431 437 NotificationModel().create(
432 438 created_by=user,
433 439 notification_subject=subject,
434 440 notification_body=body_plaintext,
435 441 notification_type=notification_type,
436 442 recipients=recipients,
437 443 mention_recipients=mention_recipients,
438 444 email_kwargs=kwargs,
439 445 )
440 446
441 447 Session().flush()
442 448 if comment.pull_request:
443 449 action = 'repo.pull_request.comment.create'
444 450 else:
445 451 action = 'repo.commit.comment.create'
446 452
447 453 comment_data = comment.get_api_data()
448 454 self._log_audit_action(
449 455 action, {'data': comment_data}, auth_user, comment)
450 456
451 457 msg_url = ''
452 458 channel = None
453 459 if commit_obj:
454 460 msg_url = commit_comment_url
455 461 repo_name = repo.repo_name
456 462 channel = u'/repo${}$/commit/{}'.format(
457 463 repo_name,
458 464 commit_obj.raw_id
459 465 )
460 466 elif pull_request_obj:
461 467 msg_url = pr_comment_url
462 468 repo_name = pr_target_repo.repo_name
463 469 channel = u'/repo${}$/pr/{}'.format(
464 470 repo_name,
465 471 pull_request_id
466 472 )
467 473
468 474 message = '<strong>{}</strong> {} - ' \
469 475 '<a onclick="window.location=\'{}\';' \
470 476 'window.location.reload()">' \
471 477 '<strong>{}</strong></a>'
472 478 message = message.format(
473 479 user.username, _('made a comment'), msg_url,
474 480 _('Show it now'))
475 481
476 482 channelstream.post_message(
477 483 channel, message, user.username,
478 484 registry=get_current_registry())
479 485
480 486 return comment
481 487
488 def edit(self, comment_id, text, auth_user, version):
489 """
490 Change existing comment for commit or pull request.
491
492 :param comment_id:
493 :param text:
494 :param auth_user: current authenticated user calling this method
495 :param version: last comment version
496 """
497 if not text:
498 log.warning('Missing text for comment, skipping...')
499 return
500
501 comment = ChangesetComment.get(comment_id)
502 old_comment_text = comment.text
503 comment.text = text
504 comment_version = ChangesetCommentHistory.get_version(comment_id)
505 if (comment_version - version) != 1:
506 log.warning(
507 'Version mismatch, skipping... '
508 'version {} but should be {}'.format(
509 (version - 1),
510 comment_version,
511 )
512 )
513 return
514 comment_history = ChangesetCommentHistory()
515 comment_history.comment_id = comment_id
516 comment_history.version = comment_version
517 comment_history.created_by_user_id = auth_user.user_id
518 comment_history.text = old_comment_text
519 # TODO add email notification
520 Session().add(comment_history)
521 Session().add(comment)
522 Session().flush()
523
524 if comment.pull_request:
525 action = 'repo.pull_request.comment.edit'
526 else:
527 action = 'repo.commit.comment.edit'
528
529 comment_data = comment.get_api_data()
530 comment_data['old_comment_text'] = old_comment_text
531 self._log_audit_action(
532 action, {'data': comment_data}, auth_user, comment)
533
534 return comment_history
535
482 536 def delete(self, comment, auth_user):
483 537 """
484 538 Deletes given comment
485 539 """
486 540 comment = self.__get_commit_comment(comment)
487 541 old_data = comment.get_api_data()
488 542 Session().delete(comment)
489 543
490 544 if comment.pull_request:
491 545 action = 'repo.pull_request.comment.delete'
492 546 else:
493 547 action = 'repo.commit.comment.delete'
494 548
495 549 self._log_audit_action(
496 550 action, {'old_data': old_data}, auth_user, comment)
497 551
498 552 return comment
499 553
500 554 def get_all_comments(self, repo_id, revision=None, pull_request=None):
501 555 q = ChangesetComment.query()\
502 556 .filter(ChangesetComment.repo_id == repo_id)
503 557 if revision:
504 558 q = q.filter(ChangesetComment.revision == revision)
505 559 elif pull_request:
506 560 pull_request = self.__get_pull_request(pull_request)
507 561 q = q.filter(ChangesetComment.pull_request == pull_request)
508 562 else:
509 563 raise Exception('Please specify commit or pull_request')
510 564 q = q.order_by(ChangesetComment.created_on)
511 565 return q.all()
512 566
513 567 def get_url(self, comment, request=None, permalink=False, anchor=None):
514 568 if not request:
515 569 request = get_current_request()
516 570
517 571 comment = self.__get_commit_comment(comment)
518 572 if anchor is None:
519 573 anchor = 'comment-{}'.format(comment.comment_id)
520 574
521 575 if comment.pull_request:
522 576 pull_request = comment.pull_request
523 577 if permalink:
524 578 return request.route_url(
525 579 'pull_requests_global',
526 580 pull_request_id=pull_request.pull_request_id,
527 581 _anchor=anchor)
528 582 else:
529 583 return request.route_url(
530 584 'pullrequest_show',
531 585 repo_name=safe_str(pull_request.target_repo.repo_name),
532 586 pull_request_id=pull_request.pull_request_id,
533 587 _anchor=anchor)
534 588
535 589 else:
536 590 repo = comment.repo
537 591 commit_id = comment.revision
538 592
539 593 if permalink:
540 594 return request.route_url(
541 595 'repo_commit', repo_name=safe_str(repo.repo_id),
542 596 commit_id=commit_id,
543 597 _anchor=anchor)
544 598
545 599 else:
546 600 return request.route_url(
547 601 'repo_commit', repo_name=safe_str(repo.repo_name),
548 602 commit_id=commit_id,
549 603 _anchor=anchor)
550 604
551 605 def get_comments(self, repo_id, revision=None, pull_request=None):
552 606 """
553 607 Gets main comments based on revision or pull_request_id
554 608
555 609 :param repo_id:
556 610 :param revision:
557 611 :param pull_request:
558 612 """
559 613
560 614 q = ChangesetComment.query()\
561 615 .filter(ChangesetComment.repo_id == repo_id)\
562 616 .filter(ChangesetComment.line_no == None)\
563 617 .filter(ChangesetComment.f_path == None)
564 618 if revision:
565 619 q = q.filter(ChangesetComment.revision == revision)
566 620 elif pull_request:
567 621 pull_request = self.__get_pull_request(pull_request)
568 622 q = q.filter(ChangesetComment.pull_request == pull_request)
569 623 else:
570 624 raise Exception('Please specify commit or pull_request')
571 625 q = q.order_by(ChangesetComment.created_on)
572 626 return q.all()
573 627
574 628 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
575 629 q = self._get_inline_comments_query(repo_id, revision, pull_request)
576 630 return self._group_comments_by_path_and_line_number(q)
577 631
578 632 def get_inline_comments_count(self, inline_comments, skip_outdated=True,
579 633 version=None):
580 634 inline_cnt = 0
581 635 for fname, per_line_comments in inline_comments.iteritems():
582 636 for lno, comments in per_line_comments.iteritems():
583 637 for comm in comments:
584 638 if not comm.outdated_at_version(version) and skip_outdated:
585 639 inline_cnt += 1
586 640
587 641 return inline_cnt
588 642
589 643 def get_outdated_comments(self, repo_id, pull_request):
590 644 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
591 645 # of a pull request.
592 646 q = self._all_inline_comments_of_pull_request(pull_request)
593 647 q = q.filter(
594 648 ChangesetComment.display_state ==
595 649 ChangesetComment.COMMENT_OUTDATED
596 650 ).order_by(ChangesetComment.comment_id.asc())
597 651
598 652 return self._group_comments_by_path_and_line_number(q)
599 653
600 654 def _get_inline_comments_query(self, repo_id, revision, pull_request):
601 655 # TODO: johbo: Split this into two methods: One for PR and one for
602 656 # commit.
603 657 if revision:
604 658 q = Session().query(ChangesetComment).filter(
605 659 ChangesetComment.repo_id == repo_id,
606 660 ChangesetComment.line_no != null(),
607 661 ChangesetComment.f_path != null(),
608 662 ChangesetComment.revision == revision)
609 663
610 664 elif pull_request:
611 665 pull_request = self.__get_pull_request(pull_request)
612 666 if not CommentsModel.use_outdated_comments(pull_request):
613 667 q = self._visible_inline_comments_of_pull_request(pull_request)
614 668 else:
615 669 q = self._all_inline_comments_of_pull_request(pull_request)
616 670
617 671 else:
618 672 raise Exception('Please specify commit or pull_request_id')
619 673 q = q.order_by(ChangesetComment.comment_id.asc())
620 674 return q
621 675
622 676 def _group_comments_by_path_and_line_number(self, q):
623 677 comments = q.all()
624 678 paths = collections.defaultdict(lambda: collections.defaultdict(list))
625 679 for co in comments:
626 680 paths[co.f_path][co.line_no].append(co)
627 681 return paths
628 682
629 683 @classmethod
630 684 def needed_extra_diff_context(cls):
631 685 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
632 686
633 687 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
634 688 if not CommentsModel.use_outdated_comments(pull_request):
635 689 return
636 690
637 691 comments = self._visible_inline_comments_of_pull_request(pull_request)
638 692 comments_to_outdate = comments.all()
639 693
640 694 for comment in comments_to_outdate:
641 695 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
642 696
643 697 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
644 698 diff_line = _parse_comment_line_number(comment.line_no)
645 699
646 700 try:
647 701 old_context = old_diff_proc.get_context_of_line(
648 702 path=comment.f_path, diff_line=diff_line)
649 703 new_context = new_diff_proc.get_context_of_line(
650 704 path=comment.f_path, diff_line=diff_line)
651 705 except (diffs.LineNotInDiffException,
652 706 diffs.FileNotInDiffException):
653 707 comment.display_state = ChangesetComment.COMMENT_OUTDATED
654 708 return
655 709
656 710 if old_context == new_context:
657 711 return
658 712
659 713 if self._should_relocate_diff_line(diff_line):
660 714 new_diff_lines = new_diff_proc.find_context(
661 715 path=comment.f_path, context=old_context,
662 716 offset=self.DIFF_CONTEXT_BEFORE)
663 717 if not new_diff_lines:
664 718 comment.display_state = ChangesetComment.COMMENT_OUTDATED
665 719 else:
666 720 new_diff_line = self._choose_closest_diff_line(
667 721 diff_line, new_diff_lines)
668 722 comment.line_no = _diff_to_comment_line_number(new_diff_line)
669 723 else:
670 724 comment.display_state = ChangesetComment.COMMENT_OUTDATED
671 725
672 726 def _should_relocate_diff_line(self, diff_line):
673 727 """
674 728 Checks if relocation shall be tried for the given `diff_line`.
675 729
676 730 If a comment points into the first lines, then we can have a situation
677 731 that after an update another line has been added on top. In this case
678 732 we would find the context still and move the comment around. This
679 733 would be wrong.
680 734 """
681 735 should_relocate = (
682 736 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
683 737 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
684 738 return should_relocate
685 739
686 740 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
687 741 candidate = new_diff_lines[0]
688 742 best_delta = _diff_line_delta(diff_line, candidate)
689 743 for new_diff_line in new_diff_lines[1:]:
690 744 delta = _diff_line_delta(diff_line, new_diff_line)
691 745 if delta < best_delta:
692 746 candidate = new_diff_line
693 747 best_delta = delta
694 748 return candidate
695 749
696 750 def _visible_inline_comments_of_pull_request(self, pull_request):
697 751 comments = self._all_inline_comments_of_pull_request(pull_request)
698 752 comments = comments.filter(
699 753 coalesce(ChangesetComment.display_state, '') !=
700 754 ChangesetComment.COMMENT_OUTDATED)
701 755 return comments
702 756
703 757 def _all_inline_comments_of_pull_request(self, pull_request):
704 758 comments = Session().query(ChangesetComment)\
705 759 .filter(ChangesetComment.line_no != None)\
706 760 .filter(ChangesetComment.f_path != None)\
707 761 .filter(ChangesetComment.pull_request == pull_request)
708 762 return comments
709 763
710 764 def _all_general_comments_of_pull_request(self, pull_request):
711 765 comments = Session().query(ChangesetComment)\
712 766 .filter(ChangesetComment.line_no == None)\
713 767 .filter(ChangesetComment.f_path == None)\
714 768 .filter(ChangesetComment.pull_request == pull_request)
769
715 770 return comments
716 771
717 772 @staticmethod
718 773 def use_outdated_comments(pull_request):
719 774 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
720 775 settings = settings_model.get_general_settings()
721 776 return settings.get('rhodecode_use_outdated_comments', False)
722 777
723 778 def trigger_commit_comment_hook(self, repo, user, action, data=None):
724 779 repo = self._get_repo(repo)
725 780 target_scm = repo.scm_instance()
726 781 if action == 'create':
727 782 trigger_hook = hooks_utils.trigger_comment_commit_hooks
728 783 elif action == 'edit':
729 784 # TODO(dan): when this is supported we trigger edit hook too
730 785 return
731 786 else:
732 787 return
733 788
734 789 log.debug('Handling repo %s trigger_commit_comment_hook with action %s: %s',
735 790 repo, action, trigger_hook)
736 791 trigger_hook(
737 792 username=user.username,
738 793 repo_name=repo.repo_name,
739 794 repo_type=target_scm.alias,
740 795 repo=repo,
741 796 data=data)
742 797
743 798
744 799 def _parse_comment_line_number(line_no):
745 800 """
746 801 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
747 802 """
748 803 old_line = None
749 804 new_line = None
750 805 if line_no.startswith('o'):
751 806 old_line = int(line_no[1:])
752 807 elif line_no.startswith('n'):
753 808 new_line = int(line_no[1:])
754 809 else:
755 810 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
756 811 return diffs.DiffLineNumber(old_line, new_line)
757 812
758 813
759 814 def _diff_to_comment_line_number(diff_line):
760 815 if diff_line.new is not None:
761 816 return u'n{}'.format(diff_line.new)
762 817 elif diff_line.old is not None:
763 818 return u'o{}'.format(diff_line.old)
764 819 return u''
765 820
766 821
767 822 def _diff_line_delta(a, b):
768 823 if None not in (a.new, b.new):
769 824 return abs(a.new - b.new)
770 825 elif None not in (a.old, b.old):
771 826 return abs(a.old - b.old)
772 827 else:
773 828 raise ValueError(
774 829 "Cannot compute delta between {} and {}".format(a, b))
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
General Comments 0
You need to be logged in to leave comments. Login now