##// END OF EJS Templates
pull-requests: overhaul of the UX by adding new sidebar...
marcink -
r4482:3b004b10 default
parent child Browse files
Show More

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

@@ -1,533 +1,543 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 82 name='repo_commit_comment_history_view',
83 83 pattern='/{repo_name:.*?[^/]}/changeset/{commit_id}/comment/{comment_history_id}/history_view', repo_route=True)
84 84
85 85 config.add_route(
86 86 name='repo_commit_comment_attachment_upload',
87 87 pattern='/{repo_name:.*?[^/]}/changeset/{commit_id}/comment/attachment_upload', repo_route=True)
88 88
89 89 config.add_route(
90 90 name='repo_commit_comment_delete',
91 91 pattern='/{repo_name:.*?[^/]}/changeset/{commit_id}/comment/{comment_id}/delete', repo_route=True)
92 92
93 93 config.add_route(
94 94 name='repo_commit_comment_edit',
95 95 pattern='/{repo_name:.*?[^/]}/changeset/{commit_id}/comment/{comment_id}/edit', repo_route=True)
96 96
97 97 # still working url for backward compat.
98 98 config.add_route(
99 99 name='repo_commit_raw_deprecated',
100 100 pattern='/{repo_name:.*?[^/]}/raw-changeset/{commit_id}', repo_route=True)
101 101
102 102 # Files
103 103 config.add_route(
104 104 name='repo_archivefile',
105 105 pattern='/{repo_name:.*?[^/]}/archive/{fname:.*}', repo_route=True)
106 106
107 107 config.add_route(
108 108 name='repo_files_diff',
109 109 pattern='/{repo_name:.*?[^/]}/diff/{f_path:.*}', repo_route=True)
110 110 config.add_route( # legacy route to make old links work
111 111 name='repo_files_diff_2way_redirect',
112 112 pattern='/{repo_name:.*?[^/]}/diff-2way/{f_path:.*}', repo_route=True)
113 113
114 114 config.add_route(
115 115 name='repo_files',
116 116 pattern='/{repo_name:.*?[^/]}/files/{commit_id}/{f_path:.*}', repo_route=True)
117 117 config.add_route(
118 118 name='repo_files:default_path',
119 119 pattern='/{repo_name:.*?[^/]}/files/{commit_id}/', repo_route=True)
120 120 config.add_route(
121 121 name='repo_files:default_commit',
122 122 pattern='/{repo_name:.*?[^/]}/files', repo_route=True)
123 123
124 124 config.add_route(
125 125 name='repo_files:rendered',
126 126 pattern='/{repo_name:.*?[^/]}/render/{commit_id}/{f_path:.*}', repo_route=True)
127 127
128 128 config.add_route(
129 129 name='repo_files:annotated',
130 130 pattern='/{repo_name:.*?[^/]}/annotate/{commit_id}/{f_path:.*}', repo_route=True)
131 131 config.add_route(
132 132 name='repo_files:annotated_previous',
133 133 pattern='/{repo_name:.*?[^/]}/annotate-previous/{commit_id}/{f_path:.*}', repo_route=True)
134 134
135 135 config.add_route(
136 136 name='repo_nodetree_full',
137 137 pattern='/{repo_name:.*?[^/]}/nodetree_full/{commit_id}/{f_path:.*}', repo_route=True)
138 138 config.add_route(
139 139 name='repo_nodetree_full:default_path',
140 140 pattern='/{repo_name:.*?[^/]}/nodetree_full/{commit_id}/', repo_route=True)
141 141
142 142 config.add_route(
143 143 name='repo_files_nodelist',
144 144 pattern='/{repo_name:.*?[^/]}/nodelist/{commit_id}/{f_path:.*}', repo_route=True)
145 145
146 146 config.add_route(
147 147 name='repo_file_raw',
148 148 pattern='/{repo_name:.*?[^/]}/raw/{commit_id}/{f_path:.*}', repo_route=True)
149 149
150 150 config.add_route(
151 151 name='repo_file_download',
152 152 pattern='/{repo_name:.*?[^/]}/download/{commit_id}/{f_path:.*}', repo_route=True)
153 153 config.add_route( # backward compat to keep old links working
154 154 name='repo_file_download:legacy',
155 155 pattern='/{repo_name:.*?[^/]}/rawfile/{commit_id}/{f_path:.*}',
156 156 repo_route=True)
157 157
158 158 config.add_route(
159 159 name='repo_file_history',
160 160 pattern='/{repo_name:.*?[^/]}/history/{commit_id}/{f_path:.*}', repo_route=True)
161 161
162 162 config.add_route(
163 163 name='repo_file_authors',
164 164 pattern='/{repo_name:.*?[^/]}/authors/{commit_id}/{f_path:.*}', repo_route=True)
165 165
166 166 config.add_route(
167 167 name='repo_files_check_head',
168 168 pattern='/{repo_name:.*?[^/]}/check_head/{commit_id}/{f_path:.*}',
169 169 repo_route=True)
170 170 config.add_route(
171 171 name='repo_files_remove_file',
172 172 pattern='/{repo_name:.*?[^/]}/remove_file/{commit_id}/{f_path:.*}',
173 173 repo_route=True)
174 174 config.add_route(
175 175 name='repo_files_delete_file',
176 176 pattern='/{repo_name:.*?[^/]}/delete_file/{commit_id}/{f_path:.*}',
177 177 repo_route=True)
178 178 config.add_route(
179 179 name='repo_files_edit_file',
180 180 pattern='/{repo_name:.*?[^/]}/edit_file/{commit_id}/{f_path:.*}',
181 181 repo_route=True)
182 182 config.add_route(
183 183 name='repo_files_update_file',
184 184 pattern='/{repo_name:.*?[^/]}/update_file/{commit_id}/{f_path:.*}',
185 185 repo_route=True)
186 186 config.add_route(
187 187 name='repo_files_add_file',
188 188 pattern='/{repo_name:.*?[^/]}/add_file/{commit_id}/{f_path:.*}',
189 189 repo_route=True)
190 190 config.add_route(
191 191 name='repo_files_upload_file',
192 192 pattern='/{repo_name:.*?[^/]}/upload_file/{commit_id}/{f_path:.*}',
193 193 repo_route=True)
194 194 config.add_route(
195 195 name='repo_files_create_file',
196 196 pattern='/{repo_name:.*?[^/]}/create_file/{commit_id}/{f_path:.*}',
197 197 repo_route=True)
198 198
199 199 # Refs data
200 200 config.add_route(
201 201 name='repo_refs_data',
202 202 pattern='/{repo_name:.*?[^/]}/refs-data', repo_route=True)
203 203
204 204 config.add_route(
205 205 name='repo_refs_changelog_data',
206 206 pattern='/{repo_name:.*?[^/]}/refs-data-changelog', repo_route=True)
207 207
208 208 config.add_route(
209 209 name='repo_stats',
210 210 pattern='/{repo_name:.*?[^/]}/repo_stats/{commit_id}', repo_route=True)
211 211
212 212 # Commits
213 213 config.add_route(
214 214 name='repo_commits',
215 215 pattern='/{repo_name:.*?[^/]}/commits', repo_route=True)
216 216 config.add_route(
217 217 name='repo_commits_file',
218 218 pattern='/{repo_name:.*?[^/]}/commits/{commit_id}/{f_path:.*}', repo_route=True)
219 219 config.add_route(
220 220 name='repo_commits_elements',
221 221 pattern='/{repo_name:.*?[^/]}/commits_elements', repo_route=True)
222 222 config.add_route(
223 223 name='repo_commits_elements_file',
224 224 pattern='/{repo_name:.*?[^/]}/commits_elements/{commit_id}/{f_path:.*}', repo_route=True)
225 225
226 226 # Changelog (old deprecated name for commits page)
227 227 config.add_route(
228 228 name='repo_changelog',
229 229 pattern='/{repo_name:.*?[^/]}/changelog', repo_route=True)
230 230 config.add_route(
231 231 name='repo_changelog_file',
232 232 pattern='/{repo_name:.*?[^/]}/changelog/{commit_id}/{f_path:.*}', repo_route=True)
233 233
234 234 # Compare
235 235 config.add_route(
236 236 name='repo_compare_select',
237 237 pattern='/{repo_name:.*?[^/]}/compare', repo_route=True)
238 238
239 239 config.add_route(
240 240 name='repo_compare',
241 241 pattern='/{repo_name:.*?[^/]}/compare/{source_ref_type}@{source_ref:.*?}...{target_ref_type}@{target_ref:.*?}', repo_route=True)
242 242
243 243 # Tags
244 244 config.add_route(
245 245 name='tags_home',
246 246 pattern='/{repo_name:.*?[^/]}/tags', repo_route=True)
247 247
248 248 # Branches
249 249 config.add_route(
250 250 name='branches_home',
251 251 pattern='/{repo_name:.*?[^/]}/branches', repo_route=True)
252 252
253 253 # Bookmarks
254 254 config.add_route(
255 255 name='bookmarks_home',
256 256 pattern='/{repo_name:.*?[^/]}/bookmarks', repo_route=True)
257 257
258 258 # Forks
259 259 config.add_route(
260 260 name='repo_fork_new',
261 261 pattern='/{repo_name:.*?[^/]}/fork', repo_route=True,
262 262 repo_forbid_when_archived=True,
263 263 repo_accepted_types=['hg', 'git'])
264 264
265 265 config.add_route(
266 266 name='repo_fork_create',
267 267 pattern='/{repo_name:.*?[^/]}/fork/create', repo_route=True,
268 268 repo_forbid_when_archived=True,
269 269 repo_accepted_types=['hg', 'git'])
270 270
271 271 config.add_route(
272 272 name='repo_forks_show_all',
273 273 pattern='/{repo_name:.*?[^/]}/forks', repo_route=True,
274 274 repo_accepted_types=['hg', 'git'])
275 275 config.add_route(
276 276 name='repo_forks_data',
277 277 pattern='/{repo_name:.*?[^/]}/forks/data', repo_route=True,
278 278 repo_accepted_types=['hg', 'git'])
279 279
280 280 # Pull Requests
281 281 config.add_route(
282 282 name='pullrequest_show',
283 283 pattern='/{repo_name:.*?[^/]}/pull-request/{pull_request_id:\d+}',
284 284 repo_route=True)
285 285
286 286 config.add_route(
287 287 name='pullrequest_show_all',
288 288 pattern='/{repo_name:.*?[^/]}/pull-request',
289 289 repo_route=True, repo_accepted_types=['hg', 'git'])
290 290
291 291 config.add_route(
292 292 name='pullrequest_show_all_data',
293 293 pattern='/{repo_name:.*?[^/]}/pull-request-data',
294 294 repo_route=True, repo_accepted_types=['hg', 'git'])
295 295
296 296 config.add_route(
297 297 name='pullrequest_repo_refs',
298 298 pattern='/{repo_name:.*?[^/]}/pull-request/refs/{target_repo_name:.*?[^/]}',
299 299 repo_route=True)
300 300
301 301 config.add_route(
302 302 name='pullrequest_repo_targets',
303 303 pattern='/{repo_name:.*?[^/]}/pull-request/repo-targets',
304 304 repo_route=True)
305 305
306 306 config.add_route(
307 307 name='pullrequest_new',
308 308 pattern='/{repo_name:.*?[^/]}/pull-request/new',
309 309 repo_route=True, repo_accepted_types=['hg', 'git'],
310 310 repo_forbid_when_archived=True)
311 311
312 312 config.add_route(
313 313 name='pullrequest_create',
314 314 pattern='/{repo_name:.*?[^/]}/pull-request/create',
315 315 repo_route=True, repo_accepted_types=['hg', 'git'],
316 316 repo_forbid_when_archived=True)
317 317
318 318 config.add_route(
319 319 name='pullrequest_update',
320 320 pattern='/{repo_name:.*?[^/]}/pull-request/{pull_request_id:\d+}/update',
321 321 repo_route=True, repo_forbid_when_archived=True)
322 322
323 323 config.add_route(
324 324 name='pullrequest_merge',
325 325 pattern='/{repo_name:.*?[^/]}/pull-request/{pull_request_id:\d+}/merge',
326 326 repo_route=True, repo_forbid_when_archived=True)
327 327
328 328 config.add_route(
329 329 name='pullrequest_delete',
330 330 pattern='/{repo_name:.*?[^/]}/pull-request/{pull_request_id:\d+}/delete',
331 331 repo_route=True, repo_forbid_when_archived=True)
332 332
333 333 config.add_route(
334 334 name='pullrequest_comment_create',
335 335 pattern='/{repo_name:.*?[^/]}/pull-request/{pull_request_id:\d+}/comment',
336 336 repo_route=True)
337 337
338 338 config.add_route(
339 339 name='pullrequest_comment_edit',
340 340 pattern='/{repo_name:.*?[^/]}/pull-request/{pull_request_id:\d+}/comment/{comment_id}/edit',
341 341 repo_route=True, repo_accepted_types=['hg', 'git'])
342 342
343 343 config.add_route(
344 344 name='pullrequest_comment_delete',
345 345 pattern='/{repo_name:.*?[^/]}/pull-request/{pull_request_id:\d+}/comment/{comment_id}/delete',
346 346 repo_route=True, repo_accepted_types=['hg', 'git'])
347 347
348 config.add_route(
349 name='pullrequest_comments',
350 pattern='/{repo_name:.*?[^/]}/pull-request/{pull_request_id:\d+}/comments',
351 repo_route=True)
352
353 config.add_route(
354 name='pullrequest_todos',
355 pattern='/{repo_name:.*?[^/]}/pull-request/{pull_request_id:\d+}/todos',
356 repo_route=True)
357
348 358 # Artifacts, (EE feature)
349 359 config.add_route(
350 360 name='repo_artifacts_list',
351 361 pattern='/{repo_name:.*?[^/]}/artifacts', repo_route=True)
352 362
353 363 # Settings
354 364 config.add_route(
355 365 name='edit_repo',
356 366 pattern='/{repo_name:.*?[^/]}/settings', repo_route=True)
357 367 # update is POST on edit_repo
358 368
359 369 # Settings advanced
360 370 config.add_route(
361 371 name='edit_repo_advanced',
362 372 pattern='/{repo_name:.*?[^/]}/settings/advanced', repo_route=True)
363 373 config.add_route(
364 374 name='edit_repo_advanced_archive',
365 375 pattern='/{repo_name:.*?[^/]}/settings/advanced/archive', repo_route=True)
366 376 config.add_route(
367 377 name='edit_repo_advanced_delete',
368 378 pattern='/{repo_name:.*?[^/]}/settings/advanced/delete', repo_route=True)
369 379 config.add_route(
370 380 name='edit_repo_advanced_locking',
371 381 pattern='/{repo_name:.*?[^/]}/settings/advanced/locking', repo_route=True)
372 382 config.add_route(
373 383 name='edit_repo_advanced_journal',
374 384 pattern='/{repo_name:.*?[^/]}/settings/advanced/journal', repo_route=True)
375 385 config.add_route(
376 386 name='edit_repo_advanced_fork',
377 387 pattern='/{repo_name:.*?[^/]}/settings/advanced/fork', repo_route=True)
378 388
379 389 config.add_route(
380 390 name='edit_repo_advanced_hooks',
381 391 pattern='/{repo_name:.*?[^/]}/settings/advanced/hooks', repo_route=True)
382 392
383 393 # Caches
384 394 config.add_route(
385 395 name='edit_repo_caches',
386 396 pattern='/{repo_name:.*?[^/]}/settings/caches', repo_route=True)
387 397
388 398 # Permissions
389 399 config.add_route(
390 400 name='edit_repo_perms',
391 401 pattern='/{repo_name:.*?[^/]}/settings/permissions', repo_route=True)
392 402
393 403 config.add_route(
394 404 name='edit_repo_perms_set_private',
395 405 pattern='/{repo_name:.*?[^/]}/settings/permissions/set_private', repo_route=True)
396 406
397 407 # Permissions Branch (EE feature)
398 408 config.add_route(
399 409 name='edit_repo_perms_branch',
400 410 pattern='/{repo_name:.*?[^/]}/settings/branch_permissions', repo_route=True)
401 411 config.add_route(
402 412 name='edit_repo_perms_branch_delete',
403 413 pattern='/{repo_name:.*?[^/]}/settings/branch_permissions/{rule_id}/delete',
404 414 repo_route=True)
405 415
406 416 # Maintenance
407 417 config.add_route(
408 418 name='edit_repo_maintenance',
409 419 pattern='/{repo_name:.*?[^/]}/settings/maintenance', repo_route=True)
410 420
411 421 config.add_route(
412 422 name='edit_repo_maintenance_execute',
413 423 pattern='/{repo_name:.*?[^/]}/settings/maintenance/execute', repo_route=True)
414 424
415 425 # Fields
416 426 config.add_route(
417 427 name='edit_repo_fields',
418 428 pattern='/{repo_name:.*?[^/]}/settings/fields', repo_route=True)
419 429 config.add_route(
420 430 name='edit_repo_fields_create',
421 431 pattern='/{repo_name:.*?[^/]}/settings/fields/create', repo_route=True)
422 432 config.add_route(
423 433 name='edit_repo_fields_delete',
424 434 pattern='/{repo_name:.*?[^/]}/settings/fields/{field_id}/delete', repo_route=True)
425 435
426 436 # Locking
427 437 config.add_route(
428 438 name='repo_edit_toggle_locking',
429 439 pattern='/{repo_name:.*?[^/]}/settings/toggle_locking', repo_route=True)
430 440
431 441 # Remote
432 442 config.add_route(
433 443 name='edit_repo_remote',
434 444 pattern='/{repo_name:.*?[^/]}/settings/remote', repo_route=True)
435 445 config.add_route(
436 446 name='edit_repo_remote_pull',
437 447 pattern='/{repo_name:.*?[^/]}/settings/remote/pull', repo_route=True)
438 448 config.add_route(
439 449 name='edit_repo_remote_push',
440 450 pattern='/{repo_name:.*?[^/]}/settings/remote/push', repo_route=True)
441 451
442 452 # Statistics
443 453 config.add_route(
444 454 name='edit_repo_statistics',
445 455 pattern='/{repo_name:.*?[^/]}/settings/statistics', repo_route=True)
446 456 config.add_route(
447 457 name='edit_repo_statistics_reset',
448 458 pattern='/{repo_name:.*?[^/]}/settings/statistics/update', repo_route=True)
449 459
450 460 # Issue trackers
451 461 config.add_route(
452 462 name='edit_repo_issuetracker',
453 463 pattern='/{repo_name:.*?[^/]}/settings/issue_trackers', repo_route=True)
454 464 config.add_route(
455 465 name='edit_repo_issuetracker_test',
456 466 pattern='/{repo_name:.*?[^/]}/settings/issue_trackers/test', repo_route=True)
457 467 config.add_route(
458 468 name='edit_repo_issuetracker_delete',
459 469 pattern='/{repo_name:.*?[^/]}/settings/issue_trackers/delete', repo_route=True)
460 470 config.add_route(
461 471 name='edit_repo_issuetracker_update',
462 472 pattern='/{repo_name:.*?[^/]}/settings/issue_trackers/update', repo_route=True)
463 473
464 474 # VCS Settings
465 475 config.add_route(
466 476 name='edit_repo_vcs',
467 477 pattern='/{repo_name:.*?[^/]}/settings/vcs', repo_route=True)
468 478 config.add_route(
469 479 name='edit_repo_vcs_update',
470 480 pattern='/{repo_name:.*?[^/]}/settings/vcs/update', repo_route=True)
471 481
472 482 # svn pattern
473 483 config.add_route(
474 484 name='edit_repo_vcs_svn_pattern_delete',
475 485 pattern='/{repo_name:.*?[^/]}/settings/vcs/svn_pattern/delete', repo_route=True)
476 486
477 487 # Repo Review Rules (EE feature)
478 488 config.add_route(
479 489 name='repo_reviewers',
480 490 pattern='/{repo_name:.*?[^/]}/settings/review/rules', repo_route=True)
481 491
482 492 config.add_route(
483 493 name='repo_default_reviewers_data',
484 494 pattern='/{repo_name:.*?[^/]}/settings/review/default-reviewers', repo_route=True)
485 495
486 496 # Repo Automation (EE feature)
487 497 config.add_route(
488 498 name='repo_automation',
489 499 pattern='/{repo_name:.*?[^/]}/settings/automation', repo_route=True)
490 500
491 501 # Strip
492 502 config.add_route(
493 503 name='edit_repo_strip',
494 504 pattern='/{repo_name:.*?[^/]}/settings/strip', repo_route=True)
495 505
496 506 config.add_route(
497 507 name='strip_check',
498 508 pattern='/{repo_name:.*?[^/]}/settings/strip_check', repo_route=True)
499 509
500 510 config.add_route(
501 511 name='strip_execute',
502 512 pattern='/{repo_name:.*?[^/]}/settings/strip_execute', repo_route=True)
503 513
504 514 # Audit logs
505 515 config.add_route(
506 516 name='edit_repo_audit_logs',
507 517 pattern='/{repo_name:.*?[^/]}/settings/audit_logs', repo_route=True)
508 518
509 519 # ATOM/RSS Feed, shouldn't contain slashes for outlook compatibility
510 520 config.add_route(
511 521 name='rss_feed_home',
512 522 pattern='/{repo_name:.*?[^/]}/feed-rss', repo_route=True)
513 523
514 524 config.add_route(
515 525 name='atom_feed_home',
516 526 pattern='/{repo_name:.*?[^/]}/feed-atom', repo_route=True)
517 527
518 528 config.add_route(
519 529 name='rss_feed_home_old',
520 530 pattern='/{repo_name:.*?[^/]}/feed/rss', repo_route=True)
521 531
522 532 config.add_route(
523 533 name='atom_feed_home_old',
524 534 pattern='/{repo_name:.*?[^/]}/feed/atom', repo_route=True)
525 535
526 536 # NOTE(marcink): needs to be at the end for catch-all
527 537 add_route_with_slash(
528 538 config,
529 539 name='repo_summary',
530 540 pattern='/{repo_name:.*?[^/]}', repo_route=True)
531 541
532 542 # Scan module for configuration decorators.
533 543 config.scan('.views', ignore='.tests')
@@ -1,723 +1,725 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
24 24 from pyramid.httpexceptions import (
25 25 HTTPNotFound, HTTPBadRequest, HTTPFound, HTTPForbidden, HTTPConflict)
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, CommentVersionMismatch
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 48 from rhodecode.model.db import ChangesetComment, ChangesetStatus, FileStore, \
49 49 ChangesetCommentHistory
50 50 from rhodecode.model.changeset_status import ChangesetStatusModel
51 51 from rhodecode.model.comment import CommentsModel
52 52 from rhodecode.model.meta import Session
53 53 from rhodecode.model.settings import VcsSettingsModel
54 54
55 55 log = logging.getLogger(__name__)
56 56
57 57
58 58 def _update_with_GET(params, request):
59 59 for k in ['diff1', 'diff2', 'diff']:
60 60 params[k] += request.GET.getall(k)
61 61
62 62
63 63 class RepoCommitsView(RepoAppView):
64 64 def load_default_context(self):
65 65 c = self._get_local_tmpl_context(include_app_defaults=True)
66 66 c.rhodecode_repo = self.rhodecode_vcs_repo
67 67
68 68 return c
69 69
70 70 def _is_diff_cache_enabled(self, target_repo):
71 71 caching_enabled = self._get_general_setting(
72 72 target_repo, 'rhodecode_diff_cache')
73 73 log.debug('Diff caching enabled: %s', caching_enabled)
74 74 return caching_enabled
75 75
76 76 def _commit(self, commit_id_range, method):
77 77 _ = self.request.translate
78 78 c = self.load_default_context()
79 79 c.fulldiff = self.request.GET.get('fulldiff')
80 80
81 81 # fetch global flags of ignore ws or context lines
82 82 diff_context = get_diff_context(self.request)
83 83 hide_whitespace_changes = get_diff_whitespace_flag(self.request)
84 84
85 85 # diff_limit will cut off the whole diff if the limit is applied
86 86 # otherwise it will just hide the big files from the front-end
87 87 diff_limit = c.visual.cut_off_limit_diff
88 88 file_limit = c.visual.cut_off_limit_file
89 89
90
90 91 # get ranges of commit ids if preset
91 92 commit_range = commit_id_range.split('...')[:2]
92 93
93 94 try:
94 95 pre_load = ['affected_files', 'author', 'branch', 'date',
95 96 'message', 'parents']
96 97 if self.rhodecode_vcs_repo.alias == 'hg':
97 98 pre_load += ['hidden', 'obsolete', 'phase']
98 99
99 100 if len(commit_range) == 2:
100 101 commits = self.rhodecode_vcs_repo.get_commits(
101 102 start_id=commit_range[0], end_id=commit_range[1],
102 103 pre_load=pre_load, translate_tags=False)
103 104 commits = list(commits)
104 105 else:
105 106 commits = [self.rhodecode_vcs_repo.get_commit(
106 107 commit_id=commit_id_range, pre_load=pre_load)]
107 108
108 109 c.commit_ranges = commits
109 110 if not c.commit_ranges:
110 111 raise RepositoryError('The commit range returned an empty result')
111 112 except CommitDoesNotExistError as e:
112 113 msg = _('No such commit exists. Org exception: `{}`').format(e)
113 114 h.flash(msg, category='error')
114 115 raise HTTPNotFound()
115 116 except Exception:
116 117 log.exception("General failure")
117 118 raise HTTPNotFound()
118 119
119 120 c.changes = OrderedDict()
120 121 c.lines_added = 0
121 122 c.lines_deleted = 0
122 123
123 124 # auto collapse if we have more than limit
124 125 collapse_limit = diffs.DiffProcessor._collapse_commits_over
125 126 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
126 127
127 128 c.commit_statuses = ChangesetStatus.STATUSES
128 129 c.inline_comments = []
129 130 c.files = []
130 131
131 132 c.statuses = []
132 133 c.comments = []
133 134 c.unresolved_comments = []
134 135 c.resolved_comments = []
135 136 if len(c.commit_ranges) == 1:
136 137 commit = c.commit_ranges[0]
137 138 c.comments = CommentsModel().get_comments(
138 139 self.db_repo.repo_id,
139 140 revision=commit.raw_id)
140 141 c.statuses.append(ChangesetStatusModel().get_status(
141 142 self.db_repo.repo_id, commit.raw_id))
142 143 # comments from PR
143 144 statuses = ChangesetStatusModel().get_statuses(
144 145 self.db_repo.repo_id, commit.raw_id,
145 146 with_revisions=True)
146 147 prs = set(st.pull_request for st in statuses
147 148 if st.pull_request is not None)
148 149 # from associated statuses, check the pull requests, and
149 150 # show comments from them
150 151 for pr in prs:
151 152 c.comments.extend(pr.comments)
152 153
153 154 c.unresolved_comments = CommentsModel()\
154 155 .get_commit_unresolved_todos(commit.raw_id)
155 156 c.resolved_comments = CommentsModel()\
156 157 .get_commit_resolved_todos(commit.raw_id)
157 158
158 159 diff = None
159 160 # Iterate over ranges (default commit view is always one commit)
160 161 for commit in c.commit_ranges:
161 162 c.changes[commit.raw_id] = []
162 163
163 164 commit2 = commit
164 165 commit1 = commit.first_parent
165 166
166 167 if method == 'show':
167 168 inline_comments = CommentsModel().get_inline_comments(
168 169 self.db_repo.repo_id, revision=commit.raw_id)
169 170 c.inline_cnt = len(CommentsModel().get_inline_comments_as_list(
170 171 inline_comments))
171 172 c.inline_comments = inline_comments
172 173
173 174 cache_path = self.rhodecode_vcs_repo.get_create_shadow_cache_pr_path(
174 175 self.db_repo)
175 176 cache_file_path = diff_cache_exist(
176 177 cache_path, 'diff', commit.raw_id,
177 178 hide_whitespace_changes, diff_context, c.fulldiff)
178 179
179 180 caching_enabled = self._is_diff_cache_enabled(self.db_repo)
180 181 force_recache = str2bool(self.request.GET.get('force_recache'))
181 182
182 183 cached_diff = None
183 184 if caching_enabled:
184 185 cached_diff = load_cached_diff(cache_file_path)
185 186
186 187 has_proper_diff_cache = cached_diff and cached_diff.get('diff')
187 188 if not force_recache and has_proper_diff_cache:
188 189 diffset = cached_diff['diff']
189 190 else:
190 191 vcs_diff = self.rhodecode_vcs_repo.get_diff(
191 192 commit1, commit2,
192 193 ignore_whitespace=hide_whitespace_changes,
193 194 context=diff_context)
194 195
195 196 diff_processor = diffs.DiffProcessor(
196 197 vcs_diff, format='newdiff', diff_limit=diff_limit,
197 198 file_limit=file_limit, show_full_diff=c.fulldiff)
198 199
199 200 _parsed = diff_processor.prepare()
200 201
201 202 diffset = codeblocks.DiffSet(
202 203 repo_name=self.db_repo_name,
203 204 source_node_getter=codeblocks.diffset_node_getter(commit1),
204 205 target_node_getter=codeblocks.diffset_node_getter(commit2))
205 206
206 207 diffset = self.path_filter.render_patchset_filtered(
207 208 diffset, _parsed, commit1.raw_id, commit2.raw_id)
208 209
209 210 # save cached diff
210 211 if caching_enabled:
211 212 cache_diff(cache_file_path, diffset, None)
212 213
213 214 c.limited_diff = diffset.limited_diff
214 215 c.changes[commit.raw_id] = diffset
215 216 else:
216 217 # TODO(marcink): no cache usage here...
217 218 _diff = self.rhodecode_vcs_repo.get_diff(
218 219 commit1, commit2,
219 220 ignore_whitespace=hide_whitespace_changes, context=diff_context)
220 221 diff_processor = diffs.DiffProcessor(
221 222 _diff, format='newdiff', diff_limit=diff_limit,
222 223 file_limit=file_limit, show_full_diff=c.fulldiff)
223 224 # downloads/raw we only need RAW diff nothing else
224 225 diff = self.path_filter.get_raw_patch(diff_processor)
225 226 c.changes[commit.raw_id] = [None, None, None, None, diff, None, None]
226 227
227 228 # sort comments by how they were generated
228 229 c.comments = sorted(c.comments, key=lambda x: x.comment_id)
230 c.at_version_num = None
229 231
230 232 if len(c.commit_ranges) == 1:
231 233 c.commit = c.commit_ranges[0]
232 234 c.parent_tmpl = ''.join(
233 235 '# Parent %s\n' % x.raw_id for x in c.commit.parents)
234 236
235 237 if method == 'download':
236 238 response = Response(diff)
237 239 response.content_type = 'text/plain'
238 240 response.content_disposition = (
239 241 'attachment; filename=%s.diff' % commit_id_range[:12])
240 242 return response
241 243 elif method == 'patch':
242 244 c.diff = safe_unicode(diff)
243 245 patch = render(
244 246 'rhodecode:templates/changeset/patch_changeset.mako',
245 247 self._get_template_context(c), self.request)
246 248 response = Response(patch)
247 249 response.content_type = 'text/plain'
248 250 return response
249 251 elif method == 'raw':
250 252 response = Response(diff)
251 253 response.content_type = 'text/plain'
252 254 return response
253 255 elif method == 'show':
254 256 if len(c.commit_ranges) == 1:
255 257 html = render(
256 258 'rhodecode:templates/changeset/changeset.mako',
257 259 self._get_template_context(c), self.request)
258 260 return Response(html)
259 261 else:
260 262 c.ancestor = None
261 263 c.target_repo = self.db_repo
262 264 html = render(
263 265 'rhodecode:templates/changeset/changeset_range.mako',
264 266 self._get_template_context(c), self.request)
265 267 return Response(html)
266 268
267 269 raise HTTPBadRequest()
268 270
269 271 @LoginRequired()
270 272 @HasRepoPermissionAnyDecorator(
271 273 'repository.read', 'repository.write', 'repository.admin')
272 274 @view_config(
273 275 route_name='repo_commit', request_method='GET',
274 276 renderer=None)
275 277 def repo_commit_show(self):
276 278 commit_id = self.request.matchdict['commit_id']
277 279 return self._commit(commit_id, method='show')
278 280
279 281 @LoginRequired()
280 282 @HasRepoPermissionAnyDecorator(
281 283 'repository.read', 'repository.write', 'repository.admin')
282 284 @view_config(
283 285 route_name='repo_commit_raw', request_method='GET',
284 286 renderer=None)
285 287 @view_config(
286 288 route_name='repo_commit_raw_deprecated', request_method='GET',
287 289 renderer=None)
288 290 def repo_commit_raw(self):
289 291 commit_id = self.request.matchdict['commit_id']
290 292 return self._commit(commit_id, method='raw')
291 293
292 294 @LoginRequired()
293 295 @HasRepoPermissionAnyDecorator(
294 296 'repository.read', 'repository.write', 'repository.admin')
295 297 @view_config(
296 298 route_name='repo_commit_patch', request_method='GET',
297 299 renderer=None)
298 300 def repo_commit_patch(self):
299 301 commit_id = self.request.matchdict['commit_id']
300 302 return self._commit(commit_id, method='patch')
301 303
302 304 @LoginRequired()
303 305 @HasRepoPermissionAnyDecorator(
304 306 'repository.read', 'repository.write', 'repository.admin')
305 307 @view_config(
306 308 route_name='repo_commit_download', request_method='GET',
307 309 renderer=None)
308 310 def repo_commit_download(self):
309 311 commit_id = self.request.matchdict['commit_id']
310 312 return self._commit(commit_id, method='download')
311 313
312 314 @LoginRequired()
313 315 @NotAnonymous()
314 316 @HasRepoPermissionAnyDecorator(
315 317 'repository.read', 'repository.write', 'repository.admin')
316 318 @CSRFRequired()
317 319 @view_config(
318 320 route_name='repo_commit_comment_create', request_method='POST',
319 321 renderer='json_ext')
320 322 def repo_commit_comment_create(self):
321 323 _ = self.request.translate
322 324 commit_id = self.request.matchdict['commit_id']
323 325
324 326 c = self.load_default_context()
325 327 status = self.request.POST.get('changeset_status', None)
326 328 text = self.request.POST.get('text')
327 329 comment_type = self.request.POST.get('comment_type')
328 330 resolves_comment_id = self.request.POST.get('resolves_comment_id', None)
329 331
330 332 if status:
331 333 text = text or (_('Status change %(transition_icon)s %(status)s')
332 334 % {'transition_icon': '>',
333 335 'status': ChangesetStatus.get_status_lbl(status)})
334 336
335 337 multi_commit_ids = []
336 338 for _commit_id in self.request.POST.get('commit_ids', '').split(','):
337 339 if _commit_id not in ['', None, EmptyCommit.raw_id]:
338 340 if _commit_id not in multi_commit_ids:
339 341 multi_commit_ids.append(_commit_id)
340 342
341 343 commit_ids = multi_commit_ids or [commit_id]
342 344
343 345 comment = None
344 346 for current_id in filter(None, commit_ids):
345 347 comment = CommentsModel().create(
346 348 text=text,
347 349 repo=self.db_repo.repo_id,
348 350 user=self._rhodecode_db_user.user_id,
349 351 commit_id=current_id,
350 352 f_path=self.request.POST.get('f_path'),
351 353 line_no=self.request.POST.get('line'),
352 354 status_change=(ChangesetStatus.get_status_lbl(status)
353 355 if status else None),
354 356 status_change_type=status,
355 357 comment_type=comment_type,
356 358 resolves_comment_id=resolves_comment_id,
357 359 auth_user=self._rhodecode_user
358 360 )
359 361
360 362 # get status if set !
361 363 if status:
362 364 # if latest status was from pull request and it's closed
363 365 # disallow changing status !
364 366 # dont_allow_on_closed_pull_request = True !
365 367
366 368 try:
367 369 ChangesetStatusModel().set_status(
368 370 self.db_repo.repo_id,
369 371 status,
370 372 self._rhodecode_db_user.user_id,
371 373 comment,
372 374 revision=current_id,
373 375 dont_allow_on_closed_pull_request=True
374 376 )
375 377 except StatusChangeOnClosedPullRequestError:
376 378 msg = _('Changing the status of a commit associated with '
377 379 'a closed pull request is not allowed')
378 380 log.exception(msg)
379 381 h.flash(msg, category='warning')
380 382 raise HTTPFound(h.route_path(
381 383 'repo_commit', repo_name=self.db_repo_name,
382 384 commit_id=current_id))
383 385
384 386 commit = self.db_repo.get_commit(current_id)
385 387 CommentsModel().trigger_commit_comment_hook(
386 388 self.db_repo, self._rhodecode_user, 'create',
387 389 data={'comment': comment, 'commit': commit})
388 390
389 391 # finalize, commit and redirect
390 392 Session().commit()
391 393
392 394 data = {
393 395 'target_id': h.safeid(h.safe_unicode(
394 396 self.request.POST.get('f_path'))),
395 397 }
396 398 if comment:
397 399 c.co = comment
398 400 rendered_comment = render(
399 401 'rhodecode:templates/changeset/changeset_comment_block.mako',
400 402 self._get_template_context(c), self.request)
401 403
402 404 data.update(comment.get_dict())
403 405 data.update({'rendered_text': rendered_comment})
404 406
405 407 return data
406 408
407 409 @LoginRequired()
408 410 @NotAnonymous()
409 411 @HasRepoPermissionAnyDecorator(
410 412 'repository.read', 'repository.write', 'repository.admin')
411 413 @CSRFRequired()
412 414 @view_config(
413 415 route_name='repo_commit_comment_preview', request_method='POST',
414 416 renderer='string', xhr=True)
415 417 def repo_commit_comment_preview(self):
416 418 # Technically a CSRF token is not needed as no state changes with this
417 419 # call. However, as this is a POST is better to have it, so automated
418 420 # tools don't flag it as potential CSRF.
419 421 # Post is required because the payload could be bigger than the maximum
420 422 # allowed by GET.
421 423
422 424 text = self.request.POST.get('text')
423 425 renderer = self.request.POST.get('renderer') or 'rst'
424 426 if text:
425 427 return h.render(text, renderer=renderer, mentions=True,
426 428 repo_name=self.db_repo_name)
427 429 return ''
428 430
429 431 @LoginRequired()
430 432 @NotAnonymous()
431 433 @HasRepoPermissionAnyDecorator(
432 434 'repository.read', 'repository.write', 'repository.admin')
433 435 @CSRFRequired()
434 436 @view_config(
435 437 route_name='repo_commit_comment_history_view', request_method='POST',
436 438 renderer='string', xhr=True)
437 439 def repo_commit_comment_history_view(self):
438 440 c = self.load_default_context()
439 441
440 442 comment_history_id = self.request.matchdict['comment_history_id']
441 443 comment_history = ChangesetCommentHistory.get_or_404(comment_history_id)
442 444 is_repo_comment = comment_history.comment.repo.repo_id == self.db_repo.repo_id
443 445
444 446 if is_repo_comment:
445 447 c.comment_history = comment_history
446 448
447 449 rendered_comment = render(
448 450 'rhodecode:templates/changeset/comment_history.mako',
449 451 self._get_template_context(c)
450 452 , self.request)
451 453 return rendered_comment
452 454 else:
453 455 log.warning('No permissions for user %s to show comment_history_id: %s',
454 456 self._rhodecode_db_user, comment_history_id)
455 457 raise HTTPNotFound()
456 458
457 459 @LoginRequired()
458 460 @NotAnonymous()
459 461 @HasRepoPermissionAnyDecorator(
460 462 'repository.read', 'repository.write', 'repository.admin')
461 463 @CSRFRequired()
462 464 @view_config(
463 465 route_name='repo_commit_comment_attachment_upload', request_method='POST',
464 466 renderer='json_ext', xhr=True)
465 467 def repo_commit_comment_attachment_upload(self):
466 468 c = self.load_default_context()
467 469 upload_key = 'attachment'
468 470
469 471 file_obj = self.request.POST.get(upload_key)
470 472
471 473 if file_obj is None:
472 474 self.request.response.status = 400
473 475 return {'store_fid': None,
474 476 'access_path': None,
475 477 'error': '{} data field is missing'.format(upload_key)}
476 478
477 479 if not hasattr(file_obj, 'filename'):
478 480 self.request.response.status = 400
479 481 return {'store_fid': None,
480 482 'access_path': None,
481 483 'error': 'filename cannot be read from the data field'}
482 484
483 485 filename = file_obj.filename
484 486 file_display_name = filename
485 487
486 488 metadata = {
487 489 'user_uploaded': {'username': self._rhodecode_user.username,
488 490 'user_id': self._rhodecode_user.user_id,
489 491 'ip': self._rhodecode_user.ip_addr}}
490 492
491 493 # TODO(marcink): allow .ini configuration for allowed_extensions, and file-size
492 494 allowed_extensions = [
493 495 'gif', '.jpeg', '.jpg', '.png', '.docx', '.gz', '.log', '.pdf',
494 496 '.pptx', '.txt', '.xlsx', '.zip']
495 497 max_file_size = 10 * 1024 * 1024 # 10MB, also validated via dropzone.js
496 498
497 499 try:
498 500 storage = store_utils.get_file_storage(self.request.registry.settings)
499 501 store_uid, metadata = storage.save_file(
500 502 file_obj.file, filename, extra_metadata=metadata,
501 503 extensions=allowed_extensions, max_filesize=max_file_size)
502 504 except FileNotAllowedException:
503 505 self.request.response.status = 400
504 506 permitted_extensions = ', '.join(allowed_extensions)
505 507 error_msg = 'File `{}` is not allowed. ' \
506 508 'Only following extensions are permitted: {}'.format(
507 509 filename, permitted_extensions)
508 510 return {'store_fid': None,
509 511 'access_path': None,
510 512 'error': error_msg}
511 513 except FileOverSizeException:
512 514 self.request.response.status = 400
513 515 limit_mb = h.format_byte_size_binary(max_file_size)
514 516 return {'store_fid': None,
515 517 'access_path': None,
516 518 'error': 'File {} is exceeding allowed limit of {}.'.format(
517 519 filename, limit_mb)}
518 520
519 521 try:
520 522 entry = FileStore.create(
521 523 file_uid=store_uid, filename=metadata["filename"],
522 524 file_hash=metadata["sha256"], file_size=metadata["size"],
523 525 file_display_name=file_display_name,
524 526 file_description=u'comment attachment `{}`'.format(safe_unicode(filename)),
525 527 hidden=True, check_acl=True, user_id=self._rhodecode_user.user_id,
526 528 scope_repo_id=self.db_repo.repo_id
527 529 )
528 530 Session().add(entry)
529 531 Session().commit()
530 532 log.debug('Stored upload in DB as %s', entry)
531 533 except Exception:
532 534 log.exception('Failed to store file %s', filename)
533 535 self.request.response.status = 400
534 536 return {'store_fid': None,
535 537 'access_path': None,
536 538 'error': 'File {} failed to store in DB.'.format(filename)}
537 539
538 540 Session().commit()
539 541
540 542 return {
541 543 'store_fid': store_uid,
542 544 'access_path': h.route_path(
543 545 'download_file', fid=store_uid),
544 546 'fqn_access_path': h.route_url(
545 547 'download_file', fid=store_uid),
546 548 'repo_access_path': h.route_path(
547 549 'repo_artifacts_get', repo_name=self.db_repo_name, uid=store_uid),
548 550 'repo_fqn_access_path': h.route_url(
549 551 'repo_artifacts_get', repo_name=self.db_repo_name, uid=store_uid),
550 552 }
551 553
552 554 @LoginRequired()
553 555 @NotAnonymous()
554 556 @HasRepoPermissionAnyDecorator(
555 557 'repository.read', 'repository.write', 'repository.admin')
556 558 @CSRFRequired()
557 559 @view_config(
558 560 route_name='repo_commit_comment_delete', request_method='POST',
559 561 renderer='json_ext')
560 562 def repo_commit_comment_delete(self):
561 563 commit_id = self.request.matchdict['commit_id']
562 564 comment_id = self.request.matchdict['comment_id']
563 565
564 566 comment = ChangesetComment.get_or_404(comment_id)
565 567 if not comment:
566 568 log.debug('Comment with id:%s not found, skipping', comment_id)
567 569 # comment already deleted in another call probably
568 570 return True
569 571
570 572 if comment.immutable:
571 573 # don't allow deleting comments that are immutable
572 574 raise HTTPForbidden()
573 575
574 576 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
575 577 super_admin = h.HasPermissionAny('hg.admin')()
576 578 comment_owner = (comment.author.user_id == self._rhodecode_db_user.user_id)
577 579 is_repo_comment = comment.repo.repo_id == self.db_repo.repo_id
578 580 comment_repo_admin = is_repo_admin and is_repo_comment
579 581
580 582 if super_admin or comment_owner or comment_repo_admin:
581 583 CommentsModel().delete(comment=comment, auth_user=self._rhodecode_user)
582 584 Session().commit()
583 585 return True
584 586 else:
585 587 log.warning('No permissions for user %s to delete comment_id: %s',
586 588 self._rhodecode_db_user, comment_id)
587 589 raise HTTPNotFound()
588 590
589 591 @LoginRequired()
590 592 @NotAnonymous()
591 593 @HasRepoPermissionAnyDecorator(
592 594 'repository.read', 'repository.write', 'repository.admin')
593 595 @CSRFRequired()
594 596 @view_config(
595 597 route_name='repo_commit_comment_edit', request_method='POST',
596 598 renderer='json_ext')
597 599 def repo_commit_comment_edit(self):
598 600 self.load_default_context()
599 601
600 602 comment_id = self.request.matchdict['comment_id']
601 603 comment = ChangesetComment.get_or_404(comment_id)
602 604
603 605 if comment.immutable:
604 606 # don't allow deleting comments that are immutable
605 607 raise HTTPForbidden()
606 608
607 609 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
608 610 super_admin = h.HasPermissionAny('hg.admin')()
609 611 comment_owner = (comment.author.user_id == self._rhodecode_db_user.user_id)
610 612 is_repo_comment = comment.repo.repo_id == self.db_repo.repo_id
611 613 comment_repo_admin = is_repo_admin and is_repo_comment
612 614
613 615 if super_admin or comment_owner or comment_repo_admin:
614 616 text = self.request.POST.get('text')
615 617 version = self.request.POST.get('version')
616 618 if text == comment.text:
617 619 log.warning(
618 620 'Comment(repo): '
619 621 'Trying to create new version '
620 622 'with the same comment body {}'.format(
621 623 comment_id,
622 624 )
623 625 )
624 626 raise HTTPNotFound()
625 627
626 628 if version.isdigit():
627 629 version = int(version)
628 630 else:
629 631 log.warning(
630 632 'Comment(repo): Wrong version type {} {} '
631 633 'for comment {}'.format(
632 634 version,
633 635 type(version),
634 636 comment_id,
635 637 )
636 638 )
637 639 raise HTTPNotFound()
638 640
639 641 try:
640 642 comment_history = CommentsModel().edit(
641 643 comment_id=comment_id,
642 644 text=text,
643 645 auth_user=self._rhodecode_user,
644 646 version=version,
645 647 )
646 648 except CommentVersionMismatch:
647 649 raise HTTPConflict()
648 650
649 651 if not comment_history:
650 652 raise HTTPNotFound()
651 653
652 654 commit_id = self.request.matchdict['commit_id']
653 655 commit = self.db_repo.get_commit(commit_id)
654 656 CommentsModel().trigger_commit_comment_hook(
655 657 self.db_repo, self._rhodecode_user, 'edit',
656 658 data={'comment': comment, 'commit': commit})
657 659
658 660 Session().commit()
659 661 return {
660 662 'comment_history_id': comment_history.comment_history_id,
661 663 'comment_id': comment.comment_id,
662 664 'comment_version': comment_history.version,
663 665 'comment_author_username': comment_history.author.username,
664 666 'comment_author_gravatar': h.gravatar_url(comment_history.author.email, 16),
665 667 'comment_created_on': h.age_component(comment_history.created_on,
666 668 time_is_local=True),
667 669 }
668 670 else:
669 671 log.warning('No permissions for user %s to edit comment_id: %s',
670 672 self._rhodecode_db_user, comment_id)
671 673 raise HTTPNotFound()
672 674
673 675 @LoginRequired()
674 676 @HasRepoPermissionAnyDecorator(
675 677 'repository.read', 'repository.write', 'repository.admin')
676 678 @view_config(
677 679 route_name='repo_commit_data', request_method='GET',
678 680 renderer='json_ext', xhr=True)
679 681 def repo_commit_data(self):
680 682 commit_id = self.request.matchdict['commit_id']
681 683 self.load_default_context()
682 684
683 685 try:
684 686 return self.rhodecode_vcs_repo.get_commit(commit_id=commit_id)
685 687 except CommitDoesNotExistError as e:
686 688 return EmptyCommit(message=str(e))
687 689
688 690 @LoginRequired()
689 691 @HasRepoPermissionAnyDecorator(
690 692 'repository.read', 'repository.write', 'repository.admin')
691 693 @view_config(
692 694 route_name='repo_commit_children', request_method='GET',
693 695 renderer='json_ext', xhr=True)
694 696 def repo_commit_children(self):
695 697 commit_id = self.request.matchdict['commit_id']
696 698 self.load_default_context()
697 699
698 700 try:
699 701 commit = self.rhodecode_vcs_repo.get_commit(commit_id=commit_id)
700 702 children = commit.children
701 703 except CommitDoesNotExistError:
702 704 children = []
703 705
704 706 result = {"results": children}
705 707 return result
706 708
707 709 @LoginRequired()
708 710 @HasRepoPermissionAnyDecorator(
709 711 'repository.read', 'repository.write', 'repository.admin')
710 712 @view_config(
711 713 route_name='repo_commit_parents', request_method='GET',
712 714 renderer='json_ext')
713 715 def repo_commit_parents(self):
714 716 commit_id = self.request.matchdict['commit_id']
715 717 self.load_default_context()
716 718
717 719 try:
718 720 commit = self.rhodecode_vcs_repo.get_commit(commit_id=commit_id)
719 721 parents = commit.parents
720 722 except CommitDoesNotExistError:
721 723 parents = []
722 724 result = {"results": parents}
723 725 return result
@@ -1,1639 +1,1753 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, HTTPConflict)
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.exceptions import CommentVersionMismatch
38 38 from rhodecode.lib.ext_json import json
39 39 from rhodecode.lib.auth import (
40 40 LoginRequired, HasRepoPermissionAny, HasRepoPermissionAnyDecorator,
41 41 NotAnonymous, CSRFRequired)
42 42 from rhodecode.lib.utils2 import str2bool, safe_str, safe_unicode
43 43 from rhodecode.lib.vcs.backends.base import EmptyCommit, UpdateFailureReason
44 44 from rhodecode.lib.vcs.exceptions import (
45 45 CommitDoesNotExistError, RepositoryRequirementError, EmptyRepositoryError)
46 46 from rhodecode.model.changeset_status import ChangesetStatusModel
47 47 from rhodecode.model.comment import CommentsModel
48 48 from rhodecode.model.db import (
49 49 func, or_, PullRequest, ChangesetComment, ChangesetStatus, Repository)
50 50 from rhodecode.model.forms import PullRequestForm
51 51 from rhodecode.model.meta import Session
52 52 from rhodecode.model.pull_request import PullRequestModel, MergeCheck
53 53 from rhodecode.model.scm import ScmModel
54 54
55 55 log = logging.getLogger(__name__)
56 56
57 57
58 58 class RepoPullRequestsView(RepoAppView, DataGridAppView):
59 59
60 60 def load_default_context(self):
61 61 c = self._get_local_tmpl_context(include_app_defaults=True)
62 62 c.REVIEW_STATUS_APPROVED = ChangesetStatus.STATUS_APPROVED
63 63 c.REVIEW_STATUS_REJECTED = ChangesetStatus.STATUS_REJECTED
64 64 # backward compat., we use for OLD PRs a plain renderer
65 65 c.renderer = 'plain'
66 66 return c
67 67
68 68 def _get_pull_requests_list(
69 69 self, repo_name, source, filter_type, opened_by, statuses):
70 70
71 71 draw, start, limit = self._extract_chunk(self.request)
72 72 search_q, order_by, order_dir = self._extract_ordering(self.request)
73 73 _render = self.request.get_partial_renderer(
74 74 'rhodecode:templates/data_table/_dt_elements.mako')
75 75
76 76 # pagination
77 77
78 78 if filter_type == 'awaiting_review':
79 79 pull_requests = PullRequestModel().get_awaiting_review(
80 80 repo_name, search_q=search_q, source=source, opened_by=opened_by,
81 81 statuses=statuses, offset=start, length=limit,
82 82 order_by=order_by, order_dir=order_dir)
83 83 pull_requests_total_count = PullRequestModel().count_awaiting_review(
84 84 repo_name, search_q=search_q, source=source, statuses=statuses,
85 85 opened_by=opened_by)
86 86 elif filter_type == 'awaiting_my_review':
87 87 pull_requests = PullRequestModel().get_awaiting_my_review(
88 88 repo_name, search_q=search_q, source=source, opened_by=opened_by,
89 89 user_id=self._rhodecode_user.user_id, statuses=statuses,
90 90 offset=start, length=limit, order_by=order_by,
91 91 order_dir=order_dir)
92 92 pull_requests_total_count = PullRequestModel().count_awaiting_my_review(
93 93 repo_name, search_q=search_q, source=source, user_id=self._rhodecode_user.user_id,
94 94 statuses=statuses, opened_by=opened_by)
95 95 else:
96 96 pull_requests = PullRequestModel().get_all(
97 97 repo_name, search_q=search_q, source=source, opened_by=opened_by,
98 98 statuses=statuses, offset=start, length=limit,
99 99 order_by=order_by, order_dir=order_dir)
100 100 pull_requests_total_count = PullRequestModel().count_all(
101 101 repo_name, search_q=search_q, source=source, statuses=statuses,
102 102 opened_by=opened_by)
103 103
104 104 data = []
105 105 comments_model = CommentsModel()
106 106 for pr in pull_requests:
107 107 comments = comments_model.get_all_comments(
108 108 self.db_repo.repo_id, pull_request=pr)
109 109
110 110 data.append({
111 111 'name': _render('pullrequest_name',
112 112 pr.pull_request_id, pr.pull_request_state,
113 113 pr.work_in_progress, pr.target_repo.repo_name),
114 114 'name_raw': pr.pull_request_id,
115 115 'status': _render('pullrequest_status',
116 116 pr.calculated_review_status()),
117 117 'title': _render('pullrequest_title', pr.title, pr.description),
118 118 'description': h.escape(pr.description),
119 119 'updated_on': _render('pullrequest_updated_on',
120 120 h.datetime_to_time(pr.updated_on)),
121 121 'updated_on_raw': h.datetime_to_time(pr.updated_on),
122 122 'created_on': _render('pullrequest_updated_on',
123 123 h.datetime_to_time(pr.created_on)),
124 124 'created_on_raw': h.datetime_to_time(pr.created_on),
125 125 'state': pr.pull_request_state,
126 126 'author': _render('pullrequest_author',
127 127 pr.author.full_contact, ),
128 128 'author_raw': pr.author.full_name,
129 129 'comments': _render('pullrequest_comments', len(comments)),
130 130 'comments_raw': len(comments),
131 131 'closed': pr.is_closed(),
132 132 })
133 133
134 134 data = ({
135 135 'draw': draw,
136 136 'data': data,
137 137 'recordsTotal': pull_requests_total_count,
138 138 'recordsFiltered': pull_requests_total_count,
139 139 })
140 140 return data
141 141
142 142 @LoginRequired()
143 143 @HasRepoPermissionAnyDecorator(
144 144 'repository.read', 'repository.write', 'repository.admin')
145 145 @view_config(
146 146 route_name='pullrequest_show_all', request_method='GET',
147 147 renderer='rhodecode:templates/pullrequests/pullrequests.mako')
148 148 def pull_request_list(self):
149 149 c = self.load_default_context()
150 150
151 151 req_get = self.request.GET
152 152 c.source = str2bool(req_get.get('source'))
153 153 c.closed = str2bool(req_get.get('closed'))
154 154 c.my = str2bool(req_get.get('my'))
155 155 c.awaiting_review = str2bool(req_get.get('awaiting_review'))
156 156 c.awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
157 157
158 158 c.active = 'open'
159 159 if c.my:
160 160 c.active = 'my'
161 161 if c.closed:
162 162 c.active = 'closed'
163 163 if c.awaiting_review and not c.source:
164 164 c.active = 'awaiting'
165 165 if c.source and not c.awaiting_review:
166 166 c.active = 'source'
167 167 if c.awaiting_my_review:
168 168 c.active = 'awaiting_my'
169 169
170 170 return self._get_template_context(c)
171 171
172 172 @LoginRequired()
173 173 @HasRepoPermissionAnyDecorator(
174 174 'repository.read', 'repository.write', 'repository.admin')
175 175 @view_config(
176 176 route_name='pullrequest_show_all_data', request_method='GET',
177 177 renderer='json_ext', xhr=True)
178 178 def pull_request_list_data(self):
179 179 self.load_default_context()
180 180
181 181 # additional filters
182 182 req_get = self.request.GET
183 183 source = str2bool(req_get.get('source'))
184 184 closed = str2bool(req_get.get('closed'))
185 185 my = str2bool(req_get.get('my'))
186 186 awaiting_review = str2bool(req_get.get('awaiting_review'))
187 187 awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
188 188
189 189 filter_type = 'awaiting_review' if awaiting_review \
190 190 else 'awaiting_my_review' if awaiting_my_review \
191 191 else None
192 192
193 193 opened_by = None
194 194 if my:
195 195 opened_by = [self._rhodecode_user.user_id]
196 196
197 197 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
198 198 if closed:
199 199 statuses = [PullRequest.STATUS_CLOSED]
200 200
201 201 data = self._get_pull_requests_list(
202 202 repo_name=self.db_repo_name, source=source,
203 203 filter_type=filter_type, opened_by=opened_by, statuses=statuses)
204 204
205 205 return data
206 206
207 207 def _is_diff_cache_enabled(self, target_repo):
208 208 caching_enabled = self._get_general_setting(
209 209 target_repo, 'rhodecode_diff_cache')
210 210 log.debug('Diff caching enabled: %s', caching_enabled)
211 211 return caching_enabled
212 212
213 213 def _get_diffset(self, source_repo_name, source_repo,
214 214 ancestor_commit,
215 215 source_ref_id, target_ref_id,
216 216 target_commit, source_commit, diff_limit, file_limit,
217 217 fulldiff, hide_whitespace_changes, diff_context, use_ancestor=True):
218 218
219 219 if use_ancestor:
220 220 # we might want to not use it for versions
221 221 target_ref_id = ancestor_commit.raw_id
222 222
223 223 vcs_diff = PullRequestModel().get_diff(
224 224 source_repo, source_ref_id, target_ref_id,
225 225 hide_whitespace_changes, diff_context)
226 226
227 227 diff_processor = diffs.DiffProcessor(
228 228 vcs_diff, format='newdiff', diff_limit=diff_limit,
229 229 file_limit=file_limit, show_full_diff=fulldiff)
230 230
231 231 _parsed = diff_processor.prepare()
232 232
233 233 diffset = codeblocks.DiffSet(
234 234 repo_name=self.db_repo_name,
235 235 source_repo_name=source_repo_name,
236 236 source_node_getter=codeblocks.diffset_node_getter(target_commit),
237 237 target_node_getter=codeblocks.diffset_node_getter(source_commit),
238 238 )
239 239 diffset = self.path_filter.render_patchset_filtered(
240 240 diffset, _parsed, target_commit.raw_id, source_commit.raw_id)
241 241
242 242 return diffset
243 243
244 244 def _get_range_diffset(self, source_scm, source_repo,
245 245 commit1, commit2, diff_limit, file_limit,
246 246 fulldiff, hide_whitespace_changes, diff_context):
247 247 vcs_diff = source_scm.get_diff(
248 248 commit1, commit2,
249 249 ignore_whitespace=hide_whitespace_changes,
250 250 context=diff_context)
251 251
252 252 diff_processor = diffs.DiffProcessor(
253 253 vcs_diff, format='newdiff', diff_limit=diff_limit,
254 254 file_limit=file_limit, show_full_diff=fulldiff)
255 255
256 256 _parsed = diff_processor.prepare()
257 257
258 258 diffset = codeblocks.DiffSet(
259 259 repo_name=source_repo.repo_name,
260 260 source_node_getter=codeblocks.diffset_node_getter(commit1),
261 261 target_node_getter=codeblocks.diffset_node_getter(commit2))
262 262
263 263 diffset = self.path_filter.render_patchset_filtered(
264 264 diffset, _parsed, commit1.raw_id, commit2.raw_id)
265 265
266 266 return diffset
267 267
268 def register_comments_vars(self, c, pull_request, versions):
269 comments_model = CommentsModel()
270
271 # GENERAL COMMENTS with versions #
272 q = comments_model._all_general_comments_of_pull_request(pull_request)
273 q = q.order_by(ChangesetComment.comment_id.asc())
274 general_comments = q
275
276 # pick comments we want to render at current version
277 c.comment_versions = comments_model.aggregate_comments(
278 general_comments, versions, c.at_version_num)
279
280 # INLINE COMMENTS with versions #
281 q = comments_model._all_inline_comments_of_pull_request(pull_request)
282 q = q.order_by(ChangesetComment.comment_id.asc())
283 inline_comments = q
284
285 c.inline_versions = comments_model.aggregate_comments(
286 inline_comments, versions, c.at_version_num, inline=True)
287
288 # Comments inline+general
289 if c.at_version:
290 c.inline_comments_flat = c.inline_versions[c.at_version_num]['display']
291 c.comments = c.comment_versions[c.at_version_num]['display']
292 else:
293 c.inline_comments_flat = c.inline_versions[c.at_version_num]['until']
294 c.comments = c.comment_versions[c.at_version_num]['until']
295
296 return general_comments, inline_comments
297
268 298 @LoginRequired()
269 299 @HasRepoPermissionAnyDecorator(
270 300 'repository.read', 'repository.write', 'repository.admin')
271 301 @view_config(
272 302 route_name='pullrequest_show', request_method='GET',
273 303 renderer='rhodecode:templates/pullrequests/pullrequest_show.mako')
274 304 def pull_request_show(self):
275 305 _ = self.request.translate
276 306 c = self.load_default_context()
277 307
278 308 pull_request = PullRequest.get_or_404(
279 309 self.request.matchdict['pull_request_id'])
280 310 pull_request_id = pull_request.pull_request_id
281 311
282 312 c.state_progressing = pull_request.is_state_changing()
313 c.pr_broadcast_channel = '/repo${}$/pr/{}'.format(
314 pull_request.target_repo.repo_name, pull_request.pull_request_id)
283 315
284 316 _new_state = {
285 317 'created': PullRequest.STATE_CREATED,
286 318 }.get(self.request.GET.get('force_state'))
287 319
288 320 if c.is_super_admin and _new_state:
289 321 with pull_request.set_state(PullRequest.STATE_UPDATING, final_state=_new_state):
290 322 h.flash(
291 323 _('Pull Request state was force changed to `{}`').format(_new_state),
292 324 category='success')
293 325 Session().commit()
294 326
295 327 raise HTTPFound(h.route_path(
296 328 'pullrequest_show', repo_name=self.db_repo_name,
297 329 pull_request_id=pull_request_id))
298 330
299 331 version = self.request.GET.get('version')
300 332 from_version = self.request.GET.get('from_version') or version
301 333 merge_checks = self.request.GET.get('merge_checks')
302 334 c.fulldiff = str2bool(self.request.GET.get('fulldiff'))
335 force_refresh = str2bool(self.request.GET.get('force_refresh'))
336 c.range_diff_on = self.request.GET.get('range-diff') == "1"
303 337
304 338 # fetch global flags of ignore ws or context lines
305 339 diff_context = diffs.get_diff_context(self.request)
306 340 hide_whitespace_changes = diffs.get_diff_whitespace_flag(self.request)
307 341
308 force_refresh = str2bool(self.request.GET.get('force_refresh'))
309
310 342 (pull_request_latest,
311 343 pull_request_at_ver,
312 344 pull_request_display_obj,
313 345 at_version) = PullRequestModel().get_pr_version(
314 346 pull_request_id, version=version)
347
315 348 pr_closed = pull_request_latest.is_closed()
316 349
317 350 if pr_closed and (version or from_version):
318 # not allow to browse versions
351 # not allow to browse versions for closed PR
319 352 raise HTTPFound(h.route_path(
320 353 'pullrequest_show', repo_name=self.db_repo_name,
321 354 pull_request_id=pull_request_id))
322 355
323 356 versions = pull_request_display_obj.versions()
324 357 # used to store per-commit range diffs
325 358 c.changes = collections.OrderedDict()
326 c.range_diff_on = self.request.GET.get('range-diff') == "1"
327 359
328 360 c.at_version = at_version
329 361 c.at_version_num = (at_version
330 if at_version and at_version != 'latest'
362 if at_version and at_version != PullRequest.LATEST_VER
331 363 else None)
332 c.at_version_pos = ChangesetComment.get_index_from_version(
364
365 c.at_version_index = ChangesetComment.get_index_from_version(
333 366 c.at_version_num, versions)
334 367
335 368 (prev_pull_request_latest,
336 369 prev_pull_request_at_ver,
337 370 prev_pull_request_display_obj,
338 371 prev_at_version) = PullRequestModel().get_pr_version(
339 372 pull_request_id, version=from_version)
340 373
341 374 c.from_version = prev_at_version
342 375 c.from_version_num = (prev_at_version
343 if prev_at_version and prev_at_version != 'latest'
376 if prev_at_version and prev_at_version != PullRequest.LATEST_VER
344 377 else None)
345 c.from_version_pos = ChangesetComment.get_index_from_version(
378 c.from_version_index = ChangesetComment.get_index_from_version(
346 379 c.from_version_num, versions)
347 380
348 381 # define if we're in COMPARE mode or VIEW at version mode
349 382 compare = at_version != prev_at_version
350 383
351 384 # pull_requests repo_name we opened it against
352 385 # ie. target_repo must match
353 386 if self.db_repo_name != pull_request_at_ver.target_repo.repo_name:
354 387 log.warning('Mismatch between the current repo: %s, and target %s',
355 388 self.db_repo_name, pull_request_at_ver.target_repo.repo_name)
356 389 raise HTTPNotFound()
357 390
358 c.shadow_clone_url = PullRequestModel().get_shadow_clone_url(
359 pull_request_at_ver)
391 c.shadow_clone_url = PullRequestModel().get_shadow_clone_url(pull_request_at_ver)
360 392
361 393 c.pull_request = pull_request_display_obj
362 394 c.renderer = pull_request_at_ver.description_renderer or c.renderer
363 395 c.pull_request_latest = pull_request_latest
364 396
365 if compare or (at_version and not at_version == 'latest'):
397 # inject latest version
398 latest_ver = PullRequest.get_pr_display_object(pull_request_latest, pull_request_latest)
399 c.versions = versions + [latest_ver]
400
401 if compare or (at_version and not at_version == PullRequest.LATEST_VER):
366 402 c.allowed_to_change_status = False
367 403 c.allowed_to_update = False
368 404 c.allowed_to_merge = False
369 405 c.allowed_to_delete = False
370 406 c.allowed_to_comment = False
371 407 c.allowed_to_close = False
372 408 else:
373 409 can_change_status = PullRequestModel().check_user_change_status(
374 410 pull_request_at_ver, self._rhodecode_user)
375 411 c.allowed_to_change_status = can_change_status and not pr_closed
376 412
377 413 c.allowed_to_update = PullRequestModel().check_user_update(
378 414 pull_request_latest, self._rhodecode_user) and not pr_closed
379 415 c.allowed_to_merge = PullRequestModel().check_user_merge(
380 416 pull_request_latest, self._rhodecode_user) and not pr_closed
381 417 c.allowed_to_delete = PullRequestModel().check_user_delete(
382 418 pull_request_latest, self._rhodecode_user) and not pr_closed
383 419 c.allowed_to_comment = not pr_closed
384 420 c.allowed_to_close = c.allowed_to_merge and not pr_closed
385 421
386 422 c.forbid_adding_reviewers = False
387 423 c.forbid_author_to_review = False
388 424 c.forbid_commit_author_to_review = False
389 425
390 426 if pull_request_latest.reviewer_data and \
391 427 'rules' in pull_request_latest.reviewer_data:
392 428 rules = pull_request_latest.reviewer_data['rules'] or {}
393 429 try:
394 c.forbid_adding_reviewers = rules.get(
395 'forbid_adding_reviewers')
396 c.forbid_author_to_review = rules.get(
397 'forbid_author_to_review')
398 c.forbid_commit_author_to_review = rules.get(
399 'forbid_commit_author_to_review')
430 c.forbid_adding_reviewers = rules.get('forbid_adding_reviewers')
431 c.forbid_author_to_review = rules.get('forbid_author_to_review')
432 c.forbid_commit_author_to_review = rules.get('forbid_commit_author_to_review')
400 433 except Exception:
401 434 pass
402 435
403 436 # check merge capabilities
404 437 _merge_check = MergeCheck.validate(
405 438 pull_request_latest, auth_user=self._rhodecode_user,
406 439 translator=self.request.translate,
407 440 force_shadow_repo_refresh=force_refresh)
408 441
409 442 c.pr_merge_errors = _merge_check.error_details
410 443 c.pr_merge_possible = not _merge_check.failed
411 444 c.pr_merge_message = _merge_check.merge_msg
412 445 c.pr_merge_source_commit = _merge_check.source_commit
413 446 c.pr_merge_target_commit = _merge_check.target_commit
414 447
415 448 c.pr_merge_info = MergeCheck.get_merge_conditions(
416 449 pull_request_latest, translator=self.request.translate)
417 450
418 451 c.pull_request_review_status = _merge_check.review_status
419 452 if merge_checks:
420 453 self.request.override_renderer = \
421 454 'rhodecode:templates/pullrequests/pullrequest_merge_checks.mako'
422 455 return self._get_template_context(c)
423 456
424 comments_model = CommentsModel()
457 c.allowed_reviewers = [obj.user_id for obj in pull_request.reviewers if obj.user]
425 458
426 459 # reviewers and statuses
427 c.pull_request_reviewers = pull_request_at_ver.reviewers_statuses()
428 allowed_reviewers = [x[0].user_id for x in c.pull_request_reviewers]
460 c.pull_request_default_reviewers_data_json = json.dumps(pull_request.reviewer_data)
461 c.pull_request_set_reviewers_data_json = collections.OrderedDict({'reviewers': []})
429 462
430 # GENERAL COMMENTS with versions #
431 q = comments_model._all_general_comments_of_pull_request(pull_request_latest)
432 q = q.order_by(ChangesetComment.comment_id.asc())
433 general_comments = q
463 for review_obj, member, reasons, mandatory, status in pull_request_at_ver.reviewers_statuses():
464 member_reviewer = h.reviewer_as_json(
465 member, reasons=reasons, mandatory=mandatory,
466 user_group=review_obj.rule_user_group_data()
467 )
434 468
435 # pick comments we want to render at current version
436 c.comment_versions = comments_model.aggregate_comments(
437 general_comments, versions, c.at_version_num)
438 c.comments = c.comment_versions[c.at_version_num]['until']
469 current_review_status = status[0][1].status if status else ChangesetStatus.STATUS_NOT_REVIEWED
470 member_reviewer['review_status'] = current_review_status
471 member_reviewer['review_status_label'] = h.commit_status_lbl(current_review_status)
472 member_reviewer['allowed_to_update'] = c.allowed_to_update
473 c.pull_request_set_reviewers_data_json['reviewers'].append(member_reviewer)
439 474
440 # INLINE COMMENTS with versions #
441 q = comments_model._all_inline_comments_of_pull_request(pull_request_latest)
442 q = q.order_by(ChangesetComment.comment_id.asc())
443 inline_comments = q
475 c.pull_request_set_reviewers_data_json = json.dumps(c.pull_request_set_reviewers_data_json)
476
477
444 478
445 c.inline_versions = comments_model.aggregate_comments(
446 inline_comments, versions, c.at_version_num, inline=True)
479
480 general_comments, inline_comments = \
481 self.register_comments_vars(c, pull_request_latest, versions)
447 482
448 483 # TODOs
449 484 c.unresolved_comments = CommentsModel() \
450 .get_pull_request_unresolved_todos(pull_request)
485 .get_pull_request_unresolved_todos(pull_request_latest)
451 486 c.resolved_comments = CommentsModel() \
452 .get_pull_request_resolved_todos(pull_request)
453
454 # inject latest version
455 latest_ver = PullRequest.get_pr_display_object(
456 pull_request_latest, pull_request_latest)
457
458 c.versions = versions + [latest_ver]
487 .get_pull_request_resolved_todos(pull_request_latest)
459 488
460 489 # if we use version, then do not show later comments
461 490 # than current version
462 491 display_inline_comments = collections.defaultdict(
463 492 lambda: collections.defaultdict(list))
464 493 for co in inline_comments:
465 494 if c.at_version_num:
466 495 # pick comments that are at least UPTO given version, so we
467 496 # don't render comments for higher version
468 497 should_render = co.pull_request_version_id and \
469 498 co.pull_request_version_id <= c.at_version_num
470 499 else:
471 500 # showing all, for 'latest'
472 501 should_render = True
473 502
474 503 if should_render:
475 504 display_inline_comments[co.f_path][co.line_no].append(co)
476 505
477 506 # load diff data into template context, if we use compare mode then
478 507 # diff is calculated based on changes between versions of PR
479 508
480 509 source_repo = pull_request_at_ver.source_repo
481 510 source_ref_id = pull_request_at_ver.source_ref_parts.commit_id
482 511
483 512 target_repo = pull_request_at_ver.target_repo
484 513 target_ref_id = pull_request_at_ver.target_ref_parts.commit_id
485 514
486 515 if compare:
487 516 # in compare switch the diff base to latest commit from prev version
488 517 target_ref_id = prev_pull_request_display_obj.revisions[0]
489 518
490 519 # despite opening commits for bookmarks/branches/tags, we always
491 520 # convert this to rev to prevent changes after bookmark or branch change
492 521 c.source_ref_type = 'rev'
493 522 c.source_ref = source_ref_id
494 523
495 524 c.target_ref_type = 'rev'
496 525 c.target_ref = target_ref_id
497 526
498 527 c.source_repo = source_repo
499 528 c.target_repo = target_repo
500 529
501 530 c.commit_ranges = []
502 531 source_commit = EmptyCommit()
503 532 target_commit = EmptyCommit()
504 533 c.missing_requirements = False
505 534
506 535 source_scm = source_repo.scm_instance()
507 536 target_scm = target_repo.scm_instance()
508 537
509 538 shadow_scm = None
510 539 try:
511 540 shadow_scm = pull_request_latest.get_shadow_repo()
512 541 except Exception:
513 542 log.debug('Failed to get shadow repo', exc_info=True)
514 543 # try first the existing source_repo, and then shadow
515 544 # repo if we can obtain one
516 545 commits_source_repo = source_scm
517 546 if shadow_scm:
518 547 commits_source_repo = shadow_scm
519 548
520 549 c.commits_source_repo = commits_source_repo
521 550 c.ancestor = None # set it to None, to hide it from PR view
522 551
523 552 # empty version means latest, so we keep this to prevent
524 553 # double caching
525 version_normalized = version or 'latest'
526 from_version_normalized = from_version or 'latest'
554 version_normalized = version or PullRequest.LATEST_VER
555 from_version_normalized = from_version or PullRequest.LATEST_VER
527 556
528 557 cache_path = self.rhodecode_vcs_repo.get_create_shadow_cache_pr_path(target_repo)
529 558 cache_file_path = diff_cache_exist(
530 559 cache_path, 'pull_request', pull_request_id, version_normalized,
531 560 from_version_normalized, source_ref_id, target_ref_id,
532 561 hide_whitespace_changes, diff_context, c.fulldiff)
533 562
534 563 caching_enabled = self._is_diff_cache_enabled(c.target_repo)
535 564 force_recache = self.get_recache_flag()
536 565
537 566 cached_diff = None
538 567 if caching_enabled:
539 568 cached_diff = load_cached_diff(cache_file_path)
540 569
541 570 has_proper_commit_cache = (
542 571 cached_diff and cached_diff.get('commits')
543 572 and len(cached_diff.get('commits', [])) == 5
544 573 and cached_diff.get('commits')[0]
545 574 and cached_diff.get('commits')[3])
546 575
547 576 if not force_recache and not c.range_diff_on and has_proper_commit_cache:
548 577 diff_commit_cache = \
549 578 (ancestor_commit, commit_cache, missing_requirements,
550 579 source_commit, target_commit) = cached_diff['commits']
551 580 else:
552 581 # NOTE(marcink): we reach potentially unreachable errors when a PR has
553 582 # merge errors resulting in potentially hidden commits in the shadow repo.
554 583 maybe_unreachable = _merge_check.MERGE_CHECK in _merge_check.error_details \
555 584 and _merge_check.merge_response
556 585 maybe_unreachable = maybe_unreachable \
557 586 and _merge_check.merge_response.metadata.get('unresolved_files')
558 587 log.debug("Using unreachable commits due to MERGE_CHECK in merge simulation")
559 588 diff_commit_cache = \
560 589 (ancestor_commit, commit_cache, missing_requirements,
561 590 source_commit, target_commit) = self.get_commits(
562 591 commits_source_repo,
563 592 pull_request_at_ver,
564 593 source_commit,
565 594 source_ref_id,
566 595 source_scm,
567 596 target_commit,
568 597 target_ref_id,
569 598 target_scm,
570 599 maybe_unreachable=maybe_unreachable)
571 600
572 601 # register our commit range
573 602 for comm in commit_cache.values():
574 603 c.commit_ranges.append(comm)
575 604
576 605 c.missing_requirements = missing_requirements
577 606 c.ancestor_commit = ancestor_commit
578 607 c.statuses = source_repo.statuses(
579 608 [x.raw_id for x in c.commit_ranges])
580 609
581 610 # auto collapse if we have more than limit
582 611 collapse_limit = diffs.DiffProcessor._collapse_commits_over
583 612 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
584 613 c.compare_mode = compare
585 614
586 615 # diff_limit is the old behavior, will cut off the whole diff
587 616 # if the limit is applied otherwise will just hide the
588 617 # big files from the front-end
589 618 diff_limit = c.visual.cut_off_limit_diff
590 619 file_limit = c.visual.cut_off_limit_file
591 620
592 621 c.missing_commits = False
593 622 if (c.missing_requirements
594 623 or isinstance(source_commit, EmptyCommit)
595 624 or source_commit == target_commit):
596 625
597 626 c.missing_commits = True
598 627 else:
599 628 c.inline_comments = display_inline_comments
600 629
601 630 use_ancestor = True
602 631 if from_version_normalized != version_normalized:
603 632 use_ancestor = False
604 633
605 634 has_proper_diff_cache = cached_diff and cached_diff.get('commits')
606 635 if not force_recache and has_proper_diff_cache:
607 636 c.diffset = cached_diff['diff']
608 637 else:
609 638 try:
610 639 c.diffset = self._get_diffset(
611 640 c.source_repo.repo_name, commits_source_repo,
612 641 c.ancestor_commit,
613 642 source_ref_id, target_ref_id,
614 643 target_commit, source_commit,
615 644 diff_limit, file_limit, c.fulldiff,
616 645 hide_whitespace_changes, diff_context,
617 646 use_ancestor=use_ancestor
618 )
647 )
619 648
620 649 # save cached diff
621 650 if caching_enabled:
622 651 cache_diff(cache_file_path, c.diffset, diff_commit_cache)
623 652 except CommitDoesNotExistError:
624 653 log.exception('Failed to generate diffset')
625 654 c.missing_commits = True
626 655
627 656 if not c.missing_commits:
628 657
629 658 c.limited_diff = c.diffset.limited_diff
630 659
631 660 # calculate removed files that are bound to comments
632 661 comment_deleted_files = [
633 662 fname for fname in display_inline_comments
634 663 if fname not in c.diffset.file_stats]
635 664
636 665 c.deleted_files_comments = collections.defaultdict(dict)
637 666 for fname, per_line_comments in display_inline_comments.items():
638 667 if fname in comment_deleted_files:
639 668 c.deleted_files_comments[fname]['stats'] = 0
640 669 c.deleted_files_comments[fname]['comments'] = list()
641 670 for lno, comments in per_line_comments.items():
642 671 c.deleted_files_comments[fname]['comments'].extend(comments)
643 672
644 673 # maybe calculate the range diff
645 674 if c.range_diff_on:
646 675 # TODO(marcink): set whitespace/context
647 676 context_lcl = 3
648 677 ign_whitespace_lcl = False
649 678
650 679 for commit in c.commit_ranges:
651 680 commit2 = commit
652 681 commit1 = commit.first_parent
653 682
654 683 range_diff_cache_file_path = diff_cache_exist(
655 684 cache_path, 'diff', commit.raw_id,
656 685 ign_whitespace_lcl, context_lcl, c.fulldiff)
657 686
658 687 cached_diff = None
659 688 if caching_enabled:
660 689 cached_diff = load_cached_diff(range_diff_cache_file_path)
661 690
662 691 has_proper_diff_cache = cached_diff and cached_diff.get('diff')
663 692 if not force_recache and has_proper_diff_cache:
664 693 diffset = cached_diff['diff']
665 694 else:
666 695 diffset = self._get_range_diffset(
667 696 commits_source_repo, source_repo,
668 697 commit1, commit2, diff_limit, file_limit,
669 698 c.fulldiff, ign_whitespace_lcl, context_lcl
670 699 )
671 700
672 701 # save cached diff
673 702 if caching_enabled:
674 703 cache_diff(range_diff_cache_file_path, diffset, None)
675 704
676 705 c.changes[commit.raw_id] = diffset
677 706
678 707 # this is a hack to properly display links, when creating PR, the
679 708 # compare view and others uses different notation, and
680 709 # compare_commits.mako renders links based on the target_repo.
681 710 # We need to swap that here to generate it properly on the html side
682 711 c.target_repo = c.source_repo
683 712
684 713 c.commit_statuses = ChangesetStatus.STATUSES
685 714
686 715 c.show_version_changes = not pr_closed
687 716 if c.show_version_changes:
688 717 cur_obj = pull_request_at_ver
689 718 prev_obj = prev_pull_request_at_ver
690 719
691 720 old_commit_ids = prev_obj.revisions
692 721 new_commit_ids = cur_obj.revisions
693 722 commit_changes = PullRequestModel()._calculate_commit_id_changes(
694 723 old_commit_ids, new_commit_ids)
695 724 c.commit_changes_summary = commit_changes
696 725
697 726 # calculate the diff for commits between versions
698 727 c.commit_changes = []
699 728
700 729 def mark(cs, fw):
701 730 return list(h.itertools.izip_longest([], cs, fillvalue=fw))
702 731
703 732 for c_type, raw_id in mark(commit_changes.added, 'a') \
704 733 + mark(commit_changes.removed, 'r') \
705 734 + mark(commit_changes.common, 'c'):
706 735
707 736 if raw_id in commit_cache:
708 737 commit = commit_cache[raw_id]
709 738 else:
710 739 try:
711 740 commit = commits_source_repo.get_commit(raw_id)
712 741 except CommitDoesNotExistError:
713 742 # in case we fail extracting still use "dummy" commit
714 743 # for display in commit diff
715 744 commit = h.AttributeDict(
716 745 {'raw_id': raw_id,
717 746 'message': 'EMPTY or MISSING COMMIT'})
718 747 c.commit_changes.append([c_type, commit])
719 748
720 749 # current user review statuses for each version
721 750 c.review_versions = {}
722 if self._rhodecode_user.user_id in allowed_reviewers:
751 if self._rhodecode_user.user_id in c.allowed_reviewers:
723 752 for co in general_comments:
724 753 if co.author.user_id == self._rhodecode_user.user_id:
725 754 status = co.status_change
726 755 if status:
727 756 _ver_pr = status[0].comment.pull_request_version_id
728 757 c.review_versions[_ver_pr] = status[0]
729 758
730 759 return self._get_template_context(c)
731 760
732 761 def get_commits(
733 762 self, commits_source_repo, pull_request_at_ver, source_commit,
734 763 source_ref_id, source_scm, target_commit, target_ref_id, target_scm,
735 764 maybe_unreachable=False):
736 765
737 766 commit_cache = collections.OrderedDict()
738 767 missing_requirements = False
739 768
740 769 try:
741 770 pre_load = ["author", "date", "message", "branch", "parents"]
742 771
743 772 pull_request_commits = pull_request_at_ver.revisions
744 773 log.debug('Loading %s commits from %s',
745 774 len(pull_request_commits), commits_source_repo)
746 775
747 776 for rev in pull_request_commits:
748 777 comm = commits_source_repo.get_commit(commit_id=rev, pre_load=pre_load,
749 778 maybe_unreachable=maybe_unreachable)
750 779 commit_cache[comm.raw_id] = comm
751 780
752 781 # Order here matters, we first need to get target, and then
753 782 # the source
754 783 target_commit = commits_source_repo.get_commit(
755 784 commit_id=safe_str(target_ref_id))
756 785
757 786 source_commit = commits_source_repo.get_commit(
758 787 commit_id=safe_str(source_ref_id), maybe_unreachable=True)
759 788 except CommitDoesNotExistError:
760 789 log.warning('Failed to get commit from `{}` repo'.format(
761 790 commits_source_repo), exc_info=True)
762 791 except RepositoryRequirementError:
763 792 log.warning('Failed to get all required data from repo', exc_info=True)
764 793 missing_requirements = True
765 794
766 795 pr_ancestor_id = pull_request_at_ver.common_ancestor_id
767 796
768 797 try:
769 798 ancestor_commit = source_scm.get_commit(pr_ancestor_id)
770 799 except Exception:
771 800 ancestor_commit = None
772 801
773 802 return ancestor_commit, commit_cache, missing_requirements, source_commit, target_commit
774 803
775 804 def assure_not_empty_repo(self):
776 805 _ = self.request.translate
777 806
778 807 try:
779 808 self.db_repo.scm_instance().get_commit()
780 809 except EmptyRepositoryError:
781 810 h.flash(h.literal(_('There are no commits yet')),
782 811 category='warning')
783 812 raise HTTPFound(
784 813 h.route_path('repo_summary', repo_name=self.db_repo.repo_name))
785 814
786 815 @LoginRequired()
787 816 @NotAnonymous()
788 817 @HasRepoPermissionAnyDecorator(
789 818 'repository.read', 'repository.write', 'repository.admin')
790 819 @view_config(
791 820 route_name='pullrequest_new', request_method='GET',
792 821 renderer='rhodecode:templates/pullrequests/pullrequest.mako')
793 822 def pull_request_new(self):
794 823 _ = self.request.translate
795 824 c = self.load_default_context()
796 825
797 826 self.assure_not_empty_repo()
798 827 source_repo = self.db_repo
799 828
800 829 commit_id = self.request.GET.get('commit')
801 830 branch_ref = self.request.GET.get('branch')
802 831 bookmark_ref = self.request.GET.get('bookmark')
803 832
804 833 try:
805 834 source_repo_data = PullRequestModel().generate_repo_data(
806 835 source_repo, commit_id=commit_id,
807 836 branch=branch_ref, bookmark=bookmark_ref,
808 837 translator=self.request.translate)
809 838 except CommitDoesNotExistError as e:
810 839 log.exception(e)
811 840 h.flash(_('Commit does not exist'), 'error')
812 841 raise HTTPFound(
813 842 h.route_path('pullrequest_new', repo_name=source_repo.repo_name))
814 843
815 844 default_target_repo = source_repo
816 845
817 846 if source_repo.parent and c.has_origin_repo_read_perm:
818 847 parent_vcs_obj = source_repo.parent.scm_instance()
819 848 if parent_vcs_obj and not parent_vcs_obj.is_empty():
820 849 # change default if we have a parent repo
821 850 default_target_repo = source_repo.parent
822 851
823 852 target_repo_data = PullRequestModel().generate_repo_data(
824 853 default_target_repo, translator=self.request.translate)
825 854
826 855 selected_source_ref = source_repo_data['refs']['selected_ref']
827 856 title_source_ref = ''
828 857 if selected_source_ref:
829 858 title_source_ref = selected_source_ref.split(':', 2)[1]
830 859 c.default_title = PullRequestModel().generate_pullrequest_title(
831 860 source=source_repo.repo_name,
832 861 source_ref=title_source_ref,
833 862 target=default_target_repo.repo_name
834 863 )
835 864
836 865 c.default_repo_data = {
837 866 'source_repo_name': source_repo.repo_name,
838 867 'source_refs_json': json.dumps(source_repo_data),
839 868 'target_repo_name': default_target_repo.repo_name,
840 869 'target_refs_json': json.dumps(target_repo_data),
841 870 }
842 871 c.default_source_ref = selected_source_ref
843 872
844 873 return self._get_template_context(c)
845 874
846 875 @LoginRequired()
847 876 @NotAnonymous()
848 877 @HasRepoPermissionAnyDecorator(
849 878 'repository.read', 'repository.write', 'repository.admin')
850 879 @view_config(
851 880 route_name='pullrequest_repo_refs', request_method='GET',
852 881 renderer='json_ext', xhr=True)
853 882 def pull_request_repo_refs(self):
854 883 self.load_default_context()
855 884 target_repo_name = self.request.matchdict['target_repo_name']
856 885 repo = Repository.get_by_repo_name(target_repo_name)
857 886 if not repo:
858 887 raise HTTPNotFound()
859 888
860 889 target_perm = HasRepoPermissionAny(
861 890 'repository.read', 'repository.write', 'repository.admin')(
862 891 target_repo_name)
863 892 if not target_perm:
864 893 raise HTTPNotFound()
865 894
866 895 return PullRequestModel().generate_repo_data(
867 896 repo, translator=self.request.translate)
868 897
869 898 @LoginRequired()
870 899 @NotAnonymous()
871 900 @HasRepoPermissionAnyDecorator(
872 901 'repository.read', 'repository.write', 'repository.admin')
873 902 @view_config(
874 903 route_name='pullrequest_repo_targets', request_method='GET',
875 904 renderer='json_ext', xhr=True)
876 905 def pullrequest_repo_targets(self):
877 906 _ = self.request.translate
878 907 filter_query = self.request.GET.get('query')
879 908
880 909 # get the parents
881 910 parent_target_repos = []
882 911 if self.db_repo.parent:
883 912 parents_query = Repository.query() \
884 913 .order_by(func.length(Repository.repo_name)) \
885 914 .filter(Repository.fork_id == self.db_repo.parent.repo_id)
886 915
887 916 if filter_query:
888 917 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
889 918 parents_query = parents_query.filter(
890 919 Repository.repo_name.ilike(ilike_expression))
891 920 parents = parents_query.limit(20).all()
892 921
893 922 for parent in parents:
894 923 parent_vcs_obj = parent.scm_instance()
895 924 if parent_vcs_obj and not parent_vcs_obj.is_empty():
896 925 parent_target_repos.append(parent)
897 926
898 927 # get other forks, and repo itself
899 928 query = Repository.query() \
900 929 .order_by(func.length(Repository.repo_name)) \
901 930 .filter(
902 931 or_(Repository.repo_id == self.db_repo.repo_id, # repo itself
903 932 Repository.fork_id == self.db_repo.repo_id) # forks of this repo
904 933 ) \
905 934 .filter(~Repository.repo_id.in_([x.repo_id for x in parent_target_repos]))
906 935
907 936 if filter_query:
908 937 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
909 938 query = query.filter(Repository.repo_name.ilike(ilike_expression))
910 939
911 940 limit = max(20 - len(parent_target_repos), 5) # not less then 5
912 941 target_repos = query.limit(limit).all()
913 942
914 943 all_target_repos = target_repos + parent_target_repos
915 944
916 945 repos = []
917 946 # This checks permissions to the repositories
918 947 for obj in ScmModel().get_repos(all_target_repos):
919 948 repos.append({
920 949 'id': obj['name'],
921 950 'text': obj['name'],
922 951 'type': 'repo',
923 952 'repo_id': obj['dbrepo']['repo_id'],
924 953 'repo_type': obj['dbrepo']['repo_type'],
925 954 'private': obj['dbrepo']['private'],
926 955
927 956 })
928 957
929 958 data = {
930 959 'more': False,
931 960 'results': [{
932 961 'text': _('Repositories'),
933 962 'children': repos
934 963 }] if repos else []
935 964 }
936 965 return data
937 966
938 967 @LoginRequired()
939 968 @NotAnonymous()
940 969 @HasRepoPermissionAnyDecorator(
941 970 'repository.read', 'repository.write', 'repository.admin')
971 @view_config(
972 route_name='pullrequest_comments', request_method='POST',
973 renderer='string', xhr=True)
974 def pullrequest_comments(self):
975 self.load_default_context()
976
977 pull_request = PullRequest.get_or_404(
978 self.request.matchdict['pull_request_id'])
979 pull_request_id = pull_request.pull_request_id
980 version = self.request.GET.get('version')
981
982 _render = self.request.get_partial_renderer(
983 'rhodecode:templates/pullrequests/pullrequest_show.mako')
984 c = _render.get_call_context()
985
986 (pull_request_latest,
987 pull_request_at_ver,
988 pull_request_display_obj,
989 at_version) = PullRequestModel().get_pr_version(
990 pull_request_id, version=version)
991 versions = pull_request_display_obj.versions()
992 latest_ver = PullRequest.get_pr_display_object(pull_request_latest, pull_request_latest)
993 c.versions = versions + [latest_ver]
994
995 c.at_version = at_version
996 c.at_version_num = (at_version
997 if at_version and at_version != PullRequest.LATEST_VER
998 else None)
999
1000 self.register_comments_vars(c, pull_request_latest, versions)
1001 all_comments = c.inline_comments_flat + c.comments
1002 return _render('comments_table', all_comments, len(all_comments))
1003
1004 @LoginRequired()
1005 @NotAnonymous()
1006 @HasRepoPermissionAnyDecorator(
1007 'repository.read', 'repository.write', 'repository.admin')
1008 @view_config(
1009 route_name='pullrequest_todos', request_method='POST',
1010 renderer='string', xhr=True)
1011 def pullrequest_todos(self):
1012 self.load_default_context()
1013
1014 pull_request = PullRequest.get_or_404(
1015 self.request.matchdict['pull_request_id'])
1016 pull_request_id = pull_request.pull_request_id
1017 version = self.request.GET.get('version')
1018
1019 _render = self.request.get_partial_renderer(
1020 'rhodecode:templates/pullrequests/pullrequest_show.mako')
1021 c = _render.get_call_context()
1022 (pull_request_latest,
1023 pull_request_at_ver,
1024 pull_request_display_obj,
1025 at_version) = PullRequestModel().get_pr_version(
1026 pull_request_id, version=version)
1027 versions = pull_request_display_obj.versions()
1028 latest_ver = PullRequest.get_pr_display_object(pull_request_latest, pull_request_latest)
1029 c.versions = versions + [latest_ver]
1030
1031 c.at_version = at_version
1032 c.at_version_num = (at_version
1033 if at_version and at_version != PullRequest.LATEST_VER
1034 else None)
1035
1036 c.unresolved_comments = CommentsModel() \
1037 .get_pull_request_unresolved_todos(pull_request)
1038 c.resolved_comments = CommentsModel() \
1039 .get_pull_request_resolved_todos(pull_request)
1040
1041 all_comments = c.unresolved_comments + c.resolved_comments
1042 return _render('comments_table', all_comments, len(c.unresolved_comments), todo_comments=True)
1043
1044 @LoginRequired()
1045 @NotAnonymous()
1046 @HasRepoPermissionAnyDecorator(
1047 'repository.read', 'repository.write', 'repository.admin')
942 1048 @CSRFRequired()
943 1049 @view_config(
944 1050 route_name='pullrequest_create', request_method='POST',
945 1051 renderer=None)
946 1052 def pull_request_create(self):
947 1053 _ = self.request.translate
948 1054 self.assure_not_empty_repo()
949 1055 self.load_default_context()
950 1056
951 1057 controls = peppercorn.parse(self.request.POST.items())
952 1058
953 1059 try:
954 1060 form = PullRequestForm(
955 1061 self.request.translate, self.db_repo.repo_id)()
956 1062 _form = form.to_python(controls)
957 1063 except formencode.Invalid as errors:
958 1064 if errors.error_dict.get('revisions'):
959 1065 msg = 'Revisions: %s' % errors.error_dict['revisions']
960 1066 elif errors.error_dict.get('pullrequest_title'):
961 1067 msg = errors.error_dict.get('pullrequest_title')
962 1068 else:
963 1069 msg = _('Error creating pull request: {}').format(errors)
964 1070 log.exception(msg)
965 1071 h.flash(msg, 'error')
966 1072
967 1073 # would rather just go back to form ...
968 1074 raise HTTPFound(
969 1075 h.route_path('pullrequest_new', repo_name=self.db_repo_name))
970 1076
971 1077 source_repo = _form['source_repo']
972 1078 source_ref = _form['source_ref']
973 1079 target_repo = _form['target_repo']
974 1080 target_ref = _form['target_ref']
975 1081 commit_ids = _form['revisions'][::-1]
976 1082 common_ancestor_id = _form['common_ancestor']
977 1083
978 1084 # find the ancestor for this pr
979 1085 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
980 1086 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
981 1087
982 1088 if not (source_db_repo or target_db_repo):
983 1089 h.flash(_('source_repo or target repo not found'), category='error')
984 1090 raise HTTPFound(
985 1091 h.route_path('pullrequest_new', repo_name=self.db_repo_name))
986 1092
987 1093 # re-check permissions again here
988 1094 # source_repo we must have read permissions
989 1095
990 1096 source_perm = HasRepoPermissionAny(
991 1097 'repository.read', 'repository.write', 'repository.admin')(
992 1098 source_db_repo.repo_name)
993 1099 if not source_perm:
994 1100 msg = _('Not Enough permissions to source repo `{}`.'.format(
995 1101 source_db_repo.repo_name))
996 1102 h.flash(msg, category='error')
997 1103 # copy the args back to redirect
998 1104 org_query = self.request.GET.mixed()
999 1105 raise HTTPFound(
1000 1106 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
1001 1107 _query=org_query))
1002 1108
1003 1109 # target repo we must have read permissions, and also later on
1004 1110 # we want to check branch permissions here
1005 1111 target_perm = HasRepoPermissionAny(
1006 1112 'repository.read', 'repository.write', 'repository.admin')(
1007 1113 target_db_repo.repo_name)
1008 1114 if not target_perm:
1009 1115 msg = _('Not Enough permissions to target repo `{}`.'.format(
1010 1116 target_db_repo.repo_name))
1011 1117 h.flash(msg, category='error')
1012 1118 # copy the args back to redirect
1013 1119 org_query = self.request.GET.mixed()
1014 1120 raise HTTPFound(
1015 1121 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
1016 1122 _query=org_query))
1017 1123
1018 1124 source_scm = source_db_repo.scm_instance()
1019 1125 target_scm = target_db_repo.scm_instance()
1020 1126
1021 1127 source_commit = source_scm.get_commit(source_ref.split(':')[-1])
1022 1128 target_commit = target_scm.get_commit(target_ref.split(':')[-1])
1023 1129
1024 1130 ancestor = source_scm.get_common_ancestor(
1025 1131 source_commit.raw_id, target_commit.raw_id, target_scm)
1026 1132
1027 1133 # recalculate target ref based on ancestor
1028 1134 target_ref_type, target_ref_name, __ = _form['target_ref'].split(':')
1029 1135 target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
1030 1136
1031 1137 get_default_reviewers_data, validate_default_reviewers = \
1032 1138 PullRequestModel().get_reviewer_functions()
1033 1139
1034 1140 # recalculate reviewers logic, to make sure we can validate this
1035 1141 reviewer_rules = get_default_reviewers_data(
1036 1142 self._rhodecode_db_user, source_db_repo,
1037 1143 source_commit, target_db_repo, target_commit)
1038 1144
1039 1145 given_reviewers = _form['review_members']
1040 1146 reviewers = validate_default_reviewers(
1041 1147 given_reviewers, reviewer_rules)
1042 1148
1043 1149 pullrequest_title = _form['pullrequest_title']
1044 1150 title_source_ref = source_ref.split(':', 2)[1]
1045 1151 if not pullrequest_title:
1046 1152 pullrequest_title = PullRequestModel().generate_pullrequest_title(
1047 1153 source=source_repo,
1048 1154 source_ref=title_source_ref,
1049 1155 target=target_repo
1050 1156 )
1051 1157
1052 1158 description = _form['pullrequest_desc']
1053 1159 description_renderer = _form['description_renderer']
1054 1160
1055 1161 try:
1056 1162 pull_request = PullRequestModel().create(
1057 1163 created_by=self._rhodecode_user.user_id,
1058 1164 source_repo=source_repo,
1059 1165 source_ref=source_ref,
1060 1166 target_repo=target_repo,
1061 1167 target_ref=target_ref,
1062 1168 revisions=commit_ids,
1063 1169 common_ancestor_id=common_ancestor_id,
1064 1170 reviewers=reviewers,
1065 1171 title=pullrequest_title,
1066 1172 description=description,
1067 1173 description_renderer=description_renderer,
1068 1174 reviewer_data=reviewer_rules,
1069 1175 auth_user=self._rhodecode_user
1070 1176 )
1071 1177 Session().commit()
1072 1178
1073 1179 h.flash(_('Successfully opened new pull request'),
1074 1180 category='success')
1075 1181 except Exception:
1076 1182 msg = _('Error occurred during creation of this pull request.')
1077 1183 log.exception(msg)
1078 1184 h.flash(msg, category='error')
1079 1185
1080 1186 # copy the args back to redirect
1081 1187 org_query = self.request.GET.mixed()
1082 1188 raise HTTPFound(
1083 1189 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
1084 1190 _query=org_query))
1085 1191
1086 1192 raise HTTPFound(
1087 1193 h.route_path('pullrequest_show', repo_name=target_repo,
1088 1194 pull_request_id=pull_request.pull_request_id))
1089 1195
1090 1196 @LoginRequired()
1091 1197 @NotAnonymous()
1092 1198 @HasRepoPermissionAnyDecorator(
1093 1199 'repository.read', 'repository.write', 'repository.admin')
1094 1200 @CSRFRequired()
1095 1201 @view_config(
1096 1202 route_name='pullrequest_update', request_method='POST',
1097 1203 renderer='json_ext')
1098 1204 def pull_request_update(self):
1099 1205 pull_request = PullRequest.get_or_404(
1100 1206 self.request.matchdict['pull_request_id'])
1101 1207 _ = self.request.translate
1102 1208
1103 self.load_default_context()
1209 c = self.load_default_context()
1104 1210 redirect_url = None
1105 1211
1106 1212 if pull_request.is_closed():
1107 1213 log.debug('update: forbidden because pull request is closed')
1108 1214 msg = _(u'Cannot update closed pull requests.')
1109 1215 h.flash(msg, category='error')
1110 1216 return {'response': True,
1111 1217 'redirect_url': redirect_url}
1112 1218
1113 1219 is_state_changing = pull_request.is_state_changing()
1220 c.pr_broadcast_channel = '/repo${}$/pr/{}'.format(
1221 pull_request.target_repo.repo_name, pull_request.pull_request_id)
1114 1222
1115 1223 # only owner or admin can update it
1116 1224 allowed_to_update = PullRequestModel().check_user_update(
1117 1225 pull_request, self._rhodecode_user)
1118 1226 if allowed_to_update:
1119 1227 controls = peppercorn.parse(self.request.POST.items())
1120 1228 force_refresh = str2bool(self.request.POST.get('force_refresh'))
1121 1229
1122 1230 if 'review_members' in controls:
1123 1231 self._update_reviewers(
1124 1232 pull_request, controls['review_members'],
1125 1233 pull_request.reviewer_data)
1126 1234 elif str2bool(self.request.POST.get('update_commits', 'false')):
1127 1235 if is_state_changing:
1128 1236 log.debug('commits update: forbidden because pull request is in state %s',
1129 1237 pull_request.pull_request_state)
1130 1238 msg = _(u'Cannot update pull requests commits in state other than `{}`. '
1131 1239 u'Current state is: `{}`').format(
1132 1240 PullRequest.STATE_CREATED, pull_request.pull_request_state)
1133 1241 h.flash(msg, category='error')
1134 1242 return {'response': True,
1135 1243 'redirect_url': redirect_url}
1136 1244
1137 self._update_commits(pull_request)
1245 self._update_commits(c, pull_request)
1138 1246 if force_refresh:
1139 1247 redirect_url = h.route_path(
1140 1248 'pullrequest_show', repo_name=self.db_repo_name,
1141 1249 pull_request_id=pull_request.pull_request_id,
1142 1250 _query={"force_refresh": 1})
1143 1251 elif str2bool(self.request.POST.get('edit_pull_request', 'false')):
1144 1252 self._edit_pull_request(pull_request)
1145 1253 else:
1146 1254 raise HTTPBadRequest()
1147 1255
1148 1256 return {'response': True,
1149 1257 'redirect_url': redirect_url}
1150 1258 raise HTTPForbidden()
1151 1259
1152 1260 def _edit_pull_request(self, pull_request):
1153 1261 _ = self.request.translate
1154 1262
1155 1263 try:
1156 1264 PullRequestModel().edit(
1157 1265 pull_request,
1158 1266 self.request.POST.get('title'),
1159 1267 self.request.POST.get('description'),
1160 1268 self.request.POST.get('description_renderer'),
1161 1269 self._rhodecode_user)
1162 1270 except ValueError:
1163 1271 msg = _(u'Cannot update closed pull requests.')
1164 1272 h.flash(msg, category='error')
1165 1273 return
1166 1274 else:
1167 1275 Session().commit()
1168 1276
1169 1277 msg = _(u'Pull request title & description updated.')
1170 1278 h.flash(msg, category='success')
1171 1279 return
1172 1280
1173 def _update_commits(self, pull_request):
1281 def _update_commits(self, c, pull_request):
1174 1282 _ = self.request.translate
1175 1283
1176 1284 with pull_request.set_state(PullRequest.STATE_UPDATING):
1177 1285 resp = PullRequestModel().update_commits(
1178 1286 pull_request, self._rhodecode_db_user)
1179 1287
1180 1288 if resp.executed:
1181 1289
1182 1290 if resp.target_changed and resp.source_changed:
1183 1291 changed = 'target and source repositories'
1184 1292 elif resp.target_changed and not resp.source_changed:
1185 1293 changed = 'target repository'
1186 1294 elif not resp.target_changed and resp.source_changed:
1187 1295 changed = 'source repository'
1188 1296 else:
1189 1297 changed = 'nothing'
1190 1298
1191 1299 msg = _(u'Pull request updated to "{source_commit_id}" with '
1192 1300 u'{count_added} added, {count_removed} removed commits. '
1193 1301 u'Source of changes: {change_source}')
1194 1302 msg = msg.format(
1195 1303 source_commit_id=pull_request.source_ref_parts.commit_id,
1196 1304 count_added=len(resp.changes.added),
1197 1305 count_removed=len(resp.changes.removed),
1198 1306 change_source=changed)
1199 1307 h.flash(msg, category='success')
1200 1308
1201 channel = '/repo${}$/pr/{}'.format(
1202 pull_request.target_repo.repo_name, pull_request.pull_request_id)
1203 1309 message = msg + (
1204 1310 ' - <a onclick="window.location.reload()">'
1205 1311 '<strong>{}</strong></a>'.format(_('Reload page')))
1312
1313 message_obj = {
1314 'message': message,
1315 'level': 'success',
1316 'topic': '/notifications'
1317 }
1318
1206 1319 channelstream.post_message(
1207 channel, message, self._rhodecode_user.username,
1320 c.pr_broadcast_channel, message_obj, self._rhodecode_user.username,
1208 1321 registry=self.request.registry)
1209 1322 else:
1210 1323 msg = PullRequestModel.UPDATE_STATUS_MESSAGES[resp.reason]
1211 1324 warning_reasons = [
1212 1325 UpdateFailureReason.NO_CHANGE,
1213 1326 UpdateFailureReason.WRONG_REF_TYPE,
1214 1327 ]
1215 1328 category = 'warning' if resp.reason in warning_reasons else 'error'
1216 1329 h.flash(msg, category=category)
1217 1330
1218 1331 @LoginRequired()
1219 1332 @NotAnonymous()
1220 1333 @HasRepoPermissionAnyDecorator(
1221 1334 'repository.read', 'repository.write', 'repository.admin')
1222 1335 @CSRFRequired()
1223 1336 @view_config(
1224 1337 route_name='pullrequest_merge', request_method='POST',
1225 1338 renderer='json_ext')
1226 1339 def pull_request_merge(self):
1227 1340 """
1228 1341 Merge will perform a server-side merge of the specified
1229 1342 pull request, if the pull request is approved and mergeable.
1230 1343 After successful merging, the pull request is automatically
1231 1344 closed, with a relevant comment.
1232 1345 """
1233 1346 pull_request = PullRequest.get_or_404(
1234 1347 self.request.matchdict['pull_request_id'])
1235 1348 _ = self.request.translate
1236 1349
1237 1350 if pull_request.is_state_changing():
1238 1351 log.debug('show: forbidden because pull request is in state %s',
1239 1352 pull_request.pull_request_state)
1240 1353 msg = _(u'Cannot merge pull requests in state other than `{}`. '
1241 1354 u'Current state is: `{}`').format(PullRequest.STATE_CREATED,
1242 1355 pull_request.pull_request_state)
1243 1356 h.flash(msg, category='error')
1244 1357 raise HTTPFound(
1245 1358 h.route_path('pullrequest_show',
1246 1359 repo_name=pull_request.target_repo.repo_name,
1247 1360 pull_request_id=pull_request.pull_request_id))
1248 1361
1249 1362 self.load_default_context()
1250 1363
1251 1364 with pull_request.set_state(PullRequest.STATE_UPDATING):
1252 1365 check = MergeCheck.validate(
1253 1366 pull_request, auth_user=self._rhodecode_user,
1254 1367 translator=self.request.translate)
1255 1368 merge_possible = not check.failed
1256 1369
1257 1370 for err_type, error_msg in check.errors:
1258 1371 h.flash(error_msg, category=err_type)
1259 1372
1260 1373 if merge_possible:
1261 1374 log.debug("Pre-conditions checked, trying to merge.")
1262 1375 extras = vcs_operation_context(
1263 1376 self.request.environ, repo_name=pull_request.target_repo.repo_name,
1264 1377 username=self._rhodecode_db_user.username, action='push',
1265 1378 scm=pull_request.target_repo.repo_type)
1266 1379 with pull_request.set_state(PullRequest.STATE_UPDATING):
1267 1380 self._merge_pull_request(
1268 1381 pull_request, self._rhodecode_db_user, extras)
1269 1382 else:
1270 1383 log.debug("Pre-conditions failed, NOT merging.")
1271 1384
1272 1385 raise HTTPFound(
1273 1386 h.route_path('pullrequest_show',
1274 1387 repo_name=pull_request.target_repo.repo_name,
1275 1388 pull_request_id=pull_request.pull_request_id))
1276 1389
1277 1390 def _merge_pull_request(self, pull_request, user, extras):
1278 1391 _ = self.request.translate
1279 1392 merge_resp = PullRequestModel().merge_repo(pull_request, user, extras=extras)
1280 1393
1281 1394 if merge_resp.executed:
1282 1395 log.debug("The merge was successful, closing the pull request.")
1283 1396 PullRequestModel().close_pull_request(
1284 1397 pull_request.pull_request_id, user)
1285 1398 Session().commit()
1286 1399 msg = _('Pull request was successfully merged and closed.')
1287 1400 h.flash(msg, category='success')
1288 1401 else:
1289 1402 log.debug(
1290 1403 "The merge was not successful. Merge response: %s", merge_resp)
1291 1404 msg = merge_resp.merge_status_message
1292 1405 h.flash(msg, category='error')
1293 1406
1294 1407 def _update_reviewers(self, pull_request, review_members, reviewer_rules):
1295 1408 _ = self.request.translate
1296 1409
1297 1410 get_default_reviewers_data, validate_default_reviewers = \
1298 1411 PullRequestModel().get_reviewer_functions()
1299 1412
1300 1413 try:
1301 1414 reviewers = validate_default_reviewers(review_members, reviewer_rules)
1302 1415 except ValueError as e:
1303 1416 log.error('Reviewers Validation: {}'.format(e))
1304 1417 h.flash(e, category='error')
1305 1418 return
1306 1419
1307 1420 old_calculated_status = pull_request.calculated_review_status()
1308 1421 PullRequestModel().update_reviewers(
1309 1422 pull_request, reviewers, self._rhodecode_user)
1310 1423 h.flash(_('Pull request reviewers updated.'), category='success')
1311 1424 Session().commit()
1312 1425
1313 1426 # trigger status changed if change in reviewers changes the status
1314 1427 calculated_status = pull_request.calculated_review_status()
1315 1428 if old_calculated_status != calculated_status:
1316 1429 PullRequestModel().trigger_pull_request_hook(
1317 1430 pull_request, self._rhodecode_user, 'review_status_change',
1318 1431 data={'status': calculated_status})
1319 1432
1320 1433 @LoginRequired()
1321 1434 @NotAnonymous()
1322 1435 @HasRepoPermissionAnyDecorator(
1323 1436 'repository.read', 'repository.write', 'repository.admin')
1324 1437 @CSRFRequired()
1325 1438 @view_config(
1326 1439 route_name='pullrequest_delete', request_method='POST',
1327 1440 renderer='json_ext')
1328 1441 def pull_request_delete(self):
1329 1442 _ = self.request.translate
1330 1443
1331 1444 pull_request = PullRequest.get_or_404(
1332 1445 self.request.matchdict['pull_request_id'])
1333 1446 self.load_default_context()
1334 1447
1335 1448 pr_closed = pull_request.is_closed()
1336 1449 allowed_to_delete = PullRequestModel().check_user_delete(
1337 1450 pull_request, self._rhodecode_user) and not pr_closed
1338 1451
1339 1452 # only owner can delete it !
1340 1453 if allowed_to_delete:
1341 1454 PullRequestModel().delete(pull_request, self._rhodecode_user)
1342 1455 Session().commit()
1343 1456 h.flash(_('Successfully deleted pull request'),
1344 1457 category='success')
1345 1458 raise HTTPFound(h.route_path('pullrequest_show_all',
1346 1459 repo_name=self.db_repo_name))
1347 1460
1348 1461 log.warning('user %s tried to delete pull request without access',
1349 1462 self._rhodecode_user)
1350 1463 raise HTTPNotFound()
1351 1464
1352 1465 @LoginRequired()
1353 1466 @NotAnonymous()
1354 1467 @HasRepoPermissionAnyDecorator(
1355 1468 'repository.read', 'repository.write', 'repository.admin')
1356 1469 @CSRFRequired()
1357 1470 @view_config(
1358 1471 route_name='pullrequest_comment_create', request_method='POST',
1359 1472 renderer='json_ext')
1360 1473 def pull_request_comment_create(self):
1361 1474 _ = self.request.translate
1362 1475
1363 1476 pull_request = PullRequest.get_or_404(
1364 1477 self.request.matchdict['pull_request_id'])
1365 1478 pull_request_id = pull_request.pull_request_id
1366 1479
1367 1480 if pull_request.is_closed():
1368 1481 log.debug('comment: forbidden because pull request is closed')
1369 1482 raise HTTPForbidden()
1370 1483
1371 1484 allowed_to_comment = PullRequestModel().check_user_comment(
1372 1485 pull_request, self._rhodecode_user)
1373 1486 if not allowed_to_comment:
1374 1487 log.debug(
1375 1488 'comment: forbidden because pull request is from forbidden repo')
1376 1489 raise HTTPForbidden()
1377 1490
1378 1491 c = self.load_default_context()
1379 1492
1380 1493 status = self.request.POST.get('changeset_status', None)
1381 1494 text = self.request.POST.get('text')
1382 1495 comment_type = self.request.POST.get('comment_type')
1383 1496 resolves_comment_id = self.request.POST.get('resolves_comment_id', None)
1384 1497 close_pull_request = self.request.POST.get('close_pull_request')
1385 1498
1386 1499 # the logic here should work like following, if we submit close
1387 1500 # pr comment, use `close_pull_request_with_comment` function
1388 1501 # else handle regular comment logic
1389 1502
1390 1503 if close_pull_request:
1391 1504 # only owner or admin or person with write permissions
1392 1505 allowed_to_close = PullRequestModel().check_user_update(
1393 1506 pull_request, self._rhodecode_user)
1394 1507 if not allowed_to_close:
1395 1508 log.debug('comment: forbidden because not allowed to close '
1396 1509 'pull request %s', pull_request_id)
1397 1510 raise HTTPForbidden()
1398 1511
1399 1512 # This also triggers `review_status_change`
1400 1513 comment, status = PullRequestModel().close_pull_request_with_comment(
1401 1514 pull_request, self._rhodecode_user, self.db_repo, message=text,
1402 1515 auth_user=self._rhodecode_user)
1403 1516 Session().flush()
1404 1517
1405 1518 PullRequestModel().trigger_pull_request_hook(
1406 1519 pull_request, self._rhodecode_user, 'comment',
1407 1520 data={'comment': comment})
1408 1521
1409 1522 else:
1410 1523 # regular comment case, could be inline, or one with status.
1411 1524 # for that one we check also permissions
1412 1525
1413 1526 allowed_to_change_status = PullRequestModel().check_user_change_status(
1414 1527 pull_request, self._rhodecode_user)
1415 1528
1416 1529 if status and allowed_to_change_status:
1417 1530 message = (_('Status change %(transition_icon)s %(status)s')
1418 1531 % {'transition_icon': '>',
1419 1532 'status': ChangesetStatus.get_status_lbl(status)})
1420 1533 text = text or message
1421 1534
1422 1535 comment = CommentsModel().create(
1423 1536 text=text,
1424 1537 repo=self.db_repo.repo_id,
1425 1538 user=self._rhodecode_user.user_id,
1426 1539 pull_request=pull_request,
1427 1540 f_path=self.request.POST.get('f_path'),
1428 1541 line_no=self.request.POST.get('line'),
1429 1542 status_change=(ChangesetStatus.get_status_lbl(status)
1430 1543 if status and allowed_to_change_status else None),
1431 1544 status_change_type=(status
1432 1545 if status and allowed_to_change_status else None),
1433 1546 comment_type=comment_type,
1434 1547 resolves_comment_id=resolves_comment_id,
1435 1548 auth_user=self._rhodecode_user
1436 1549 )
1437 1550
1438 1551 if allowed_to_change_status:
1439 1552 # calculate old status before we change it
1440 1553 old_calculated_status = pull_request.calculated_review_status()
1441 1554
1442 1555 # get status if set !
1443 1556 if status:
1444 1557 ChangesetStatusModel().set_status(
1445 1558 self.db_repo.repo_id,
1446 1559 status,
1447 1560 self._rhodecode_user.user_id,
1448 1561 comment,
1449 1562 pull_request=pull_request
1450 1563 )
1451 1564
1452 1565 Session().flush()
1453 1566 # this is somehow required to get access to some relationship
1454 1567 # loaded on comment
1455 1568 Session().refresh(comment)
1456 1569
1457 1570 PullRequestModel().trigger_pull_request_hook(
1458 1571 pull_request, self._rhodecode_user, 'comment',
1459 1572 data={'comment': comment})
1460 1573
1461 1574 # we now calculate the status of pull request, and based on that
1462 1575 # calculation we set the commits status
1463 1576 calculated_status = pull_request.calculated_review_status()
1464 1577 if old_calculated_status != calculated_status:
1465 1578 PullRequestModel().trigger_pull_request_hook(
1466 1579 pull_request, self._rhodecode_user, 'review_status_change',
1467 1580 data={'status': calculated_status})
1468 1581
1469 1582 Session().commit()
1470 1583
1471 1584 data = {
1472 1585 'target_id': h.safeid(h.safe_unicode(
1473 1586 self.request.POST.get('f_path'))),
1474 1587 }
1475 1588 if comment:
1476 1589 c.co = comment
1590 c.at_version_num = None
1477 1591 rendered_comment = render(
1478 1592 'rhodecode:templates/changeset/changeset_comment_block.mako',
1479 1593 self._get_template_context(c), self.request)
1480 1594
1481 1595 data.update(comment.get_dict())
1482 1596 data.update({'rendered_text': rendered_comment})
1483 1597
1484 1598 return data
1485 1599
1486 1600 @LoginRequired()
1487 1601 @NotAnonymous()
1488 1602 @HasRepoPermissionAnyDecorator(
1489 1603 'repository.read', 'repository.write', 'repository.admin')
1490 1604 @CSRFRequired()
1491 1605 @view_config(
1492 1606 route_name='pullrequest_comment_delete', request_method='POST',
1493 1607 renderer='json_ext')
1494 1608 def pull_request_comment_delete(self):
1495 1609 pull_request = PullRequest.get_or_404(
1496 1610 self.request.matchdict['pull_request_id'])
1497 1611
1498 1612 comment = ChangesetComment.get_or_404(
1499 1613 self.request.matchdict['comment_id'])
1500 1614 comment_id = comment.comment_id
1501 1615
1502 1616 if comment.immutable:
1503 1617 # don't allow deleting comments that are immutable
1504 1618 raise HTTPForbidden()
1505 1619
1506 1620 if pull_request.is_closed():
1507 1621 log.debug('comment: forbidden because pull request is closed')
1508 1622 raise HTTPForbidden()
1509 1623
1510 1624 if not comment:
1511 1625 log.debug('Comment with id:%s not found, skipping', comment_id)
1512 1626 # comment already deleted in another call probably
1513 1627 return True
1514 1628
1515 1629 if comment.pull_request.is_closed():
1516 1630 # don't allow deleting comments on closed pull request
1517 1631 raise HTTPForbidden()
1518 1632
1519 1633 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
1520 1634 super_admin = h.HasPermissionAny('hg.admin')()
1521 1635 comment_owner = comment.author.user_id == self._rhodecode_user.user_id
1522 1636 is_repo_comment = comment.repo.repo_name == self.db_repo_name
1523 1637 comment_repo_admin = is_repo_admin and is_repo_comment
1524 1638
1525 1639 if super_admin or comment_owner or comment_repo_admin:
1526 1640 old_calculated_status = comment.pull_request.calculated_review_status()
1527 1641 CommentsModel().delete(comment=comment, auth_user=self._rhodecode_user)
1528 1642 Session().commit()
1529 1643 calculated_status = comment.pull_request.calculated_review_status()
1530 1644 if old_calculated_status != calculated_status:
1531 1645 PullRequestModel().trigger_pull_request_hook(
1532 1646 comment.pull_request, self._rhodecode_user, 'review_status_change',
1533 1647 data={'status': calculated_status})
1534 1648 return True
1535 1649 else:
1536 1650 log.warning('No permissions for user %s to delete comment_id: %s',
1537 1651 self._rhodecode_db_user, comment_id)
1538 1652 raise HTTPNotFound()
1539 1653
1540 1654 @LoginRequired()
1541 1655 @NotAnonymous()
1542 1656 @HasRepoPermissionAnyDecorator(
1543 1657 'repository.read', 'repository.write', 'repository.admin')
1544 1658 @CSRFRequired()
1545 1659 @view_config(
1546 1660 route_name='pullrequest_comment_edit', request_method='POST',
1547 1661 renderer='json_ext')
1548 1662 def pull_request_comment_edit(self):
1549 1663 self.load_default_context()
1550 1664
1551 1665 pull_request = PullRequest.get_or_404(
1552 1666 self.request.matchdict['pull_request_id']
1553 1667 )
1554 1668 comment = ChangesetComment.get_or_404(
1555 1669 self.request.matchdict['comment_id']
1556 1670 )
1557 1671 comment_id = comment.comment_id
1558 1672
1559 1673 if comment.immutable:
1560 1674 # don't allow deleting comments that are immutable
1561 1675 raise HTTPForbidden()
1562 1676
1563 1677 if pull_request.is_closed():
1564 1678 log.debug('comment: forbidden because pull request is closed')
1565 1679 raise HTTPForbidden()
1566 1680
1567 1681 if not comment:
1568 1682 log.debug('Comment with id:%s not found, skipping', comment_id)
1569 1683 # comment already deleted in another call probably
1570 1684 return True
1571 1685
1572 1686 if comment.pull_request.is_closed():
1573 1687 # don't allow deleting comments on closed pull request
1574 1688 raise HTTPForbidden()
1575 1689
1576 1690 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
1577 1691 super_admin = h.HasPermissionAny('hg.admin')()
1578 1692 comment_owner = comment.author.user_id == self._rhodecode_user.user_id
1579 1693 is_repo_comment = comment.repo.repo_name == self.db_repo_name
1580 1694 comment_repo_admin = is_repo_admin and is_repo_comment
1581 1695
1582 1696 if super_admin or comment_owner or comment_repo_admin:
1583 1697 text = self.request.POST.get('text')
1584 1698 version = self.request.POST.get('version')
1585 1699 if text == comment.text:
1586 1700 log.warning(
1587 1701 'Comment(PR): '
1588 1702 'Trying to create new version '
1589 1703 'with the same comment body {}'.format(
1590 1704 comment_id,
1591 1705 )
1592 1706 )
1593 1707 raise HTTPNotFound()
1594 1708
1595 1709 if version.isdigit():
1596 1710 version = int(version)
1597 1711 else:
1598 1712 log.warning(
1599 1713 'Comment(PR): Wrong version type {} {} '
1600 1714 'for comment {}'.format(
1601 1715 version,
1602 1716 type(version),
1603 1717 comment_id,
1604 1718 )
1605 1719 )
1606 1720 raise HTTPNotFound()
1607 1721
1608 1722 try:
1609 1723 comment_history = CommentsModel().edit(
1610 1724 comment_id=comment_id,
1611 1725 text=text,
1612 1726 auth_user=self._rhodecode_user,
1613 1727 version=version,
1614 1728 )
1615 1729 except CommentVersionMismatch:
1616 1730 raise HTTPConflict()
1617 1731
1618 1732 if not comment_history:
1619 1733 raise HTTPNotFound()
1620 1734
1621 1735 Session().commit()
1622 1736
1623 1737 PullRequestModel().trigger_pull_request_hook(
1624 1738 pull_request, self._rhodecode_user, 'comment_edit',
1625 1739 data={'comment': comment})
1626 1740
1627 1741 return {
1628 1742 'comment_history_id': comment_history.comment_history_id,
1629 1743 'comment_id': comment.comment_id,
1630 1744 'comment_version': comment_history.version,
1631 1745 'comment_author_username': comment_history.author.username,
1632 1746 'comment_author_gravatar': h.gravatar_url(comment_history.author.email, 16),
1633 1747 'comment_created_on': h.age_component(comment_history.created_on,
1634 1748 time_is_local=True),
1635 1749 }
1636 1750 else:
1637 1751 log.warning('No permissions for user %s to edit comment_id: %s',
1638 1752 self._rhodecode_db_user, comment_id)
1639 1753 raise HTTPNotFound()
@@ -1,2092 +1,2106 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 Helper functions
23 23
24 24 Consists of functions to typically be used within templates, but also
25 25 available to Controllers. This module is available to both as 'h'.
26 26 """
27 27 import base64
28 28
29 29 import os
30 30 import random
31 31 import hashlib
32 32 import StringIO
33 33 import textwrap
34 34 import urllib
35 35 import math
36 36 import logging
37 37 import re
38 38 import time
39 39 import string
40 40 import hashlib
41 41 from collections import OrderedDict
42 42
43 43 import pygments
44 44 import itertools
45 45 import fnmatch
46 46 import bleach
47 47
48 48 from pyramid import compat
49 49 from datetime import datetime
50 50 from functools import partial
51 51 from pygments.formatters.html import HtmlFormatter
52 52 from pygments.lexers import (
53 53 get_lexer_by_name, get_lexer_for_filename, get_lexer_for_mimetype)
54 54
55 55 from pyramid.threadlocal import get_current_request
56 56 from tempita import looper
57 57 from webhelpers2.html import literal, HTML, escape
58 58 from webhelpers2.html._autolink import _auto_link_urls
59 59 from webhelpers2.html.tools import (
60 60 button_to, highlight, js_obfuscate, strip_links, strip_tags)
61 61
62 62 from webhelpers2.text import (
63 63 chop_at, collapse, convert_accented_entities,
64 64 convert_misc_entities, lchop, plural, rchop, remove_formatting,
65 65 replace_whitespace, urlify, truncate, wrap_paragraphs)
66 66 from webhelpers2.date import time_ago_in_words
67 67
68 68 from webhelpers2.html.tags import (
69 69 _input, NotGiven, _make_safe_id_component as safeid,
70 70 form as insecure_form,
71 71 auto_discovery_link, checkbox, end_form, file,
72 72 hidden, image, javascript_link, link_to, link_to_if, link_to_unless, ol,
73 73 select as raw_select, stylesheet_link, submit, text, password, textarea,
74 74 ul, radio, Options)
75 75
76 76 from webhelpers2.number import format_byte_size
77 77
78 78 from rhodecode.lib.action_parser import action_parser
79 79 from rhodecode.lib.pagination import Page, RepoPage, SqlPage
80 80 from rhodecode.lib.ext_json import json
81 81 from rhodecode.lib.utils import repo_name_slug, get_custom_lexer
82 82 from rhodecode.lib.utils2 import (
83 83 str2bool, safe_unicode, safe_str,
84 84 get_commit_safe, datetime_to_time, time_to_datetime, time_to_utcdatetime,
85 85 AttributeDict, safe_int, md5, md5_safe, get_host_info)
86 86 from rhodecode.lib.markup_renderer import MarkupRenderer, relative_links
87 87 from rhodecode.lib.vcs.exceptions import CommitDoesNotExistError
88 88 from rhodecode.lib.vcs.backends.base import BaseChangeset, EmptyCommit
89 89 from rhodecode.lib.vcs.conf.settings import ARCHIVE_SPECS
90 90 from rhodecode.lib.index.search_utils import get_matching_line_offsets
91 91 from rhodecode.config.conf import DATE_FORMAT, DATETIME_FORMAT
92 92 from rhodecode.model.changeset_status import ChangesetStatusModel
93 93 from rhodecode.model.db import Permission, User, Repository, UserApiKeys, FileStore
94 94 from rhodecode.model.repo_group import RepoGroupModel
95 95 from rhodecode.model.settings import IssueTrackerSettingsModel
96 96
97 97
98 98 log = logging.getLogger(__name__)
99 99
100 100
101 101 DEFAULT_USER = User.DEFAULT_USER
102 102 DEFAULT_USER_EMAIL = User.DEFAULT_USER_EMAIL
103 103
104 104
105 105 def asset(path, ver=None, **kwargs):
106 106 """
107 107 Helper to generate a static asset file path for rhodecode assets
108 108
109 109 eg. h.asset('images/image.png', ver='3923')
110 110
111 111 :param path: path of asset
112 112 :param ver: optional version query param to append as ?ver=
113 113 """
114 114 request = get_current_request()
115 115 query = {}
116 116 query.update(kwargs)
117 117 if ver:
118 118 query = {'ver': ver}
119 119 return request.static_path(
120 120 'rhodecode:public/{}'.format(path), _query=query)
121 121
122 122
123 123 default_html_escape_table = {
124 124 ord('&'): u'&amp;',
125 125 ord('<'): u'&lt;',
126 126 ord('>'): u'&gt;',
127 127 ord('"'): u'&quot;',
128 128 ord("'"): u'&#39;',
129 129 }
130 130
131 131
132 132 def html_escape(text, html_escape_table=default_html_escape_table):
133 133 """Produce entities within text."""
134 134 return text.translate(html_escape_table)
135 135
136 136
137 137 def chop_at_smart(s, sub, inclusive=False, suffix_if_chopped=None):
138 138 """
139 139 Truncate string ``s`` at the first occurrence of ``sub``.
140 140
141 141 If ``inclusive`` is true, truncate just after ``sub`` rather than at it.
142 142 """
143 143 suffix_if_chopped = suffix_if_chopped or ''
144 144 pos = s.find(sub)
145 145 if pos == -1:
146 146 return s
147 147
148 148 if inclusive:
149 149 pos += len(sub)
150 150
151 151 chopped = s[:pos]
152 152 left = s[pos:].strip()
153 153
154 154 if left and suffix_if_chopped:
155 155 chopped += suffix_if_chopped
156 156
157 157 return chopped
158 158
159 159
160 160 def shorter(text, size=20, prefix=False):
161 161 postfix = '...'
162 162 if len(text) > size:
163 163 if prefix:
164 164 # shorten in front
165 165 return postfix + text[-(size - len(postfix)):]
166 166 else:
167 167 return text[:size - len(postfix)] + postfix
168 168 return text
169 169
170 170
171 171 def reset(name, value=None, id=NotGiven, type="reset", **attrs):
172 172 """
173 173 Reset button
174 174 """
175 175 return _input(type, name, value, id, attrs)
176 176
177 177
178 178 def select(name, selected_values, options, id=NotGiven, **attrs):
179 179
180 180 if isinstance(options, (list, tuple)):
181 181 options_iter = options
182 182 # Handle old value,label lists ... where value also can be value,label lists
183 183 options = Options()
184 184 for opt in options_iter:
185 185 if isinstance(opt, tuple) and len(opt) == 2:
186 186 value, label = opt
187 187 elif isinstance(opt, basestring):
188 188 value = label = opt
189 189 else:
190 190 raise ValueError('invalid select option type %r' % type(opt))
191 191
192 192 if isinstance(value, (list, tuple)):
193 193 option_group = options.add_optgroup(label)
194 194 for opt2 in value:
195 195 if isinstance(opt2, tuple) and len(opt2) == 2:
196 196 group_value, group_label = opt2
197 197 elif isinstance(opt2, basestring):
198 198 group_value = group_label = opt2
199 199 else:
200 200 raise ValueError('invalid select option type %r' % type(opt2))
201 201
202 202 option_group.add_option(group_label, group_value)
203 203 else:
204 204 options.add_option(label, value)
205 205
206 206 return raw_select(name, selected_values, options, id=id, **attrs)
207 207
208 208
209 209 def branding(name, length=40):
210 210 return truncate(name, length, indicator="")
211 211
212 212
213 213 def FID(raw_id, path):
214 214 """
215 215 Creates a unique ID for filenode based on it's hash of path and commit
216 216 it's safe to use in urls
217 217
218 218 :param raw_id:
219 219 :param path:
220 220 """
221 221
222 222 return 'c-%s-%s' % (short_id(raw_id), md5_safe(path)[:12])
223 223
224 224
225 225 class _GetError(object):
226 226 """Get error from form_errors, and represent it as span wrapped error
227 227 message
228 228
229 229 :param field_name: field to fetch errors for
230 230 :param form_errors: form errors dict
231 231 """
232 232
233 233 def __call__(self, field_name, form_errors):
234 234 tmpl = """<span class="error_msg">%s</span>"""
235 235 if form_errors and field_name in form_errors:
236 236 return literal(tmpl % form_errors.get(field_name))
237 237
238 238
239 239 get_error = _GetError()
240 240
241 241
242 242 class _ToolTip(object):
243 243
244 244 def __call__(self, tooltip_title, trim_at=50):
245 245 """
246 246 Special function just to wrap our text into nice formatted
247 247 autowrapped text
248 248
249 249 :param tooltip_title:
250 250 """
251 251 tooltip_title = escape(tooltip_title)
252 252 tooltip_title = tooltip_title.replace('<', '&lt;').replace('>', '&gt;')
253 253 return tooltip_title
254 254
255 255
256 256 tooltip = _ToolTip()
257 257
258 258 files_icon = u'<i class="file-breadcrumb-copy tooltip icon-clipboard clipboard-action" data-clipboard-text="{}" title="Copy file path"></i>'
259 259
260 260
261 261 def files_breadcrumbs(repo_name, repo_type, commit_id, file_path, landing_ref_name=None, at_ref=None,
262 262 limit_items=False, linkify_last_item=False, hide_last_item=False,
263 263 copy_path_icon=True):
264 264 if isinstance(file_path, str):
265 265 file_path = safe_unicode(file_path)
266 266
267 267 if at_ref:
268 268 route_qry = {'at': at_ref}
269 269 default_landing_ref = at_ref or landing_ref_name or commit_id
270 270 else:
271 271 route_qry = None
272 272 default_landing_ref = commit_id
273 273
274 274 # first segment is a `HOME` link to repo files root location
275 275 root_name = literal(u'<i class="icon-home"></i>')
276 276
277 277 url_segments = [
278 278 link_to(
279 279 root_name,
280 280 repo_files_by_ref_url(
281 281 repo_name,
282 282 repo_type,
283 283 f_path=None, # None here is a special case for SVN repos,
284 284 # that won't prefix with a ref
285 285 ref_name=default_landing_ref,
286 286 commit_id=commit_id,
287 287 query=route_qry
288 288 )
289 289 )]
290 290
291 291 path_segments = file_path.split('/')
292 292 last_cnt = len(path_segments) - 1
293 293 for cnt, segment in enumerate(path_segments):
294 294 if not segment:
295 295 continue
296 296 segment_html = escape(segment)
297 297
298 298 last_item = cnt == last_cnt
299 299
300 300 if last_item and hide_last_item:
301 301 # iterate over and hide last element
302 302 continue
303 303
304 304 if last_item and linkify_last_item is False:
305 305 # plain version
306 306 url_segments.append(segment_html)
307 307 else:
308 308 url_segments.append(
309 309 link_to(
310 310 segment_html,
311 311 repo_files_by_ref_url(
312 312 repo_name,
313 313 repo_type,
314 314 f_path='/'.join(path_segments[:cnt + 1]),
315 315 ref_name=default_landing_ref,
316 316 commit_id=commit_id,
317 317 query=route_qry
318 318 ),
319 319 ))
320 320
321 321 limited_url_segments = url_segments[:1] + ['...'] + url_segments[-5:]
322 322 if limit_items and len(limited_url_segments) < len(url_segments):
323 323 url_segments = limited_url_segments
324 324
325 325 full_path = file_path
326 326 if copy_path_icon:
327 327 icon = files_icon.format(escape(full_path))
328 328 else:
329 329 icon = ''
330 330
331 331 if file_path == '':
332 332 return root_name
333 333 else:
334 334 return literal(' / '.join(url_segments) + icon)
335 335
336 336
337 337 def files_url_data(request):
338 338 matchdict = request.matchdict
339 339
340 340 if 'f_path' not in matchdict:
341 341 matchdict['f_path'] = ''
342 342
343 343 if 'commit_id' not in matchdict:
344 344 matchdict['commit_id'] = 'tip'
345 345
346 346 return json.dumps(matchdict)
347 347
348 348
349 349 def repo_files_by_ref_url(db_repo_name, db_repo_type, f_path, ref_name, commit_id, query=None, ):
350 350 _is_svn = is_svn(db_repo_type)
351 351 final_f_path = f_path
352 352
353 353 if _is_svn:
354 354 """
355 355 For SVN the ref_name cannot be used as a commit_id, it needs to be prefixed with
356 356 actually commit_id followed by the ref_name. This should be done only in case
357 357 This is a initial landing url, without additional paths.
358 358
359 359 like: /1000/tags/1.0.0/?at=tags/1.0.0
360 360 """
361 361
362 362 if ref_name and ref_name != 'tip':
363 363 # NOTE(marcink): for svn the ref_name is actually the stored path, so we prefix it
364 364 # for SVN we only do this magic prefix if it's root, .eg landing revision
365 365 # of files link. If we are in the tree we don't need this since we traverse the url
366 366 # that has everything stored
367 367 if f_path in ['', '/']:
368 368 final_f_path = '/'.join([ref_name, f_path])
369 369
370 370 # SVN always needs a commit_id explicitly, without a named REF
371 371 default_commit_id = commit_id
372 372 else:
373 373 """
374 374 For git and mercurial we construct a new URL using the names instead of commit_id
375 375 like: /master/some_path?at=master
376 376 """
377 377 # We currently do not support branches with slashes
378 378 if '/' in ref_name:
379 379 default_commit_id = commit_id
380 380 else:
381 381 default_commit_id = ref_name
382 382
383 383 # sometimes we pass f_path as None, to indicate explicit no prefix,
384 384 # we translate it to string to not have None
385 385 final_f_path = final_f_path or ''
386 386
387 387 files_url = route_path(
388 388 'repo_files',
389 389 repo_name=db_repo_name,
390 390 commit_id=default_commit_id,
391 391 f_path=final_f_path,
392 392 _query=query
393 393 )
394 394 return files_url
395 395
396 396
397 397 def code_highlight(code, lexer, formatter, use_hl_filter=False):
398 398 """
399 399 Lex ``code`` with ``lexer`` and format it with the formatter ``formatter``.
400 400
401 401 If ``outfile`` is given and a valid file object (an object
402 402 with a ``write`` method), the result will be written to it, otherwise
403 403 it is returned as a string.
404 404 """
405 405 if use_hl_filter:
406 406 # add HL filter
407 407 from rhodecode.lib.index import search_utils
408 408 lexer.add_filter(search_utils.ElasticSearchHLFilter())
409 409 return pygments.format(pygments.lex(code, lexer), formatter)
410 410
411 411
412 412 class CodeHtmlFormatter(HtmlFormatter):
413 413 """
414 414 My code Html Formatter for source codes
415 415 """
416 416
417 417 def wrap(self, source, outfile):
418 418 return self._wrap_div(self._wrap_pre(self._wrap_code(source)))
419 419
420 420 def _wrap_code(self, source):
421 421 for cnt, it in enumerate(source):
422 422 i, t = it
423 423 t = '<div id="L%s">%s</div>' % (cnt + 1, t)
424 424 yield i, t
425 425
426 426 def _wrap_tablelinenos(self, inner):
427 427 dummyoutfile = StringIO.StringIO()
428 428 lncount = 0
429 429 for t, line in inner:
430 430 if t:
431 431 lncount += 1
432 432 dummyoutfile.write(line)
433 433
434 434 fl = self.linenostart
435 435 mw = len(str(lncount + fl - 1))
436 436 sp = self.linenospecial
437 437 st = self.linenostep
438 438 la = self.lineanchors
439 439 aln = self.anchorlinenos
440 440 nocls = self.noclasses
441 441 if sp:
442 442 lines = []
443 443
444 444 for i in range(fl, fl + lncount):
445 445 if i % st == 0:
446 446 if i % sp == 0:
447 447 if aln:
448 448 lines.append('<a href="#%s%d" class="special">%*d</a>' %
449 449 (la, i, mw, i))
450 450 else:
451 451 lines.append('<span class="special">%*d</span>' % (mw, i))
452 452 else:
453 453 if aln:
454 454 lines.append('<a href="#%s%d">%*d</a>' % (la, i, mw, i))
455 455 else:
456 456 lines.append('%*d' % (mw, i))
457 457 else:
458 458 lines.append('')
459 459 ls = '\n'.join(lines)
460 460 else:
461 461 lines = []
462 462 for i in range(fl, fl + lncount):
463 463 if i % st == 0:
464 464 if aln:
465 465 lines.append('<a href="#%s%d">%*d</a>' % (la, i, mw, i))
466 466 else:
467 467 lines.append('%*d' % (mw, i))
468 468 else:
469 469 lines.append('')
470 470 ls = '\n'.join(lines)
471 471
472 472 # in case you wonder about the seemingly redundant <div> here: since the
473 473 # content in the other cell also is wrapped in a div, some browsers in
474 474 # some configurations seem to mess up the formatting...
475 475 if nocls:
476 476 yield 0, ('<table class="%stable">' % self.cssclass +
477 477 '<tr><td><div class="linenodiv" '
478 478 'style="background-color: #f0f0f0; padding-right: 10px">'
479 479 '<pre style="line-height: 125%">' +
480 480 ls + '</pre></div></td><td id="hlcode" class="code">')
481 481 else:
482 482 yield 0, ('<table class="%stable">' % self.cssclass +
483 483 '<tr><td class="linenos"><div class="linenodiv"><pre>' +
484 484 ls + '</pre></div></td><td id="hlcode" class="code">')
485 485 yield 0, dummyoutfile.getvalue()
486 486 yield 0, '</td></tr></table>'
487 487
488 488
489 489 class SearchContentCodeHtmlFormatter(CodeHtmlFormatter):
490 490 def __init__(self, **kw):
491 491 # only show these line numbers if set
492 492 self.only_lines = kw.pop('only_line_numbers', [])
493 493 self.query_terms = kw.pop('query_terms', [])
494 494 self.max_lines = kw.pop('max_lines', 5)
495 495 self.line_context = kw.pop('line_context', 3)
496 496 self.url = kw.pop('url', None)
497 497
498 498 super(CodeHtmlFormatter, self).__init__(**kw)
499 499
500 500 def _wrap_code(self, source):
501 501 for cnt, it in enumerate(source):
502 502 i, t = it
503 503 t = '<pre>%s</pre>' % t
504 504 yield i, t
505 505
506 506 def _wrap_tablelinenos(self, inner):
507 507 yield 0, '<table class="code-highlight %stable">' % self.cssclass
508 508
509 509 last_shown_line_number = 0
510 510 current_line_number = 1
511 511
512 512 for t, line in inner:
513 513 if not t:
514 514 yield t, line
515 515 continue
516 516
517 517 if current_line_number in self.only_lines:
518 518 if last_shown_line_number + 1 != current_line_number:
519 519 yield 0, '<tr>'
520 520 yield 0, '<td class="line">...</td>'
521 521 yield 0, '<td id="hlcode" class="code"></td>'
522 522 yield 0, '</tr>'
523 523
524 524 yield 0, '<tr>'
525 525 if self.url:
526 526 yield 0, '<td class="line"><a href="%s#L%i">%i</a></td>' % (
527 527 self.url, current_line_number, current_line_number)
528 528 else:
529 529 yield 0, '<td class="line"><a href="">%i</a></td>' % (
530 530 current_line_number)
531 531 yield 0, '<td id="hlcode" class="code">' + line + '</td>'
532 532 yield 0, '</tr>'
533 533
534 534 last_shown_line_number = current_line_number
535 535
536 536 current_line_number += 1
537 537
538 538 yield 0, '</table>'
539 539
540 540
541 541 def hsv_to_rgb(h, s, v):
542 542 """ Convert hsv color values to rgb """
543 543
544 544 if s == 0.0:
545 545 return v, v, v
546 546 i = int(h * 6.0) # XXX assume int() truncates!
547 547 f = (h * 6.0) - i
548 548 p = v * (1.0 - s)
549 549 q = v * (1.0 - s * f)
550 550 t = v * (1.0 - s * (1.0 - f))
551 551 i = i % 6
552 552 if i == 0:
553 553 return v, t, p
554 554 if i == 1:
555 555 return q, v, p
556 556 if i == 2:
557 557 return p, v, t
558 558 if i == 3:
559 559 return p, q, v
560 560 if i == 4:
561 561 return t, p, v
562 562 if i == 5:
563 563 return v, p, q
564 564
565 565
566 566 def unique_color_generator(n=10000, saturation=0.10, lightness=0.95):
567 567 """
568 568 Generator for getting n of evenly distributed colors using
569 569 hsv color and golden ratio. It always return same order of colors
570 570
571 571 :param n: number of colors to generate
572 572 :param saturation: saturation of returned colors
573 573 :param lightness: lightness of returned colors
574 574 :returns: RGB tuple
575 575 """
576 576
577 577 golden_ratio = 0.618033988749895
578 578 h = 0.22717784590367374
579 579
580 580 for _ in xrange(n):
581 581 h += golden_ratio
582 582 h %= 1
583 583 HSV_tuple = [h, saturation, lightness]
584 584 RGB_tuple = hsv_to_rgb(*HSV_tuple)
585 585 yield map(lambda x: str(int(x * 256)), RGB_tuple)
586 586
587 587
588 588 def color_hasher(n=10000, saturation=0.10, lightness=0.95):
589 589 """
590 590 Returns a function which when called with an argument returns a unique
591 591 color for that argument, eg.
592 592
593 593 :param n: number of colors to generate
594 594 :param saturation: saturation of returned colors
595 595 :param lightness: lightness of returned colors
596 596 :returns: css RGB string
597 597
598 598 >>> color_hash = color_hasher()
599 599 >>> color_hash('hello')
600 600 'rgb(34, 12, 59)'
601 601 >>> color_hash('hello')
602 602 'rgb(34, 12, 59)'
603 603 >>> color_hash('other')
604 604 'rgb(90, 224, 159)'
605 605 """
606 606
607 607 color_dict = {}
608 608 cgenerator = unique_color_generator(
609 609 saturation=saturation, lightness=lightness)
610 610
611 611 def get_color_string(thing):
612 612 if thing in color_dict:
613 613 col = color_dict[thing]
614 614 else:
615 615 col = color_dict[thing] = cgenerator.next()
616 616 return "rgb(%s)" % (', '.join(col))
617 617
618 618 return get_color_string
619 619
620 620
621 621 def get_lexer_safe(mimetype=None, filepath=None):
622 622 """
623 623 Tries to return a relevant pygments lexer using mimetype/filepath name,
624 624 defaulting to plain text if none could be found
625 625 """
626 626 lexer = None
627 627 try:
628 628 if mimetype:
629 629 lexer = get_lexer_for_mimetype(mimetype)
630 630 if not lexer:
631 631 lexer = get_lexer_for_filename(filepath)
632 632 except pygments.util.ClassNotFound:
633 633 pass
634 634
635 635 if not lexer:
636 636 lexer = get_lexer_by_name('text')
637 637
638 638 return lexer
639 639
640 640
641 641 def get_lexer_for_filenode(filenode):
642 642 lexer = get_custom_lexer(filenode.extension) or filenode.lexer
643 643 return lexer
644 644
645 645
646 646 def pygmentize(filenode, **kwargs):
647 647 """
648 648 pygmentize function using pygments
649 649
650 650 :param filenode:
651 651 """
652 652 lexer = get_lexer_for_filenode(filenode)
653 653 return literal(code_highlight(filenode.content, lexer,
654 654 CodeHtmlFormatter(**kwargs)))
655 655
656 656
657 657 def is_following_repo(repo_name, user_id):
658 658 from rhodecode.model.scm import ScmModel
659 659 return ScmModel().is_following_repo(repo_name, user_id)
660 660
661 661
662 662 class _Message(object):
663 663 """A message returned by ``Flash.pop_messages()``.
664 664
665 665 Converting the message to a string returns the message text. Instances
666 666 also have the following attributes:
667 667
668 668 * ``message``: the message text.
669 669 * ``category``: the category specified when the message was created.
670 670 """
671 671
672 672 def __init__(self, category, message, sub_data=None):
673 673 self.category = category
674 674 self.message = message
675 675 self.sub_data = sub_data or {}
676 676
677 677 def __str__(self):
678 678 return self.message
679 679
680 680 __unicode__ = __str__
681 681
682 682 def __html__(self):
683 683 return escape(safe_unicode(self.message))
684 684
685 685
686 686 class Flash(object):
687 687 # List of allowed categories. If None, allow any category.
688 688 categories = ["warning", "notice", "error", "success"]
689 689
690 690 # Default category if none is specified.
691 691 default_category = "notice"
692 692
693 693 def __init__(self, session_key="flash", categories=None,
694 694 default_category=None):
695 695 """
696 696 Instantiate a ``Flash`` object.
697 697
698 698 ``session_key`` is the key to save the messages under in the user's
699 699 session.
700 700
701 701 ``categories`` is an optional list which overrides the default list
702 702 of categories.
703 703
704 704 ``default_category`` overrides the default category used for messages
705 705 when none is specified.
706 706 """
707 707 self.session_key = session_key
708 708 if categories is not None:
709 709 self.categories = categories
710 710 if default_category is not None:
711 711 self.default_category = default_category
712 712 if self.categories and self.default_category not in self.categories:
713 713 raise ValueError(
714 714 "unrecognized default category %r" % (self.default_category,))
715 715
716 716 def pop_messages(self, session=None, request=None):
717 717 """
718 718 Return all accumulated messages and delete them from the session.
719 719
720 720 The return value is a list of ``Message`` objects.
721 721 """
722 722 messages = []
723 723
724 724 if not session:
725 725 if not request:
726 726 request = get_current_request()
727 727 session = request.session
728 728
729 729 # Pop the 'old' pylons flash messages. They are tuples of the form
730 730 # (category, message)
731 731 for cat, msg in session.pop(self.session_key, []):
732 732 messages.append(_Message(cat, msg))
733 733
734 734 # Pop the 'new' pyramid flash messages for each category as list
735 735 # of strings.
736 736 for cat in self.categories:
737 737 for msg in session.pop_flash(queue=cat):
738 738 sub_data = {}
739 739 if hasattr(msg, 'rsplit'):
740 740 flash_data = msg.rsplit('|DELIM|', 1)
741 741 org_message = flash_data[0]
742 742 if len(flash_data) > 1:
743 743 sub_data = json.loads(flash_data[1])
744 744 else:
745 745 org_message = msg
746 746
747 747 messages.append(_Message(cat, org_message, sub_data=sub_data))
748 748
749 749 # Map messages from the default queue to the 'notice' category.
750 750 for msg in session.pop_flash():
751 751 messages.append(_Message('notice', msg))
752 752
753 753 session.save()
754 754 return messages
755 755
756 756 def json_alerts(self, session=None, request=None):
757 757 payloads = []
758 758 messages = flash.pop_messages(session=session, request=request) or []
759 759 for message in messages:
760 760 payloads.append({
761 761 'message': {
762 762 'message': u'{}'.format(message.message),
763 763 'level': message.category,
764 764 'force': True,
765 765 'subdata': message.sub_data
766 766 }
767 767 })
768 768 return json.dumps(payloads)
769 769
770 770 def __call__(self, message, category=None, ignore_duplicate=True,
771 771 session=None, request=None):
772 772
773 773 if not session:
774 774 if not request:
775 775 request = get_current_request()
776 776 session = request.session
777 777
778 778 session.flash(
779 779 message, queue=category, allow_duplicate=not ignore_duplicate)
780 780
781 781
782 782 flash = Flash()
783 783
784 784 #==============================================================================
785 785 # SCM FILTERS available via h.
786 786 #==============================================================================
787 787 from rhodecode.lib.vcs.utils import author_name, author_email
788 788 from rhodecode.lib.utils2 import age, age_from_seconds
789 789 from rhodecode.model.db import User, ChangesetStatus
790 790
791 791
792 792 email = author_email
793 793
794 794
795 795 def capitalize(raw_text):
796 796 return raw_text.capitalize()
797 797
798 798
799 799 def short_id(long_id):
800 800 return long_id[:12]
801 801
802 802
803 803 def hide_credentials(url):
804 804 from rhodecode.lib.utils2 import credentials_filter
805 805 return credentials_filter(url)
806 806
807 807
808 808 import pytz
809 809 import tzlocal
810 810 local_timezone = tzlocal.get_localzone()
811 811
812 812
813 def age_component(datetime_iso, value=None, time_is_local=False, tooltip=True):
814 title = value or format_date(datetime_iso)
813 def get_timezone(datetime_iso, time_is_local=False):
815 814 tzinfo = '+00:00'
816 815
817 816 # detect if we have a timezone info, otherwise, add it
818 817 if time_is_local and isinstance(datetime_iso, datetime) and not datetime_iso.tzinfo:
819 818 force_timezone = os.environ.get('RC_TIMEZONE', '')
820 819 if force_timezone:
821 820 force_timezone = pytz.timezone(force_timezone)
822 821 timezone = force_timezone or local_timezone
823 822 offset = timezone.localize(datetime_iso).strftime('%z')
824 823 tzinfo = '{}:{}'.format(offset[:-2], offset[-2:])
824 return tzinfo
825
826
827 def age_component(datetime_iso, value=None, time_is_local=False, tooltip=True):
828 title = value or format_date(datetime_iso)
829 tzinfo = get_timezone(datetime_iso, time_is_local=time_is_local)
825 830
826 831 return literal(
827 832 '<time class="timeago {cls}" title="{tt_title}" datetime="{dt}{tzinfo}">{title}</time>'.format(
828 833 cls='tooltip' if tooltip else '',
829 834 tt_title=('{title}{tzinfo}'.format(title=title, tzinfo=tzinfo)) if tooltip else '',
830 835 title=title, dt=datetime_iso, tzinfo=tzinfo
831 836 ))
832 837
833 838
834 839 def _shorten_commit_id(commit_id, commit_len=None):
835 840 if commit_len is None:
836 841 request = get_current_request()
837 842 commit_len = request.call_context.visual.show_sha_length
838 843 return commit_id[:commit_len]
839 844
840 845
841 846 def show_id(commit, show_idx=None, commit_len=None):
842 847 """
843 848 Configurable function that shows ID
844 849 by default it's r123:fffeeefffeee
845 850
846 851 :param commit: commit instance
847 852 """
848 853 if show_idx is None:
849 854 request = get_current_request()
850 855 show_idx = request.call_context.visual.show_revision_number
851 856
852 857 raw_id = _shorten_commit_id(commit.raw_id, commit_len=commit_len)
853 858 if show_idx:
854 859 return 'r%s:%s' % (commit.idx, raw_id)
855 860 else:
856 861 return '%s' % (raw_id, )
857 862
858 863
859 864 def format_date(date):
860 865 """
861 866 use a standardized formatting for dates used in RhodeCode
862 867
863 868 :param date: date/datetime object
864 869 :return: formatted date
865 870 """
866 871
867 872 if date:
868 873 _fmt = "%a, %d %b %Y %H:%M:%S"
869 874 return safe_unicode(date.strftime(_fmt))
870 875
871 876 return u""
872 877
873 878
874 879 class _RepoChecker(object):
875 880
876 881 def __init__(self, backend_alias):
877 882 self._backend_alias = backend_alias
878 883
879 884 def __call__(self, repository):
880 885 if hasattr(repository, 'alias'):
881 886 _type = repository.alias
882 887 elif hasattr(repository, 'repo_type'):
883 888 _type = repository.repo_type
884 889 else:
885 890 _type = repository
886 891 return _type == self._backend_alias
887 892
888 893
889 894 is_git = _RepoChecker('git')
890 895 is_hg = _RepoChecker('hg')
891 896 is_svn = _RepoChecker('svn')
892 897
893 898
894 899 def get_repo_type_by_name(repo_name):
895 900 repo = Repository.get_by_repo_name(repo_name)
896 901 if repo:
897 902 return repo.repo_type
898 903
899 904
900 905 def is_svn_without_proxy(repository):
901 906 if is_svn(repository):
902 907 from rhodecode.model.settings import VcsSettingsModel
903 908 conf = VcsSettingsModel().get_ui_settings_as_config_obj()
904 909 return not str2bool(conf.get('vcs_svn_proxy', 'http_requests_enabled'))
905 910 return False
906 911
907 912
908 913 def discover_user(author):
909 914 """
910 915 Tries to discover RhodeCode User based on the author string. Author string
911 916 is typically `FirstName LastName <email@address.com>`
912 917 """
913 918
914 919 # if author is already an instance use it for extraction
915 920 if isinstance(author, User):
916 921 return author
917 922
918 923 # Valid email in the attribute passed, see if they're in the system
919 924 _email = author_email(author)
920 925 if _email != '':
921 926 user = User.get_by_email(_email, case_insensitive=True, cache=True)
922 927 if user is not None:
923 928 return user
924 929
925 930 # Maybe it's a username, we try to extract it and fetch by username ?
926 931 _author = author_name(author)
927 932 user = User.get_by_username(_author, case_insensitive=True, cache=True)
928 933 if user is not None:
929 934 return user
930 935
931 936 return None
932 937
933 938
934 939 def email_or_none(author):
935 940 # extract email from the commit string
936 941 _email = author_email(author)
937 942
938 943 # If we have an email, use it, otherwise
939 944 # see if it contains a username we can get an email from
940 945 if _email != '':
941 946 return _email
942 947 else:
943 948 user = User.get_by_username(
944 949 author_name(author), case_insensitive=True, cache=True)
945 950
946 951 if user is not None:
947 952 return user.email
948 953
949 954 # No valid email, not a valid user in the system, none!
950 955 return None
951 956
952 957
953 958 def link_to_user(author, length=0, **kwargs):
954 959 user = discover_user(author)
955 960 # user can be None, but if we have it already it means we can re-use it
956 961 # in the person() function, so we save 1 intensive-query
957 962 if user:
958 963 author = user
959 964
960 965 display_person = person(author, 'username_or_name_or_email')
961 966 if length:
962 967 display_person = shorter(display_person, length)
963 968
964 969 if user and user.username != user.DEFAULT_USER:
965 970 return link_to(
966 971 escape(display_person),
967 972 route_path('user_profile', username=user.username),
968 973 **kwargs)
969 974 else:
970 975 return escape(display_person)
971 976
972 977
973 978 def link_to_group(users_group_name, **kwargs):
974 979 return link_to(
975 980 escape(users_group_name),
976 981 route_path('user_group_profile', user_group_name=users_group_name),
977 982 **kwargs)
978 983
979 984
980 985 def person(author, show_attr="username_and_name"):
981 986 user = discover_user(author)
982 987 if user:
983 988 return getattr(user, show_attr)
984 989 else:
985 990 _author = author_name(author)
986 991 _email = email(author)
987 992 return _author or _email
988 993
989 994
990 995 def author_string(email):
991 996 if email:
992 997 user = User.get_by_email(email, case_insensitive=True, cache=True)
993 998 if user:
994 999 if user.first_name or user.last_name:
995 1000 return '%s %s &lt;%s&gt;' % (
996 1001 user.first_name, user.last_name, email)
997 1002 else:
998 1003 return email
999 1004 else:
1000 1005 return email
1001 1006 else:
1002 1007 return None
1003 1008
1004 1009
1005 1010 def person_by_id(id_, show_attr="username_and_name"):
1006 1011 # attr to return from fetched user
1007 1012 person_getter = lambda usr: getattr(usr, show_attr)
1008 1013
1009 1014 #maybe it's an ID ?
1010 1015 if str(id_).isdigit() or isinstance(id_, int):
1011 1016 id_ = int(id_)
1012 1017 user = User.get(id_)
1013 1018 if user is not None:
1014 1019 return person_getter(user)
1015 1020 return id_
1016 1021
1017 1022
1018 1023 def gravatar_with_user(request, author, show_disabled=False, tooltip=False):
1019 1024 _render = request.get_partial_renderer('rhodecode:templates/base/base.mako')
1020 1025 return _render('gravatar_with_user', author, show_disabled=show_disabled, tooltip=tooltip)
1021 1026
1022 1027
1023 1028 tags_paterns = OrderedDict((
1024 1029 ('lang', (re.compile(r'\[(lang|language)\ \=\&gt;\ *([a-zA-Z\-\/\#\+\.]*)\]'),
1025 1030 '<div class="metatag" tag="lang">\\2</div>')),
1026 1031
1027 1032 ('see', (re.compile(r'\[see\ \=\&gt;\ *([a-zA-Z0-9\/\=\?\&amp;\ \:\/\.\-]*)\]'),
1028 1033 '<div class="metatag" tag="see">see: \\1 </div>')),
1029 1034
1030 1035 ('url', (re.compile(r'\[url\ \=\&gt;\ \[([a-zA-Z0-9\ \.\-\_]+)\]\((http://|https://|/)(.*?)\)\]'),
1031 1036 '<div class="metatag" tag="url"> <a href="\\2\\3">\\1</a> </div>')),
1032 1037
1033 1038 ('license', (re.compile(r'\[license\ \=\&gt;\ *([a-zA-Z0-9\/\=\?\&amp;\ \:\/\.\-]*)\]'),
1034 1039 '<div class="metatag" tag="license"><a href="http:\/\/www.opensource.org/licenses/\\1">\\1</a></div>')),
1035 1040
1036 1041 ('ref', (re.compile(r'\[(requires|recommends|conflicts|base)\ \=\&gt;\ *([a-zA-Z0-9\-\/]*)\]'),
1037 1042 '<div class="metatag" tag="ref \\1">\\1: <a href="/\\2">\\2</a></div>')),
1038 1043
1039 1044 ('state', (re.compile(r'\[(stable|featured|stale|dead|dev|deprecated)\]'),
1040 1045 '<div class="metatag" tag="state \\1">\\1</div>')),
1041 1046
1042 1047 # label in grey
1043 1048 ('label', (re.compile(r'\[([a-z]+)\]'),
1044 1049 '<div class="metatag" tag="label">\\1</div>')),
1045 1050
1046 1051 # generic catch all in grey
1047 1052 ('generic', (re.compile(r'\[([a-zA-Z0-9\.\-\_]+)\]'),
1048 1053 '<div class="metatag" tag="generic">\\1</div>')),
1049 1054 ))
1050 1055
1051 1056
1052 1057 def extract_metatags(value):
1053 1058 """
1054 1059 Extract supported meta-tags from given text value
1055 1060 """
1056 1061 tags = []
1057 1062 if not value:
1058 1063 return tags, ''
1059 1064
1060 1065 for key, val in tags_paterns.items():
1061 1066 pat, replace_html = val
1062 1067 tags.extend([(key, x.group()) for x in pat.finditer(value)])
1063 1068 value = pat.sub('', value)
1064 1069
1065 1070 return tags, value
1066 1071
1067 1072
1068 1073 def style_metatag(tag_type, value):
1069 1074 """
1070 1075 converts tags from value into html equivalent
1071 1076 """
1072 1077 if not value:
1073 1078 return ''
1074 1079
1075 1080 html_value = value
1076 1081 tag_data = tags_paterns.get(tag_type)
1077 1082 if tag_data:
1078 1083 pat, replace_html = tag_data
1079 1084 # convert to plain `unicode` instead of a markup tag to be used in
1080 1085 # regex expressions. safe_unicode doesn't work here
1081 1086 html_value = pat.sub(replace_html, unicode(value))
1082 1087
1083 1088 return html_value
1084 1089
1085 1090
1086 1091 def bool2icon(value, show_at_false=True):
1087 1092 """
1088 1093 Returns boolean value of a given value, represented as html element with
1089 1094 classes that will represent icons
1090 1095
1091 1096 :param value: given value to convert to html node
1092 1097 """
1093 1098
1094 1099 if value: # does bool conversion
1095 1100 return HTML.tag('i', class_="icon-true", title='True')
1096 1101 else: # not true as bool
1097 1102 if show_at_false:
1098 1103 return HTML.tag('i', class_="icon-false", title='False')
1099 1104 return HTML.tag('i')
1100 1105
1101 1106 #==============================================================================
1102 1107 # PERMS
1103 1108 #==============================================================================
1104 1109 from rhodecode.lib.auth import (
1105 1110 HasPermissionAny, HasPermissionAll,
1106 1111 HasRepoPermissionAny, HasRepoPermissionAll, HasRepoGroupPermissionAll,
1107 1112 HasRepoGroupPermissionAny, HasRepoPermissionAnyApi, get_csrf_token,
1108 1113 csrf_token_key, AuthUser)
1109 1114
1110 1115
1111 1116 #==============================================================================
1112 1117 # GRAVATAR URL
1113 1118 #==============================================================================
1114 1119 class InitialsGravatar(object):
1115 1120 def __init__(self, email_address, first_name, last_name, size=30,
1116 1121 background=None, text_color='#fff'):
1117 1122 self.size = size
1118 1123 self.first_name = first_name
1119 1124 self.last_name = last_name
1120 1125 self.email_address = email_address
1121 1126 self.background = background or self.str2color(email_address)
1122 1127 self.text_color = text_color
1123 1128
1124 1129 def get_color_bank(self):
1125 1130 """
1126 1131 returns a predefined list of colors that gravatars can use.
1127 1132 Those are randomized distinct colors that guarantee readability and
1128 1133 uniqueness.
1129 1134
1130 1135 generated with: http://phrogz.net/css/distinct-colors.html
1131 1136 """
1132 1137 return [
1133 1138 '#bf3030', '#a67f53', '#00ff00', '#5989b3', '#392040', '#d90000',
1134 1139 '#402910', '#204020', '#79baf2', '#a700b3', '#bf6060', '#7f5320',
1135 1140 '#008000', '#003059', '#ee00ff', '#ff0000', '#8c4b00', '#007300',
1136 1141 '#005fb3', '#de73e6', '#ff4040', '#ffaa00', '#3df255', '#203140',
1137 1142 '#47004d', '#591616', '#664400', '#59b365', '#0d2133', '#83008c',
1138 1143 '#592d2d', '#bf9f60', '#73e682', '#1d3f73', '#73006b', '#402020',
1139 1144 '#b2862d', '#397341', '#597db3', '#e600d6', '#a60000', '#736039',
1140 1145 '#00b318', '#79aaf2', '#330d30', '#ff8080', '#403010', '#16591f',
1141 1146 '#002459', '#8c4688', '#e50000', '#ffbf40', '#00732e', '#102340',
1142 1147 '#bf60ac', '#8c4646', '#cc8800', '#00a642', '#1d3473', '#b32d98',
1143 1148 '#660e00', '#ffd580', '#80ffb2', '#7391e6', '#733967', '#d97b6c',
1144 1149 '#8c5e00', '#59b389', '#3967e6', '#590047', '#73281d', '#665200',
1145 1150 '#00e67a', '#2d50b3', '#8c2377', '#734139', '#b2982d', '#16593a',
1146 1151 '#001859', '#ff00aa', '#a65e53', '#ffcc00', '#0d3321', '#2d3959',
1147 1152 '#731d56', '#401610', '#4c3d00', '#468c6c', '#002ca6', '#d936a3',
1148 1153 '#d94c36', '#403920', '#36d9a3', '#0d1733', '#592d4a', '#993626',
1149 1154 '#cca300', '#00734d', '#46598c', '#8c005e', '#7f1100', '#8c7000',
1150 1155 '#00a66f', '#7382e6', '#b32d74', '#d9896c', '#ffe680', '#1d7362',
1151 1156 '#364cd9', '#73003d', '#d93a00', '#998a4d', '#59b3a1', '#5965b3',
1152 1157 '#e5007a', '#73341d', '#665f00', '#00b38f', '#0018b3', '#59163a',
1153 1158 '#b2502d', '#bfb960', '#00ffcc', '#23318c', '#a6537f', '#734939',
1154 1159 '#b2a700', '#104036', '#3d3df2', '#402031', '#e56739', '#736f39',
1155 1160 '#79f2ea', '#000059', '#401029', '#4c1400', '#ffee00', '#005953',
1156 1161 '#101040', '#990052', '#402820', '#403d10', '#00ffee', '#0000d9',
1157 1162 '#ff80c4', '#a66953', '#eeff00', '#00ccbe', '#8080ff', '#e673a1',
1158 1163 '#a62c00', '#474d00', '#1a3331', '#46468c', '#733950', '#662900',
1159 1164 '#858c23', '#238c85', '#0f0073', '#b20047', '#d9986c', '#becc00',
1160 1165 '#396f73', '#281d73', '#ff0066', '#ff6600', '#dee673', '#59adb3',
1161 1166 '#6559b3', '#590024', '#b2622d', '#98b32d', '#36ced9', '#332d59',
1162 1167 '#40001a', '#733f1d', '#526600', '#005359', '#242040', '#bf6079',
1163 1168 '#735039', '#cef23d', '#007780', '#5630bf', '#66001b', '#b24700',
1164 1169 '#acbf60', '#1d6273', '#25008c', '#731d34', '#a67453', '#50592d',
1165 1170 '#00ccff', '#6600ff', '#ff0044', '#4c1f00', '#8a994d', '#79daf2',
1166 1171 '#a173e6', '#d93662', '#402310', '#aaff00', '#2d98b3', '#8c40ff',
1167 1172 '#592d39', '#ff8c40', '#354020', '#103640', '#1a0040', '#331a20',
1168 1173 '#331400', '#334d00', '#1d5673', '#583973', '#7f0022', '#4c3626',
1169 1174 '#88cc00', '#36a3d9', '#3d0073', '#d9364c', '#33241a', '#698c23',
1170 1175 '#5995b3', '#300059', '#e57382', '#7f3300', '#366600', '#00aaff',
1171 1176 '#3a1659', '#733941', '#663600', '#74b32d', '#003c59', '#7f53a6',
1172 1177 '#73000f', '#ff8800', '#baf279', '#79caf2', '#291040', '#a6293a',
1173 1178 '#b2742d', '#587339', '#0077b3', '#632699', '#400009', '#d9a66c',
1174 1179 '#294010', '#2d4a59', '#aa00ff', '#4c131b', '#b25f00', '#5ce600',
1175 1180 '#267399', '#a336d9', '#990014', '#664e33', '#86bf60', '#0088ff',
1176 1181 '#7700b3', '#593a16', '#073300', '#1d4b73', '#ac60bf', '#e59539',
1177 1182 '#4f8c46', '#368dd9', '#5c0073'
1178 1183 ]
1179 1184
1180 1185 def rgb_to_hex_color(self, rgb_tuple):
1181 1186 """
1182 1187 Converts an rgb_tuple passed to an hex color.
1183 1188
1184 1189 :param rgb_tuple: tuple with 3 ints represents rgb color space
1185 1190 """
1186 1191 return '#' + ("".join(map(chr, rgb_tuple)).encode('hex'))
1187 1192
1188 1193 def email_to_int_list(self, email_str):
1189 1194 """
1190 1195 Get every byte of the hex digest value of email and turn it to integer.
1191 1196 It's going to be always between 0-255
1192 1197 """
1193 1198 digest = md5_safe(email_str.lower())
1194 1199 return [int(digest[i * 2:i * 2 + 2], 16) for i in range(16)]
1195 1200
1196 1201 def pick_color_bank_index(self, email_str, color_bank):
1197 1202 return self.email_to_int_list(email_str)[0] % len(color_bank)
1198 1203
1199 1204 def str2color(self, email_str):
1200 1205 """
1201 1206 Tries to map in a stable algorithm an email to color
1202 1207
1203 1208 :param email_str:
1204 1209 """
1205 1210 color_bank = self.get_color_bank()
1206 1211 # pick position (module it's length so we always find it in the
1207 1212 # bank even if it's smaller than 256 values
1208 1213 pos = self.pick_color_bank_index(email_str, color_bank)
1209 1214 return color_bank[pos]
1210 1215
1211 1216 def normalize_email(self, email_address):
1212 1217 import unicodedata
1213 1218 # default host used to fill in the fake/missing email
1214 1219 default_host = u'localhost'
1215 1220
1216 1221 if not email_address:
1217 1222 email_address = u'%s@%s' % (User.DEFAULT_USER, default_host)
1218 1223
1219 1224 email_address = safe_unicode(email_address)
1220 1225
1221 1226 if u'@' not in email_address:
1222 1227 email_address = u'%s@%s' % (email_address, default_host)
1223 1228
1224 1229 if email_address.endswith(u'@'):
1225 1230 email_address = u'%s%s' % (email_address, default_host)
1226 1231
1227 1232 email_address = unicodedata.normalize('NFKD', email_address)\
1228 1233 .encode('ascii', 'ignore')
1229 1234 return email_address
1230 1235
1231 1236 def get_initials(self):
1232 1237 """
1233 1238 Returns 2 letter initials calculated based on the input.
1234 1239 The algorithm picks first given email address, and takes first letter
1235 1240 of part before @, and then the first letter of server name. In case
1236 1241 the part before @ is in a format of `somestring.somestring2` it replaces
1237 1242 the server letter with first letter of somestring2
1238 1243
1239 1244 In case function was initialized with both first and lastname, this
1240 1245 overrides the extraction from email by first letter of the first and
1241 1246 last name. We add special logic to that functionality, In case Full name
1242 1247 is compound, like Guido Von Rossum, we use last part of the last name
1243 1248 (Von Rossum) picking `R`.
1244 1249
1245 1250 Function also normalizes the non-ascii characters to they ascii
1246 1251 representation, eg Ą => A
1247 1252 """
1248 1253 import unicodedata
1249 1254 # replace non-ascii to ascii
1250 1255 first_name = unicodedata.normalize(
1251 1256 'NFKD', safe_unicode(self.first_name)).encode('ascii', 'ignore')
1252 1257 last_name = unicodedata.normalize(
1253 1258 'NFKD', safe_unicode(self.last_name)).encode('ascii', 'ignore')
1254 1259
1255 1260 # do NFKD encoding, and also make sure email has proper format
1256 1261 email_address = self.normalize_email(self.email_address)
1257 1262
1258 1263 # first push the email initials
1259 1264 prefix, server = email_address.split('@', 1)
1260 1265
1261 1266 # check if prefix is maybe a 'first_name.last_name' syntax
1262 1267 _dot_split = prefix.rsplit('.', 1)
1263 1268 if len(_dot_split) == 2 and _dot_split[1]:
1264 1269 initials = [_dot_split[0][0], _dot_split[1][0]]
1265 1270 else:
1266 1271 initials = [prefix[0], server[0]]
1267 1272
1268 1273 # then try to replace either first_name or last_name
1269 1274 fn_letter = (first_name or " ")[0].strip()
1270 1275 ln_letter = (last_name.split(' ', 1)[-1] or " ")[0].strip()
1271 1276
1272 1277 if fn_letter:
1273 1278 initials[0] = fn_letter
1274 1279
1275 1280 if ln_letter:
1276 1281 initials[1] = ln_letter
1277 1282
1278 1283 return ''.join(initials).upper()
1279 1284
1280 1285 def get_img_data_by_type(self, font_family, img_type):
1281 1286 default_user = """
1282 1287 <svg xmlns="http://www.w3.org/2000/svg"
1283 1288 version="1.1" x="0px" y="0px" width="{size}" height="{size}"
1284 1289 viewBox="-15 -10 439.165 429.164"
1285 1290
1286 1291 xml:space="preserve"
1287 1292 style="background:{background};" >
1288 1293
1289 1294 <path d="M204.583,216.671c50.664,0,91.74-48.075,
1290 1295 91.74-107.378c0-82.237-41.074-107.377-91.74-107.377
1291 1296 c-50.668,0-91.74,25.14-91.74,107.377C112.844,
1292 1297 168.596,153.916,216.671,
1293 1298 204.583,216.671z" fill="{text_color}"/>
1294 1299 <path d="M407.164,374.717L360.88,
1295 1300 270.454c-2.117-4.771-5.836-8.728-10.465-11.138l-71.83-37.392
1296 1301 c-1.584-0.823-3.502-0.663-4.926,0.415c-20.316,
1297 1302 15.366-44.203,23.488-69.076,23.488c-24.877,
1298 1303 0-48.762-8.122-69.078-23.488
1299 1304 c-1.428-1.078-3.346-1.238-4.93-0.415L58.75,
1300 1305 259.316c-4.631,2.41-8.346,6.365-10.465,11.138L2.001,374.717
1301 1306 c-3.191,7.188-2.537,15.412,1.75,22.005c4.285,
1302 1307 6.592,11.537,10.526,19.4,10.526h362.861c7.863,0,15.117-3.936,
1303 1308 19.402-10.527 C409.699,390.129,
1304 1309 410.355,381.902,407.164,374.717z" fill="{text_color}"/>
1305 1310 </svg>""".format(
1306 1311 size=self.size,
1307 1312 background='#979797', # @grey4
1308 1313 text_color=self.text_color,
1309 1314 font_family=font_family)
1310 1315
1311 1316 return {
1312 1317 "default_user": default_user
1313 1318 }[img_type]
1314 1319
1315 1320 def get_img_data(self, svg_type=None):
1316 1321 """
1317 1322 generates the svg metadata for image
1318 1323 """
1319 1324 fonts = [
1320 1325 '-apple-system',
1321 1326 'BlinkMacSystemFont',
1322 1327 'Segoe UI',
1323 1328 'Roboto',
1324 1329 'Oxygen-Sans',
1325 1330 'Ubuntu',
1326 1331 'Cantarell',
1327 1332 'Helvetica Neue',
1328 1333 'sans-serif'
1329 1334 ]
1330 1335 font_family = ','.join(fonts)
1331 1336 if svg_type:
1332 1337 return self.get_img_data_by_type(font_family, svg_type)
1333 1338
1334 1339 initials = self.get_initials()
1335 1340 img_data = """
1336 1341 <svg xmlns="http://www.w3.org/2000/svg" pointer-events="none"
1337 1342 width="{size}" height="{size}"
1338 1343 style="width: 100%; height: 100%; background-color: {background}"
1339 1344 viewBox="0 0 {size} {size}">
1340 1345 <text text-anchor="middle" y="50%" x="50%" dy="0.35em"
1341 1346 pointer-events="auto" fill="{text_color}"
1342 1347 font-family="{font_family}"
1343 1348 style="font-weight: 400; font-size: {f_size}px;">{text}
1344 1349 </text>
1345 1350 </svg>""".format(
1346 1351 size=self.size,
1347 1352 f_size=self.size/2.05, # scale the text inside the box nicely
1348 1353 background=self.background,
1349 1354 text_color=self.text_color,
1350 1355 text=initials.upper(),
1351 1356 font_family=font_family)
1352 1357
1353 1358 return img_data
1354 1359
1355 1360 def generate_svg(self, svg_type=None):
1356 1361 img_data = self.get_img_data(svg_type)
1357 1362 return "data:image/svg+xml;base64,%s" % base64.b64encode(img_data)
1358 1363
1359 1364
1360 1365 def initials_gravatar(request, email_address, first_name, last_name, size=30, store_on_disk=False):
1361 1366
1362 1367 svg_type = None
1363 1368 if email_address == User.DEFAULT_USER_EMAIL:
1364 1369 svg_type = 'default_user'
1365 1370
1366 1371 klass = InitialsGravatar(email_address, first_name, last_name, size)
1367 1372
1368 1373 if store_on_disk:
1369 1374 from rhodecode.apps.file_store import utils as store_utils
1370 1375 from rhodecode.apps.file_store.exceptions import FileNotAllowedException, \
1371 1376 FileOverSizeException
1372 1377 from rhodecode.model.db import Session
1373 1378
1374 1379 image_key = md5_safe(email_address.lower()
1375 1380 + first_name.lower() + last_name.lower())
1376 1381
1377 1382 storage = store_utils.get_file_storage(request.registry.settings)
1378 1383 filename = '{}.svg'.format(image_key)
1379 1384 subdir = 'gravatars'
1380 1385 # since final name has a counter, we apply the 0
1381 1386 uid = storage.apply_counter(0, store_utils.uid_filename(filename, randomized=False))
1382 1387 store_uid = os.path.join(subdir, uid)
1383 1388
1384 1389 db_entry = FileStore.get_by_store_uid(store_uid)
1385 1390 if db_entry:
1386 1391 return request.route_path('download_file', fid=store_uid)
1387 1392
1388 1393 img_data = klass.get_img_data(svg_type=svg_type)
1389 1394 img_file = store_utils.bytes_to_file_obj(img_data)
1390 1395
1391 1396 try:
1392 1397 store_uid, metadata = storage.save_file(
1393 1398 img_file, filename, directory=subdir,
1394 1399 extensions=['.svg'], randomized_name=False)
1395 1400 except (FileNotAllowedException, FileOverSizeException):
1396 1401 raise
1397 1402
1398 1403 try:
1399 1404 entry = FileStore.create(
1400 1405 file_uid=store_uid, filename=metadata["filename"],
1401 1406 file_hash=metadata["sha256"], file_size=metadata["size"],
1402 1407 file_display_name=filename,
1403 1408 file_description=u'user gravatar `{}`'.format(safe_unicode(filename)),
1404 1409 hidden=True, check_acl=False, user_id=1
1405 1410 )
1406 1411 Session().add(entry)
1407 1412 Session().commit()
1408 1413 log.debug('Stored upload in DB as %s', entry)
1409 1414 except Exception:
1410 1415 raise
1411 1416
1412 1417 return request.route_path('download_file', fid=store_uid)
1413 1418
1414 1419 else:
1415 1420 return klass.generate_svg(svg_type=svg_type)
1416 1421
1417 1422
1418 1423 def gravatar_external(request, gravatar_url_tmpl, email_address, size=30):
1419 1424 return safe_str(gravatar_url_tmpl)\
1420 1425 .replace('{email}', email_address) \
1421 1426 .replace('{md5email}', md5_safe(email_address.lower())) \
1422 1427 .replace('{netloc}', request.host) \
1423 1428 .replace('{scheme}', request.scheme) \
1424 1429 .replace('{size}', safe_str(size))
1425 1430
1426 1431
1427 1432 def gravatar_url(email_address, size=30, request=None):
1428 1433 request = request or get_current_request()
1429 1434 _use_gravatar = request.call_context.visual.use_gravatar
1430 1435
1431 1436 email_address = email_address or User.DEFAULT_USER_EMAIL
1432 1437 if isinstance(email_address, unicode):
1433 1438 # hashlib crashes on unicode items
1434 1439 email_address = safe_str(email_address)
1435 1440
1436 1441 # empty email or default user
1437 1442 if not email_address or email_address == User.DEFAULT_USER_EMAIL:
1438 1443 return initials_gravatar(request, User.DEFAULT_USER_EMAIL, '', '', size=size)
1439 1444
1440 1445 if _use_gravatar:
1441 1446 gravatar_url_tmpl = request.call_context.visual.gravatar_url \
1442 1447 or User.DEFAULT_GRAVATAR_URL
1443 1448 return gravatar_external(request, gravatar_url_tmpl, email_address, size=size)
1444 1449
1445 1450 else:
1446 1451 return initials_gravatar(request, email_address, '', '', size=size)
1447 1452
1448 1453
1449 1454 def breadcrumb_repo_link(repo):
1450 1455 """
1451 1456 Makes a breadcrumbs path link to repo
1452 1457
1453 1458 ex::
1454 1459 group >> subgroup >> repo
1455 1460
1456 1461 :param repo: a Repository instance
1457 1462 """
1458 1463
1459 1464 path = [
1460 1465 link_to(group.name, route_path('repo_group_home', repo_group_name=group.group_name),
1461 1466 title='last change:{}'.format(format_date(group.last_commit_change)))
1462 1467 for group in repo.groups_with_parents
1463 1468 ] + [
1464 1469 link_to(repo.just_name, route_path('repo_summary', repo_name=repo.repo_name),
1465 1470 title='last change:{}'.format(format_date(repo.last_commit_change)))
1466 1471 ]
1467 1472
1468 1473 return literal(' &raquo; '.join(path))
1469 1474
1470 1475
1471 1476 def breadcrumb_repo_group_link(repo_group):
1472 1477 """
1473 1478 Makes a breadcrumbs path link to repo
1474 1479
1475 1480 ex::
1476 1481 group >> subgroup
1477 1482
1478 1483 :param repo_group: a Repository Group instance
1479 1484 """
1480 1485
1481 1486 path = [
1482 1487 link_to(group.name,
1483 1488 route_path('repo_group_home', repo_group_name=group.group_name),
1484 1489 title='last change:{}'.format(format_date(group.last_commit_change)))
1485 1490 for group in repo_group.parents
1486 1491 ] + [
1487 1492 link_to(repo_group.name,
1488 1493 route_path('repo_group_home', repo_group_name=repo_group.group_name),
1489 1494 title='last change:{}'.format(format_date(repo_group.last_commit_change)))
1490 1495 ]
1491 1496
1492 1497 return literal(' &raquo; '.join(path))
1493 1498
1494 1499
1495 1500 def format_byte_size_binary(file_size):
1496 1501 """
1497 1502 Formats file/folder sizes to standard.
1498 1503 """
1499 1504 if file_size is None:
1500 1505 file_size = 0
1501 1506
1502 1507 formatted_size = format_byte_size(file_size, binary=True)
1503 1508 return formatted_size
1504 1509
1505 1510
1506 1511 def urlify_text(text_, safe=True, **href_attrs):
1507 1512 """
1508 1513 Extract urls from text and make html links out of them
1509 1514 """
1510 1515
1511 1516 url_pat = re.compile(r'''(http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@#.&+]'''
1512 1517 '''|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+)''')
1513 1518
1514 1519 def url_func(match_obj):
1515 1520 url_full = match_obj.groups()[0]
1516 1521 a_options = dict(href_attrs)
1517 1522 a_options['href'] = url_full
1518 1523 a_text = url_full
1519 1524 return HTML.tag("a", a_text, **a_options)
1520 1525
1521 1526 _new_text = url_pat.sub(url_func, text_)
1522 1527
1523 1528 if safe:
1524 1529 return literal(_new_text)
1525 1530 return _new_text
1526 1531
1527 1532
1528 1533 def urlify_commits(text_, repo_name):
1529 1534 """
1530 1535 Extract commit ids from text and make link from them
1531 1536
1532 1537 :param text_:
1533 1538 :param repo_name: repo name to build the URL with
1534 1539 """
1535 1540
1536 1541 url_pat = re.compile(r'(^|\s)([0-9a-fA-F]{12,40})($|\s)')
1537 1542
1538 1543 def url_func(match_obj):
1539 1544 commit_id = match_obj.groups()[1]
1540 1545 pref = match_obj.groups()[0]
1541 1546 suf = match_obj.groups()[2]
1542 1547
1543 1548 tmpl = (
1544 1549 '%(pref)s<a class="tooltip-hovercard %(cls)s" href="%(url)s" data-hovercard-alt="%(hovercard_alt)s" data-hovercard-url="%(hovercard_url)s">'
1545 1550 '%(commit_id)s</a>%(suf)s'
1546 1551 )
1547 1552 return tmpl % {
1548 1553 'pref': pref,
1549 1554 'cls': 'revision-link',
1550 1555 'url': route_url(
1551 1556 'repo_commit', repo_name=repo_name, commit_id=commit_id),
1552 1557 'commit_id': commit_id,
1553 1558 'suf': suf,
1554 1559 'hovercard_alt': 'Commit: {}'.format(commit_id),
1555 1560 'hovercard_url': route_url(
1556 1561 'hovercard_repo_commit', repo_name=repo_name, commit_id=commit_id)
1557 1562 }
1558 1563
1559 1564 new_text = url_pat.sub(url_func, text_)
1560 1565
1561 1566 return new_text
1562 1567
1563 1568
1564 1569 def _process_url_func(match_obj, repo_name, uid, entry,
1565 1570 return_raw_data=False, link_format='html'):
1566 1571 pref = ''
1567 1572 if match_obj.group().startswith(' '):
1568 1573 pref = ' '
1569 1574
1570 1575 issue_id = ''.join(match_obj.groups())
1571 1576
1572 1577 if link_format == 'html':
1573 1578 tmpl = (
1574 1579 '%(pref)s<a class="tooltip %(cls)s" href="%(url)s" title="%(title)s">'
1575 1580 '%(issue-prefix)s%(id-repr)s'
1576 1581 '</a>')
1577 1582 elif link_format == 'html+hovercard':
1578 1583 tmpl = (
1579 1584 '%(pref)s<a class="tooltip-hovercard %(cls)s" href="%(url)s" data-hovercard-url="%(hovercard_url)s">'
1580 1585 '%(issue-prefix)s%(id-repr)s'
1581 1586 '</a>')
1582 1587 elif link_format in ['rst', 'rst+hovercard']:
1583 1588 tmpl = '`%(issue-prefix)s%(id-repr)s <%(url)s>`_'
1584 1589 elif link_format in ['markdown', 'markdown+hovercard']:
1585 1590 tmpl = '[%(pref)s%(issue-prefix)s%(id-repr)s](%(url)s)'
1586 1591 else:
1587 1592 raise ValueError('Bad link_format:{}'.format(link_format))
1588 1593
1589 1594 (repo_name_cleaned,
1590 1595 parent_group_name) = RepoGroupModel()._get_group_name_and_parent(repo_name)
1591 1596
1592 1597 # variables replacement
1593 1598 named_vars = {
1594 1599 'id': issue_id,
1595 1600 'repo': repo_name,
1596 1601 'repo_name': repo_name_cleaned,
1597 1602 'group_name': parent_group_name,
1598 1603 # set dummy keys so we always have them
1599 1604 'hostname': '',
1600 1605 'netloc': '',
1601 1606 'scheme': ''
1602 1607 }
1603 1608
1604 1609 request = get_current_request()
1605 1610 if request:
1606 1611 # exposes, hostname, netloc, scheme
1607 1612 host_data = get_host_info(request)
1608 1613 named_vars.update(host_data)
1609 1614
1610 1615 # named regex variables
1611 1616 named_vars.update(match_obj.groupdict())
1612 1617 _url = string.Template(entry['url']).safe_substitute(**named_vars)
1613 1618 desc = string.Template(entry['desc']).safe_substitute(**named_vars)
1614 1619 hovercard_url = string.Template(entry.get('hovercard_url', '')).safe_substitute(**named_vars)
1615 1620
1616 1621 def quote_cleaner(input_str):
1617 1622 """Remove quotes as it's HTML"""
1618 1623 return input_str.replace('"', '')
1619 1624
1620 1625 data = {
1621 1626 'pref': pref,
1622 1627 'cls': quote_cleaner('issue-tracker-link'),
1623 1628 'url': quote_cleaner(_url),
1624 1629 'id-repr': issue_id,
1625 1630 'issue-prefix': entry['pref'],
1626 1631 'serv': entry['url'],
1627 1632 'title': bleach.clean(desc, strip=True),
1628 1633 'hovercard_url': hovercard_url
1629 1634 }
1630 1635
1631 1636 if return_raw_data:
1632 1637 return {
1633 1638 'id': issue_id,
1634 1639 'url': _url
1635 1640 }
1636 1641 return tmpl % data
1637 1642
1638 1643
1639 1644 def get_active_pattern_entries(repo_name):
1640 1645 repo = None
1641 1646 if repo_name:
1642 1647 # Retrieving repo_name to avoid invalid repo_name to explode on
1643 1648 # IssueTrackerSettingsModel but still passing invalid name further down
1644 1649 repo = Repository.get_by_repo_name(repo_name, cache=True)
1645 1650
1646 1651 settings_model = IssueTrackerSettingsModel(repo=repo)
1647 1652 active_entries = settings_model.get_settings(cache=True)
1648 1653 return active_entries
1649 1654
1650 1655
1651 1656 pr_pattern_re = re.compile(r'(?:(?:^!)|(?: !))(\d+)')
1652 1657
1658 allowed_link_formats = [
1659 'html', 'rst', 'markdown', 'html+hovercard', 'rst+hovercard', 'markdown+hovercard']
1660
1653 1661
1654 1662 def process_patterns(text_string, repo_name, link_format='html', active_entries=None):
1655 1663
1656 allowed_formats = ['html', 'rst', 'markdown',
1657 'html+hovercard', 'rst+hovercard', 'markdown+hovercard']
1658 if link_format not in allowed_formats:
1664 if link_format not in allowed_link_formats:
1659 1665 raise ValueError('Link format can be only one of:{} got {}'.format(
1660 allowed_formats, link_format))
1666 allowed_link_formats, link_format))
1661 1667
1662 1668 if active_entries is None:
1663 log.debug('Fetch active patterns for repo: %s', repo_name)
1669 log.debug('Fetch active issue tracker patterns for repo: %s', repo_name)
1664 1670 active_entries = get_active_pattern_entries(repo_name)
1665 1671
1666 1672 issues_data = []
1667 1673 new_text = text_string
1668 1674
1669 1675 log.debug('Got %s entries to process', len(active_entries))
1670 1676 for uid, entry in active_entries.items():
1671 1677 log.debug('found issue tracker entry with uid %s', uid)
1672 1678
1673 1679 if not (entry['pat'] and entry['url']):
1674 1680 log.debug('skipping due to missing data')
1675 1681 continue
1676 1682
1677 1683 log.debug('issue tracker entry: uid: `%s` PAT:%s URL:%s PREFIX:%s',
1678 1684 uid, entry['pat'], entry['url'], entry['pref'])
1679 1685
1680 1686 if entry.get('pat_compiled'):
1681 1687 pattern = entry['pat_compiled']
1682 1688 else:
1683 1689 try:
1684 1690 pattern = re.compile(r'%s' % entry['pat'])
1685 1691 except re.error:
1686 1692 log.exception('issue tracker pattern: `%s` failed to compile', entry['pat'])
1687 1693 continue
1688 1694
1689 1695 data_func = partial(
1690 1696 _process_url_func, repo_name=repo_name, entry=entry, uid=uid,
1691 1697 return_raw_data=True)
1692 1698
1693 1699 for match_obj in pattern.finditer(text_string):
1694 1700 issues_data.append(data_func(match_obj))
1695 1701
1696 1702 url_func = partial(
1697 1703 _process_url_func, repo_name=repo_name, entry=entry, uid=uid,
1698 1704 link_format=link_format)
1699 1705
1700 1706 new_text = pattern.sub(url_func, new_text)
1701 1707 log.debug('processed prefix:uid `%s`', uid)
1702 1708
1703 1709 # finally use global replace, eg !123 -> pr-link, those will not catch
1704 1710 # if already similar pattern exists
1705 1711 server_url = '${scheme}://${netloc}'
1706 1712 pr_entry = {
1707 1713 'pref': '!',
1708 1714 'url': server_url + '/_admin/pull-requests/${id}',
1709 1715 'desc': 'Pull Request !${id}',
1710 1716 'hovercard_url': server_url + '/_hovercard/pull_request/${id}'
1711 1717 }
1712 1718 pr_url_func = partial(
1713 1719 _process_url_func, repo_name=repo_name, entry=pr_entry, uid=None,
1714 1720 link_format=link_format+'+hovercard')
1715 1721 new_text = pr_pattern_re.sub(pr_url_func, new_text)
1716 1722 log.debug('processed !pr pattern')
1717 1723
1718 1724 return new_text, issues_data
1719 1725
1720 1726
1721 def urlify_commit_message(commit_text, repository=None, active_pattern_entries=None):
1727 def urlify_commit_message(commit_text, repository=None, active_pattern_entries=None,
1728 issues_container=None):
1722 1729 """
1723 1730 Parses given text message and makes proper links.
1724 1731 issues are linked to given issue-server, and rest is a commit link
1725 1732 """
1726 1733
1727 1734 def escaper(_text):
1728 1735 return _text.replace('<', '&lt;').replace('>', '&gt;')
1729 1736
1730 1737 new_text = escaper(commit_text)
1731 1738
1732 1739 # extract http/https links and make them real urls
1733 1740 new_text = urlify_text(new_text, safe=False)
1734 1741
1735 1742 # urlify commits - extract commit ids and make link out of them, if we have
1736 1743 # the scope of repository present.
1737 1744 if repository:
1738 1745 new_text = urlify_commits(new_text, repository)
1739 1746
1740 1747 # process issue tracker patterns
1741 1748 new_text, issues = process_patterns(new_text, repository or '',
1742 1749 active_entries=active_pattern_entries)
1743 1750
1751 if issues_container is not None:
1752 issues_container.extend(issues)
1753
1744 1754 return literal(new_text)
1745 1755
1746 1756
1747 1757 def render_binary(repo_name, file_obj):
1748 1758 """
1749 1759 Choose how to render a binary file
1750 1760 """
1751 1761
1752 1762 # unicode
1753 1763 filename = file_obj.name
1754 1764
1755 1765 # images
1756 1766 for ext in ['*.png', '*.jpeg', '*.jpg', '*.ico', '*.gif']:
1757 1767 if fnmatch.fnmatch(filename, pat=ext):
1758 1768 src = route_path(
1759 1769 'repo_file_raw', repo_name=repo_name,
1760 1770 commit_id=file_obj.commit.raw_id,
1761 1771 f_path=file_obj.path)
1762 1772
1763 1773 return literal(
1764 1774 '<img class="rendered-binary" alt="rendered-image" src="{}">'.format(src))
1765 1775
1766 1776
1767 1777 def renderer_from_filename(filename, exclude=None):
1768 1778 """
1769 1779 choose a renderer based on filename, this works only for text based files
1770 1780 """
1771 1781
1772 1782 # ipython
1773 1783 for ext in ['*.ipynb']:
1774 1784 if fnmatch.fnmatch(filename, pat=ext):
1775 1785 return 'jupyter'
1776 1786
1777 1787 is_markup = MarkupRenderer.renderer_from_filename(filename, exclude=exclude)
1778 1788 if is_markup:
1779 1789 return is_markup
1780 1790 return None
1781 1791
1782 1792
1783 1793 def render(source, renderer='rst', mentions=False, relative_urls=None,
1784 repo_name=None, active_pattern_entries=None):
1794 repo_name=None, active_pattern_entries=None, issues_container=None):
1785 1795
1786 1796 def maybe_convert_relative_links(html_source):
1787 1797 if relative_urls:
1788 1798 return relative_links(html_source, relative_urls)
1789 1799 return html_source
1790 1800
1791 1801 if renderer == 'plain':
1792 1802 return literal(
1793 1803 MarkupRenderer.plain(source, leading_newline=False))
1794 1804
1795 1805 elif renderer == 'rst':
1796 1806 if repo_name:
1797 1807 # process patterns on comments if we pass in repo name
1798 1808 source, issues = process_patterns(
1799 1809 source, repo_name, link_format='rst',
1800 1810 active_entries=active_pattern_entries)
1811 if issues_container is not None:
1812 issues_container.extend(issues)
1801 1813
1802 1814 return literal(
1803 1815 '<div class="rst-block">%s</div>' %
1804 1816 maybe_convert_relative_links(
1805 1817 MarkupRenderer.rst(source, mentions=mentions)))
1806 1818
1807 1819 elif renderer == 'markdown':
1808 1820 if repo_name:
1809 1821 # process patterns on comments if we pass in repo name
1810 1822 source, issues = process_patterns(
1811 1823 source, repo_name, link_format='markdown',
1812 1824 active_entries=active_pattern_entries)
1825 if issues_container is not None:
1826 issues_container.extend(issues)
1813 1827
1814 1828 return literal(
1815 1829 '<div class="markdown-block">%s</div>' %
1816 1830 maybe_convert_relative_links(
1817 1831 MarkupRenderer.markdown(source, flavored=True,
1818 1832 mentions=mentions)))
1819 1833
1820 1834 elif renderer == 'jupyter':
1821 1835 return literal(
1822 1836 '<div class="ipynb">%s</div>' %
1823 1837 maybe_convert_relative_links(
1824 1838 MarkupRenderer.jupyter(source)))
1825 1839
1826 1840 # None means just show the file-source
1827 1841 return None
1828 1842
1829 1843
1830 1844 def commit_status(repo, commit_id):
1831 1845 return ChangesetStatusModel().get_status(repo, commit_id)
1832 1846
1833 1847
1834 1848 def commit_status_lbl(commit_status):
1835 1849 return dict(ChangesetStatus.STATUSES).get(commit_status)
1836 1850
1837 1851
1838 1852 def commit_time(repo_name, commit_id):
1839 1853 repo = Repository.get_by_repo_name(repo_name)
1840 1854 commit = repo.get_commit(commit_id=commit_id)
1841 1855 return commit.date
1842 1856
1843 1857
1844 1858 def get_permission_name(key):
1845 1859 return dict(Permission.PERMS).get(key)
1846 1860
1847 1861
1848 1862 def journal_filter_help(request):
1849 1863 _ = request.translate
1850 1864 from rhodecode.lib.audit_logger import ACTIONS
1851 1865 actions = '\n'.join(textwrap.wrap(', '.join(sorted(ACTIONS.keys())), 80))
1852 1866
1853 1867 return _(
1854 1868 'Example filter terms:\n' +
1855 1869 ' repository:vcs\n' +
1856 1870 ' username:marcin\n' +
1857 1871 ' username:(NOT marcin)\n' +
1858 1872 ' action:*push*\n' +
1859 1873 ' ip:127.0.0.1\n' +
1860 1874 ' date:20120101\n' +
1861 1875 ' date:[20120101100000 TO 20120102]\n' +
1862 1876 '\n' +
1863 1877 'Actions: {actions}\n' +
1864 1878 '\n' +
1865 1879 'Generate wildcards using \'*\' character:\n' +
1866 1880 ' "repository:vcs*" - search everything starting with \'vcs\'\n' +
1867 1881 ' "repository:*vcs*" - search for repository containing \'vcs\'\n' +
1868 1882 '\n' +
1869 1883 'Optional AND / OR operators in queries\n' +
1870 1884 ' "repository:vcs OR repository:test"\n' +
1871 1885 ' "username:test AND repository:test*"\n'
1872 1886 ).format(actions=actions)
1873 1887
1874 1888
1875 1889 def not_mapped_error(repo_name):
1876 1890 from rhodecode.translation import _
1877 1891 flash(_('%s repository is not mapped to db perhaps'
1878 1892 ' it was created or renamed from the filesystem'
1879 1893 ' please run the application again'
1880 1894 ' in order to rescan repositories') % repo_name, category='error')
1881 1895
1882 1896
1883 1897 def ip_range(ip_addr):
1884 1898 from rhodecode.model.db import UserIpMap
1885 1899 s, e = UserIpMap._get_ip_range(ip_addr)
1886 1900 return '%s - %s' % (s, e)
1887 1901
1888 1902
1889 1903 def form(url, method='post', needs_csrf_token=True, **attrs):
1890 1904 """Wrapper around webhelpers.tags.form to prevent CSRF attacks."""
1891 1905 if method.lower() != 'get' and needs_csrf_token:
1892 1906 raise Exception(
1893 1907 'Forms to POST/PUT/DELETE endpoints should have (in general) a ' +
1894 1908 'CSRF token. If the endpoint does not require such token you can ' +
1895 1909 'explicitly set the parameter needs_csrf_token to false.')
1896 1910
1897 1911 return insecure_form(url, method=method, **attrs)
1898 1912
1899 1913
1900 1914 def secure_form(form_url, method="POST", multipart=False, **attrs):
1901 1915 """Start a form tag that points the action to an url. This
1902 1916 form tag will also include the hidden field containing
1903 1917 the auth token.
1904 1918
1905 1919 The url options should be given either as a string, or as a
1906 1920 ``url()`` function. The method for the form defaults to POST.
1907 1921
1908 1922 Options:
1909 1923
1910 1924 ``multipart``
1911 1925 If set to True, the enctype is set to "multipart/form-data".
1912 1926 ``method``
1913 1927 The method to use when submitting the form, usually either
1914 1928 "GET" or "POST". If "PUT", "DELETE", or another verb is used, a
1915 1929 hidden input with name _method is added to simulate the verb
1916 1930 over POST.
1917 1931
1918 1932 """
1919 1933
1920 1934 if 'request' in attrs:
1921 1935 session = attrs['request'].session
1922 1936 del attrs['request']
1923 1937 else:
1924 1938 raise ValueError(
1925 1939 'Calling this form requires request= to be passed as argument')
1926 1940
1927 1941 _form = insecure_form(form_url, method, multipart, **attrs)
1928 1942 token = literal(
1929 1943 '<input type="hidden" name="{}" value="{}">'.format(
1930 1944 csrf_token_key, get_csrf_token(session)))
1931 1945
1932 1946 return literal("%s\n%s" % (_form, token))
1933 1947
1934 1948
1935 1949 def dropdownmenu(name, selected, options, enable_filter=False, **attrs):
1936 1950 select_html = select(name, selected, options, **attrs)
1937 1951
1938 1952 select2 = """
1939 1953 <script>
1940 1954 $(document).ready(function() {
1941 1955 $('#%s').select2({
1942 1956 containerCssClass: 'drop-menu %s',
1943 1957 dropdownCssClass: 'drop-menu-dropdown',
1944 1958 dropdownAutoWidth: true%s
1945 1959 });
1946 1960 });
1947 1961 </script>
1948 1962 """
1949 1963
1950 1964 filter_option = """,
1951 1965 minimumResultsForSearch: -1
1952 1966 """
1953 1967 input_id = attrs.get('id') or name
1954 1968 extra_classes = ' '.join(attrs.pop('extra_classes', []))
1955 1969 filter_enabled = "" if enable_filter else filter_option
1956 1970 select_script = literal(select2 % (input_id, extra_classes, filter_enabled))
1957 1971
1958 1972 return literal(select_html+select_script)
1959 1973
1960 1974
1961 1975 def get_visual_attr(tmpl_context_var, attr_name):
1962 1976 """
1963 1977 A safe way to get a variable from visual variable of template context
1964 1978
1965 1979 :param tmpl_context_var: instance of tmpl_context, usually present as `c`
1966 1980 :param attr_name: name of the attribute we fetch from the c.visual
1967 1981 """
1968 1982 visual = getattr(tmpl_context_var, 'visual', None)
1969 1983 if not visual:
1970 1984 return
1971 1985 else:
1972 1986 return getattr(visual, attr_name, None)
1973 1987
1974 1988
1975 1989 def get_last_path_part(file_node):
1976 1990 if not file_node.path:
1977 1991 return u'/'
1978 1992
1979 1993 path = safe_unicode(file_node.path.split('/')[-1])
1980 1994 return u'../' + path
1981 1995
1982 1996
1983 1997 def route_url(*args, **kwargs):
1984 1998 """
1985 1999 Wrapper around pyramids `route_url` (fully qualified url) function.
1986 2000 """
1987 2001 req = get_current_request()
1988 2002 return req.route_url(*args, **kwargs)
1989 2003
1990 2004
1991 2005 def route_path(*args, **kwargs):
1992 2006 """
1993 2007 Wrapper around pyramids `route_path` function.
1994 2008 """
1995 2009 req = get_current_request()
1996 2010 return req.route_path(*args, **kwargs)
1997 2011
1998 2012
1999 2013 def route_path_or_none(*args, **kwargs):
2000 2014 try:
2001 2015 return route_path(*args, **kwargs)
2002 2016 except KeyError:
2003 2017 return None
2004 2018
2005 2019
2006 2020 def current_route_path(request, **kw):
2007 2021 new_args = request.GET.mixed()
2008 2022 new_args.update(kw)
2009 2023 return request.current_route_path(_query=new_args)
2010 2024
2011 2025
2012 2026 def curl_api_example(method, args):
2013 2027 args_json = json.dumps(OrderedDict([
2014 2028 ('id', 1),
2015 2029 ('auth_token', 'SECRET'),
2016 2030 ('method', method),
2017 2031 ('args', args)
2018 2032 ]))
2019 2033
2020 2034 return "curl {api_url} -X POST -H 'content-type:text/plain' --data-binary '{args_json}'".format(
2021 2035 api_url=route_url('apiv2'),
2022 2036 args_json=args_json
2023 2037 )
2024 2038
2025 2039
2026 2040 def api_call_example(method, args):
2027 2041 """
2028 2042 Generates an API call example via CURL
2029 2043 """
2030 2044 curl_call = curl_api_example(method, args)
2031 2045
2032 2046 return literal(
2033 2047 curl_call +
2034 2048 "<br/><br/>SECRET can be found in <a href=\"{token_url}\">auth-tokens</a> page, "
2035 2049 "and needs to be of `api calls` role."
2036 2050 .format(token_url=route_url('my_account_auth_tokens')))
2037 2051
2038 2052
2039 2053 def notification_description(notification, request):
2040 2054 """
2041 2055 Generate notification human readable description based on notification type
2042 2056 """
2043 2057 from rhodecode.model.notification import NotificationModel
2044 2058 return NotificationModel().make_description(
2045 2059 notification, translate=request.translate)
2046 2060
2047 2061
2048 2062 def go_import_header(request, db_repo=None):
2049 2063 """
2050 2064 Creates a header for go-import functionality in Go Lang
2051 2065 """
2052 2066
2053 2067 if not db_repo:
2054 2068 return
2055 2069 if 'go-get' not in request.GET:
2056 2070 return
2057 2071
2058 2072 clone_url = db_repo.clone_url()
2059 2073 prefix = re.split(r'^https?:\/\/', clone_url)[-1]
2060 2074 # we have a repo and go-get flag,
2061 2075 return literal('<meta name="go-import" content="{} {} {}">'.format(
2062 2076 prefix, db_repo.repo_type, clone_url))
2063 2077
2064 2078
2065 2079 def reviewer_as_json(*args, **kwargs):
2066 2080 from rhodecode.apps.repository.utils import reviewer_as_json as _reviewer_as_json
2067 2081 return _reviewer_as_json(*args, **kwargs)
2068 2082
2069 2083
2070 2084 def get_repo_view_type(request):
2071 2085 route_name = request.matched_route.name
2072 2086 route_to_view_type = {
2073 2087 'repo_changelog': 'commits',
2074 2088 'repo_commits': 'commits',
2075 2089 'repo_files': 'files',
2076 2090 'repo_summary': 'summary',
2077 2091 'repo_commit': 'commit'
2078 2092 }
2079 2093
2080 2094 return route_to_view_type.get(route_name)
2081 2095
2082 2096
2083 2097 def is_active(menu_entry, selected):
2084 2098 """
2085 2099 Returns active class for selecting menus in templates
2086 2100 <li class=${h.is_active('settings', current_active)}></li>
2087 2101 """
2088 2102 if not isinstance(menu_entry, list):
2089 2103 menu_entry = [menu_entry]
2090 2104
2091 2105 if selected in menu_entry:
2092 2106 return "active"
@@ -1,840 +1,855 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 import datetime
25 25
26 26 import logging
27 27 import traceback
28 28 import collections
29 29
30 30 from pyramid.threadlocal import get_current_registry, get_current_request
31 31 from sqlalchemy.sql.expression import null
32 32 from sqlalchemy.sql.functions import coalesce
33 33
34 34 from rhodecode.lib import helpers as h, diffs, channelstream, hooks_utils
35 35 from rhodecode.lib import audit_logger
36 36 from rhodecode.lib.exceptions import CommentVersionMismatch
37 37 from rhodecode.lib.utils2 import extract_mentioned_users, safe_str, safe_int
38 38 from rhodecode.model import BaseModel
39 39 from rhodecode.model.db import (
40 40 ChangesetComment,
41 41 User,
42 42 Notification,
43 43 PullRequest,
44 44 AttributeDict,
45 45 ChangesetCommentHistory,
46 46 )
47 47 from rhodecode.model.notification import NotificationModel
48 48 from rhodecode.model.meta import Session
49 49 from rhodecode.model.settings import VcsSettingsModel
50 50 from rhodecode.model.notification import EmailNotificationModel
51 51 from rhodecode.model.validation_schema.schemas import comment_schema
52 52
53 53
54 54 log = logging.getLogger(__name__)
55 55
56 56
57 57 class CommentsModel(BaseModel):
58 58
59 59 cls = ChangesetComment
60 60
61 61 DIFF_CONTEXT_BEFORE = 3
62 62 DIFF_CONTEXT_AFTER = 3
63 63
64 64 def __get_commit_comment(self, changeset_comment):
65 65 return self._get_instance(ChangesetComment, changeset_comment)
66 66
67 67 def __get_pull_request(self, pull_request):
68 68 return self._get_instance(PullRequest, pull_request)
69 69
70 70 def _extract_mentions(self, s):
71 71 user_objects = []
72 72 for username in extract_mentioned_users(s):
73 73 user_obj = User.get_by_username(username, case_insensitive=True)
74 74 if user_obj:
75 75 user_objects.append(user_obj)
76 76 return user_objects
77 77
78 78 def _get_renderer(self, global_renderer='rst', request=None):
79 79 request = request or get_current_request()
80 80
81 81 try:
82 82 global_renderer = request.call_context.visual.default_renderer
83 83 except AttributeError:
84 84 log.debug("Renderer not set, falling back "
85 85 "to default renderer '%s'", global_renderer)
86 86 except Exception:
87 87 log.error(traceback.format_exc())
88 88 return global_renderer
89 89
90 90 def aggregate_comments(self, comments, versions, show_version, inline=False):
91 91 # group by versions, and count until, and display objects
92 92
93 93 comment_groups = collections.defaultdict(list)
94 [comment_groups[
95 _co.pull_request_version_id].append(_co) for _co in comments]
94 [comment_groups[_co.pull_request_version_id].append(_co) for _co in comments]
96 95
97 96 def yield_comments(pos):
98 97 for co in comment_groups[pos]:
99 98 yield co
100 99
101 100 comment_versions = collections.defaultdict(
102 101 lambda: collections.defaultdict(list))
103 102 prev_prvid = -1
104 103 # fake last entry with None, to aggregate on "latest" version which
105 104 # doesn't have an pull_request_version_id
106 105 for ver in versions + [AttributeDict({'pull_request_version_id': None})]:
107 106 prvid = ver.pull_request_version_id
108 107 if prev_prvid == -1:
109 108 prev_prvid = prvid
110 109
111 110 for co in yield_comments(prvid):
112 111 comment_versions[prvid]['at'].append(co)
113 112
114 113 # save until
115 114 current = comment_versions[prvid]['at']
116 115 prev_until = comment_versions[prev_prvid]['until']
117 116 cur_until = prev_until + current
118 117 comment_versions[prvid]['until'].extend(cur_until)
119 118
120 119 # save outdated
121 120 if inline:
122 121 outdated = [x for x in cur_until
123 122 if x.outdated_at_version(show_version)]
124 123 else:
125 124 outdated = [x for x in cur_until
126 125 if x.older_than_version(show_version)]
127 126 display = [x for x in cur_until if x not in outdated]
128 127
129 128 comment_versions[prvid]['outdated'] = outdated
130 129 comment_versions[prvid]['display'] = display
131 130
132 131 prev_prvid = prvid
133 132
134 133 return comment_versions
135 134
136 135 def get_repository_comments(self, repo, comment_type=None, user=None, commit_id=None):
137 136 qry = Session().query(ChangesetComment) \
138 137 .filter(ChangesetComment.repo == repo)
139 138
140 139 if comment_type and comment_type in ChangesetComment.COMMENT_TYPES:
141 140 qry = qry.filter(ChangesetComment.comment_type == comment_type)
142 141
143 142 if user:
144 143 user = self._get_user(user)
145 144 if user:
146 145 qry = qry.filter(ChangesetComment.user_id == user.user_id)
147 146
148 147 if commit_id:
149 148 qry = qry.filter(ChangesetComment.revision == commit_id)
150 149
151 150 qry = qry.order_by(ChangesetComment.created_on)
152 151 return qry.all()
153 152
154 153 def get_repository_unresolved_todos(self, repo):
155 154 todos = Session().query(ChangesetComment) \
156 155 .filter(ChangesetComment.repo == repo) \
157 156 .filter(ChangesetComment.resolved_by == None) \
158 157 .filter(ChangesetComment.comment_type
159 158 == ChangesetComment.COMMENT_TYPE_TODO)
160 159 todos = todos.all()
161 160
162 161 return todos
163 162
164 163 def get_pull_request_unresolved_todos(self, pull_request, show_outdated=True):
165 164
166 165 todos = Session().query(ChangesetComment) \
167 166 .filter(ChangesetComment.pull_request == pull_request) \
168 167 .filter(ChangesetComment.resolved_by == None) \
169 168 .filter(ChangesetComment.comment_type
170 169 == ChangesetComment.COMMENT_TYPE_TODO)
171 170
172 171 if not show_outdated:
173 172 todos = todos.filter(
174 173 coalesce(ChangesetComment.display_state, '') !=
175 174 ChangesetComment.COMMENT_OUTDATED)
176 175
177 176 todos = todos.all()
178 177
179 178 return todos
180 179
181 180 def get_pull_request_resolved_todos(self, pull_request, show_outdated=True):
182 181
183 182 todos = Session().query(ChangesetComment) \
184 183 .filter(ChangesetComment.pull_request == pull_request) \
185 184 .filter(ChangesetComment.resolved_by != None) \
186 185 .filter(ChangesetComment.comment_type
187 186 == ChangesetComment.COMMENT_TYPE_TODO)
188 187
189 188 if not show_outdated:
190 189 todos = todos.filter(
191 190 coalesce(ChangesetComment.display_state, '') !=
192 191 ChangesetComment.COMMENT_OUTDATED)
193 192
194 193 todos = todos.all()
195 194
196 195 return todos
197 196
198 197 def get_commit_unresolved_todos(self, commit_id, show_outdated=True):
199 198
200 199 todos = Session().query(ChangesetComment) \
201 200 .filter(ChangesetComment.revision == commit_id) \
202 201 .filter(ChangesetComment.resolved_by == None) \
203 202 .filter(ChangesetComment.comment_type
204 203 == ChangesetComment.COMMENT_TYPE_TODO)
205 204
206 205 if not show_outdated:
207 206 todos = todos.filter(
208 207 coalesce(ChangesetComment.display_state, '') !=
209 208 ChangesetComment.COMMENT_OUTDATED)
210 209
211 210 todos = todos.all()
212 211
213 212 return todos
214 213
215 214 def get_commit_resolved_todos(self, commit_id, show_outdated=True):
216 215
217 216 todos = Session().query(ChangesetComment) \
218 217 .filter(ChangesetComment.revision == commit_id) \
219 218 .filter(ChangesetComment.resolved_by != None) \
220 219 .filter(ChangesetComment.comment_type
221 220 == ChangesetComment.COMMENT_TYPE_TODO)
222 221
223 222 if not show_outdated:
224 223 todos = todos.filter(
225 224 coalesce(ChangesetComment.display_state, '') !=
226 225 ChangesetComment.COMMENT_OUTDATED)
227 226
228 227 todos = todos.all()
229 228
230 229 return todos
231 230
232 231 def _log_audit_action(self, action, action_data, auth_user, comment):
233 232 audit_logger.store(
234 233 action=action,
235 234 action_data=action_data,
236 235 user=auth_user,
237 236 repo=comment.repo)
238 237
239 238 def create(self, text, repo, user, commit_id=None, pull_request=None,
240 239 f_path=None, line_no=None, status_change=None,
241 240 status_change_type=None, comment_type=None,
242 241 resolves_comment_id=None, closing_pr=False, send_email=True,
243 242 renderer=None, auth_user=None, extra_recipients=None):
244 243 """
245 244 Creates new comment for commit or pull request.
246 245 IF status_change is not none this comment is associated with a
247 246 status change of commit or commit associated with pull request
248 247
249 248 :param text:
250 249 :param repo:
251 250 :param user:
252 251 :param commit_id:
253 252 :param pull_request:
254 253 :param f_path:
255 254 :param line_no:
256 255 :param status_change: Label for status change
257 256 :param comment_type: Type of comment
258 257 :param resolves_comment_id: id of comment which this one will resolve
259 258 :param status_change_type: type of status change
260 259 :param closing_pr:
261 260 :param send_email:
262 261 :param renderer: pick renderer for this comment
263 262 :param auth_user: current authenticated user calling this method
264 263 :param extra_recipients: list of extra users to be added to recipients
265 264 """
266 265
267 266 if not text:
268 267 log.warning('Missing text for comment, skipping...')
269 268 return
270 269 request = get_current_request()
271 270 _ = request.translate
272 271
273 272 if not renderer:
274 273 renderer = self._get_renderer(request=request)
275 274
276 275 repo = self._get_repo(repo)
277 276 user = self._get_user(user)
278 277 auth_user = auth_user or user
279 278
280 279 schema = comment_schema.CommentSchema()
281 280 validated_kwargs = schema.deserialize(dict(
282 281 comment_body=text,
283 282 comment_type=comment_type,
284 283 comment_file=f_path,
285 284 comment_line=line_no,
286 285 renderer_type=renderer,
287 286 status_change=status_change_type,
288 287 resolves_comment_id=resolves_comment_id,
289 288 repo=repo.repo_id,
290 289 user=user.user_id,
291 290 ))
292 291
293 292 comment = ChangesetComment()
294 293 comment.renderer = validated_kwargs['renderer_type']
295 294 comment.text = validated_kwargs['comment_body']
296 295 comment.f_path = validated_kwargs['comment_file']
297 296 comment.line_no = validated_kwargs['comment_line']
298 297 comment.comment_type = validated_kwargs['comment_type']
299 298
300 299 comment.repo = repo
301 300 comment.author = user
302 301 resolved_comment = self.__get_commit_comment(
303 302 validated_kwargs['resolves_comment_id'])
304 303 # check if the comment actually belongs to this PR
305 304 if resolved_comment and resolved_comment.pull_request and \
306 305 resolved_comment.pull_request != pull_request:
307 306 log.warning('Comment tried to resolved unrelated todo comment: %s',
308 307 resolved_comment)
309 308 # comment not bound to this pull request, forbid
310 309 resolved_comment = None
311 310
312 311 elif resolved_comment and resolved_comment.repo and \
313 312 resolved_comment.repo != repo:
314 313 log.warning('Comment tried to resolved unrelated todo comment: %s',
315 314 resolved_comment)
316 315 # comment not bound to this repo, forbid
317 316 resolved_comment = None
318 317
319 318 comment.resolved_comment = resolved_comment
320 319
321 320 pull_request_id = pull_request
322 321
323 322 commit_obj = None
324 323 pull_request_obj = None
325 324
326 325 if commit_id:
327 326 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
328 327 # do a lookup, so we don't pass something bad here
329 328 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
330 329 comment.revision = commit_obj.raw_id
331 330
332 331 elif pull_request_id:
333 332 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
334 333 pull_request_obj = self.__get_pull_request(pull_request_id)
335 334 comment.pull_request = pull_request_obj
336 335 else:
337 336 raise Exception('Please specify commit or pull_request_id')
338 337
339 338 Session().add(comment)
340 339 Session().flush()
341 340 kwargs = {
342 341 'user': user,
343 342 'renderer_type': renderer,
344 343 'repo_name': repo.repo_name,
345 344 'status_change': status_change,
346 345 'status_change_type': status_change_type,
347 346 'comment_body': text,
348 347 'comment_file': f_path,
349 348 'comment_line': line_no,
350 349 'comment_type': comment_type or 'note',
351 350 'comment_id': comment.comment_id
352 351 }
353 352
354 353 if commit_obj:
355 354 recipients = ChangesetComment.get_users(
356 355 revision=commit_obj.raw_id)
357 356 # add commit author if it's in RhodeCode system
358 357 cs_author = User.get_from_cs_author(commit_obj.author)
359 358 if not cs_author:
360 359 # use repo owner if we cannot extract the author correctly
361 360 cs_author = repo.user
362 361 recipients += [cs_author]
363 362
364 363 commit_comment_url = self.get_url(comment, request=request)
365 364 commit_comment_reply_url = self.get_url(
366 365 comment, request=request,
367 366 anchor='comment-{}/?/ReplyToComment'.format(comment.comment_id))
368 367
369 368 target_repo_url = h.link_to(
370 369 repo.repo_name,
371 370 h.route_url('repo_summary', repo_name=repo.repo_name))
372 371
373 372 commit_url = h.route_url('repo_commit', repo_name=repo.repo_name,
374 373 commit_id=commit_id)
375 374
376 375 # commit specifics
377 376 kwargs.update({
378 377 'commit': commit_obj,
379 378 'commit_message': commit_obj.message,
380 379 'commit_target_repo_url': target_repo_url,
381 380 'commit_comment_url': commit_comment_url,
382 381 'commit_comment_reply_url': commit_comment_reply_url,
383 382 'commit_url': commit_url,
384 383 'thread_ids': [commit_url, commit_comment_url],
385 384 })
386 385
387 386 elif pull_request_obj:
388 387 # get the current participants of this pull request
389 388 recipients = ChangesetComment.get_users(
390 389 pull_request_id=pull_request_obj.pull_request_id)
391 390 # add pull request author
392 391 recipients += [pull_request_obj.author]
393 392
394 393 # add the reviewers to notification
395 394 recipients += [x.user for x in pull_request_obj.reviewers]
396 395
397 396 pr_target_repo = pull_request_obj.target_repo
398 397 pr_source_repo = pull_request_obj.source_repo
399 398
400 399 pr_comment_url = self.get_url(comment, request=request)
401 400 pr_comment_reply_url = self.get_url(
402 401 comment, request=request,
403 402 anchor='comment-{}/?/ReplyToComment'.format(comment.comment_id))
404 403
405 404 pr_url = h.route_url(
406 405 'pullrequest_show',
407 406 repo_name=pr_target_repo.repo_name,
408 407 pull_request_id=pull_request_obj.pull_request_id, )
409 408
410 409 # set some variables for email notification
411 410 pr_target_repo_url = h.route_url(
412 411 'repo_summary', repo_name=pr_target_repo.repo_name)
413 412
414 413 pr_source_repo_url = h.route_url(
415 414 'repo_summary', repo_name=pr_source_repo.repo_name)
416 415
417 416 # pull request specifics
418 417 kwargs.update({
419 418 'pull_request': pull_request_obj,
420 419 'pr_id': pull_request_obj.pull_request_id,
421 420 'pull_request_url': pr_url,
422 421 'pull_request_target_repo': pr_target_repo,
423 422 'pull_request_target_repo_url': pr_target_repo_url,
424 423 'pull_request_source_repo': pr_source_repo,
425 424 'pull_request_source_repo_url': pr_source_repo_url,
426 425 'pr_comment_url': pr_comment_url,
427 426 'pr_comment_reply_url': pr_comment_reply_url,
428 427 'pr_closing': closing_pr,
429 428 'thread_ids': [pr_url, pr_comment_url],
430 429 })
431 430
432 431 recipients += [self._get_user(u) for u in (extra_recipients or [])]
433 432
434 433 if send_email:
435 434 # pre-generate the subject for notification itself
436 435 (subject, _e, body_plaintext) = EmailNotificationModel().render_email(
437 436 notification_type, **kwargs)
438 437
439 438 mention_recipients = set(
440 439 self._extract_mentions(text)).difference(recipients)
441 440
442 441 # create notification objects, and emails
443 442 NotificationModel().create(
444 443 created_by=user,
445 444 notification_subject=subject,
446 445 notification_body=body_plaintext,
447 446 notification_type=notification_type,
448 447 recipients=recipients,
449 448 mention_recipients=mention_recipients,
450 449 email_kwargs=kwargs,
451 450 )
452 451
453 452 Session().flush()
454 453 if comment.pull_request:
455 454 action = 'repo.pull_request.comment.create'
456 455 else:
457 456 action = 'repo.commit.comment.create'
458 457
458 comment_id = comment.comment_id
459 459 comment_data = comment.get_api_data()
460
460 461 self._log_audit_action(
461 462 action, {'data': comment_data}, auth_user, comment)
462 463
463 msg_url = ''
464 464 channel = None
465 465 if commit_obj:
466 msg_url = commit_comment_url
467 466 repo_name = repo.repo_name
468 467 channel = u'/repo${}$/commit/{}'.format(
469 468 repo_name,
470 469 commit_obj.raw_id
471 470 )
472 471 elif pull_request_obj:
473 msg_url = pr_comment_url
474 472 repo_name = pr_target_repo.repo_name
475 473 channel = u'/repo${}$/pr/{}'.format(
476 474 repo_name,
477 pull_request_id
475 pull_request_obj.pull_request_id
478 476 )
479 477
480 message = '<strong>{}</strong> {} - ' \
481 '<a onclick="window.location=\'{}\';' \
482 'window.location.reload()">' \
483 '<strong>{}</strong></a>'
484 message = message.format(
485 user.username, _('made a comment'), msg_url,
486 _('Show it now'))
478 if channel:
479 username = user.username
480 message = '<strong>{}</strong> {} #{}, {}'
481 message = message.format(
482 username,
483 _('posted a new comment'),
484 comment_id,
485 _('Refresh the page to see new comments.'))
487 486
488 channelstream.post_message(
489 channel, message, user.username,
490 registry=get_current_registry())
487 message_obj = {
488 'message': message,
489 'level': 'success',
490 'topic': '/notifications'
491 }
492
493 channelstream.post_message(
494 channel, message_obj, user.username,
495 registry=get_current_registry())
496
497 message_obj = {
498 'message': None,
499 'user': username,
500 'comment_id': comment_id,
501 'topic': '/comment'
502 }
503 channelstream.post_message(
504 channel, message_obj, user.username,
505 registry=get_current_registry())
491 506
492 507 return comment
493 508
494 509 def edit(self, comment_id, text, auth_user, version):
495 510 """
496 511 Change existing comment for commit or pull request.
497 512
498 513 :param comment_id:
499 514 :param text:
500 515 :param auth_user: current authenticated user calling this method
501 516 :param version: last comment version
502 517 """
503 518 if not text:
504 519 log.warning('Missing text for comment, skipping...')
505 520 return
506 521
507 522 comment = ChangesetComment.get(comment_id)
508 523 old_comment_text = comment.text
509 524 comment.text = text
510 525 comment.modified_at = datetime.datetime.now()
511 526 version = safe_int(version)
512 527
513 528 # NOTE(marcink): this returns initial comment + edits, so v2 from ui
514 529 # would return 3 here
515 530 comment_version = ChangesetCommentHistory.get_version(comment_id)
516 531
517 532 if isinstance(version, (int, long)) and (comment_version - version) != 1:
518 533 log.warning(
519 534 'Version mismatch comment_version {} submitted {}, skipping'.format(
520 535 comment_version-1, # -1 since note above
521 536 version
522 537 )
523 538 )
524 539 raise CommentVersionMismatch()
525 540
526 541 comment_history = ChangesetCommentHistory()
527 542 comment_history.comment_id = comment_id
528 543 comment_history.version = comment_version
529 544 comment_history.created_by_user_id = auth_user.user_id
530 545 comment_history.text = old_comment_text
531 546 # TODO add email notification
532 547 Session().add(comment_history)
533 548 Session().add(comment)
534 549 Session().flush()
535 550
536 551 if comment.pull_request:
537 552 action = 'repo.pull_request.comment.edit'
538 553 else:
539 554 action = 'repo.commit.comment.edit'
540 555
541 556 comment_data = comment.get_api_data()
542 557 comment_data['old_comment_text'] = old_comment_text
543 558 self._log_audit_action(
544 559 action, {'data': comment_data}, auth_user, comment)
545 560
546 561 return comment_history
547 562
548 563 def delete(self, comment, auth_user):
549 564 """
550 565 Deletes given comment
551 566 """
552 567 comment = self.__get_commit_comment(comment)
553 568 old_data = comment.get_api_data()
554 569 Session().delete(comment)
555 570
556 571 if comment.pull_request:
557 572 action = 'repo.pull_request.comment.delete'
558 573 else:
559 574 action = 'repo.commit.comment.delete'
560 575
561 576 self._log_audit_action(
562 577 action, {'old_data': old_data}, auth_user, comment)
563 578
564 579 return comment
565 580
566 581 def get_all_comments(self, repo_id, revision=None, pull_request=None):
567 582 q = ChangesetComment.query()\
568 583 .filter(ChangesetComment.repo_id == repo_id)
569 584 if revision:
570 585 q = q.filter(ChangesetComment.revision == revision)
571 586 elif pull_request:
572 587 pull_request = self.__get_pull_request(pull_request)
573 588 q = q.filter(ChangesetComment.pull_request == pull_request)
574 589 else:
575 590 raise Exception('Please specify commit or pull_request')
576 591 q = q.order_by(ChangesetComment.created_on)
577 592 return q.all()
578 593
579 594 def get_url(self, comment, request=None, permalink=False, anchor=None):
580 595 if not request:
581 596 request = get_current_request()
582 597
583 598 comment = self.__get_commit_comment(comment)
584 599 if anchor is None:
585 600 anchor = 'comment-{}'.format(comment.comment_id)
586 601
587 602 if comment.pull_request:
588 603 pull_request = comment.pull_request
589 604 if permalink:
590 605 return request.route_url(
591 606 'pull_requests_global',
592 607 pull_request_id=pull_request.pull_request_id,
593 608 _anchor=anchor)
594 609 else:
595 610 return request.route_url(
596 611 'pullrequest_show',
597 612 repo_name=safe_str(pull_request.target_repo.repo_name),
598 613 pull_request_id=pull_request.pull_request_id,
599 614 _anchor=anchor)
600 615
601 616 else:
602 617 repo = comment.repo
603 618 commit_id = comment.revision
604 619
605 620 if permalink:
606 621 return request.route_url(
607 622 'repo_commit', repo_name=safe_str(repo.repo_id),
608 623 commit_id=commit_id,
609 624 _anchor=anchor)
610 625
611 626 else:
612 627 return request.route_url(
613 628 'repo_commit', repo_name=safe_str(repo.repo_name),
614 629 commit_id=commit_id,
615 630 _anchor=anchor)
616 631
617 632 def get_comments(self, repo_id, revision=None, pull_request=None):
618 633 """
619 634 Gets main comments based on revision or pull_request_id
620 635
621 636 :param repo_id:
622 637 :param revision:
623 638 :param pull_request:
624 639 """
625 640
626 641 q = ChangesetComment.query()\
627 642 .filter(ChangesetComment.repo_id == repo_id)\
628 643 .filter(ChangesetComment.line_no == None)\
629 644 .filter(ChangesetComment.f_path == None)
630 645 if revision:
631 646 q = q.filter(ChangesetComment.revision == revision)
632 647 elif pull_request:
633 648 pull_request = self.__get_pull_request(pull_request)
634 649 q = q.filter(ChangesetComment.pull_request == pull_request)
635 650 else:
636 651 raise Exception('Please specify commit or pull_request')
637 652 q = q.order_by(ChangesetComment.created_on)
638 653 return q.all()
639 654
640 655 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
641 656 q = self._get_inline_comments_query(repo_id, revision, pull_request)
642 657 return self._group_comments_by_path_and_line_number(q)
643 658
644 659 def get_inline_comments_as_list(self, inline_comments, skip_outdated=True,
645 version=None):
646 inline_cnt = 0
660 version=None):
661 inline_comms = []
647 662 for fname, per_line_comments in inline_comments.iteritems():
648 663 for lno, comments in per_line_comments.iteritems():
649 664 for comm in comments:
650 665 if not comm.outdated_at_version(version) and skip_outdated:
651 inline_cnt += 1
666 inline_comms.append(comm)
652 667
653 return inline_cnt
668 return inline_comms
654 669
655 670 def get_outdated_comments(self, repo_id, pull_request):
656 671 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
657 672 # of a pull request.
658 673 q = self._all_inline_comments_of_pull_request(pull_request)
659 674 q = q.filter(
660 675 ChangesetComment.display_state ==
661 676 ChangesetComment.COMMENT_OUTDATED
662 677 ).order_by(ChangesetComment.comment_id.asc())
663 678
664 679 return self._group_comments_by_path_and_line_number(q)
665 680
666 681 def _get_inline_comments_query(self, repo_id, revision, pull_request):
667 682 # TODO: johbo: Split this into two methods: One for PR and one for
668 683 # commit.
669 684 if revision:
670 685 q = Session().query(ChangesetComment).filter(
671 686 ChangesetComment.repo_id == repo_id,
672 687 ChangesetComment.line_no != null(),
673 688 ChangesetComment.f_path != null(),
674 689 ChangesetComment.revision == revision)
675 690
676 691 elif pull_request:
677 692 pull_request = self.__get_pull_request(pull_request)
678 693 if not CommentsModel.use_outdated_comments(pull_request):
679 694 q = self._visible_inline_comments_of_pull_request(pull_request)
680 695 else:
681 696 q = self._all_inline_comments_of_pull_request(pull_request)
682 697
683 698 else:
684 699 raise Exception('Please specify commit or pull_request_id')
685 700 q = q.order_by(ChangesetComment.comment_id.asc())
686 701 return q
687 702
688 703 def _group_comments_by_path_and_line_number(self, q):
689 704 comments = q.all()
690 705 paths = collections.defaultdict(lambda: collections.defaultdict(list))
691 706 for co in comments:
692 707 paths[co.f_path][co.line_no].append(co)
693 708 return paths
694 709
695 710 @classmethod
696 711 def needed_extra_diff_context(cls):
697 712 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
698 713
699 714 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
700 715 if not CommentsModel.use_outdated_comments(pull_request):
701 716 return
702 717
703 718 comments = self._visible_inline_comments_of_pull_request(pull_request)
704 719 comments_to_outdate = comments.all()
705 720
706 721 for comment in comments_to_outdate:
707 722 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
708 723
709 724 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
710 725 diff_line = _parse_comment_line_number(comment.line_no)
711 726
712 727 try:
713 728 old_context = old_diff_proc.get_context_of_line(
714 729 path=comment.f_path, diff_line=diff_line)
715 730 new_context = new_diff_proc.get_context_of_line(
716 731 path=comment.f_path, diff_line=diff_line)
717 732 except (diffs.LineNotInDiffException,
718 733 diffs.FileNotInDiffException):
719 734 comment.display_state = ChangesetComment.COMMENT_OUTDATED
720 735 return
721 736
722 737 if old_context == new_context:
723 738 return
724 739
725 740 if self._should_relocate_diff_line(diff_line):
726 741 new_diff_lines = new_diff_proc.find_context(
727 742 path=comment.f_path, context=old_context,
728 743 offset=self.DIFF_CONTEXT_BEFORE)
729 744 if not new_diff_lines:
730 745 comment.display_state = ChangesetComment.COMMENT_OUTDATED
731 746 else:
732 747 new_diff_line = self._choose_closest_diff_line(
733 748 diff_line, new_diff_lines)
734 749 comment.line_no = _diff_to_comment_line_number(new_diff_line)
735 750 else:
736 751 comment.display_state = ChangesetComment.COMMENT_OUTDATED
737 752
738 753 def _should_relocate_diff_line(self, diff_line):
739 754 """
740 755 Checks if relocation shall be tried for the given `diff_line`.
741 756
742 757 If a comment points into the first lines, then we can have a situation
743 758 that after an update another line has been added on top. In this case
744 759 we would find the context still and move the comment around. This
745 760 would be wrong.
746 761 """
747 762 should_relocate = (
748 763 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
749 764 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
750 765 return should_relocate
751 766
752 767 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
753 768 candidate = new_diff_lines[0]
754 769 best_delta = _diff_line_delta(diff_line, candidate)
755 770 for new_diff_line in new_diff_lines[1:]:
756 771 delta = _diff_line_delta(diff_line, new_diff_line)
757 772 if delta < best_delta:
758 773 candidate = new_diff_line
759 774 best_delta = delta
760 775 return candidate
761 776
762 777 def _visible_inline_comments_of_pull_request(self, pull_request):
763 778 comments = self._all_inline_comments_of_pull_request(pull_request)
764 779 comments = comments.filter(
765 780 coalesce(ChangesetComment.display_state, '') !=
766 781 ChangesetComment.COMMENT_OUTDATED)
767 782 return comments
768 783
769 784 def _all_inline_comments_of_pull_request(self, pull_request):
770 785 comments = Session().query(ChangesetComment)\
771 786 .filter(ChangesetComment.line_no != None)\
772 787 .filter(ChangesetComment.f_path != None)\
773 788 .filter(ChangesetComment.pull_request == pull_request)
774 789 return comments
775 790
776 791 def _all_general_comments_of_pull_request(self, pull_request):
777 792 comments = Session().query(ChangesetComment)\
778 793 .filter(ChangesetComment.line_no == None)\
779 794 .filter(ChangesetComment.f_path == None)\
780 795 .filter(ChangesetComment.pull_request == pull_request)
781 796
782 797 return comments
783 798
784 799 @staticmethod
785 800 def use_outdated_comments(pull_request):
786 801 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
787 802 settings = settings_model.get_general_settings()
788 803 return settings.get('rhodecode_use_outdated_comments', False)
789 804
790 805 def trigger_commit_comment_hook(self, repo, user, action, data=None):
791 806 repo = self._get_repo(repo)
792 807 target_scm = repo.scm_instance()
793 808 if action == 'create':
794 809 trigger_hook = hooks_utils.trigger_comment_commit_hooks
795 810 elif action == 'edit':
796 811 trigger_hook = hooks_utils.trigger_comment_commit_edit_hooks
797 812 else:
798 813 return
799 814
800 815 log.debug('Handling repo %s trigger_commit_comment_hook with action %s: %s',
801 816 repo, action, trigger_hook)
802 817 trigger_hook(
803 818 username=user.username,
804 819 repo_name=repo.repo_name,
805 820 repo_type=target_scm.alias,
806 821 repo=repo,
807 822 data=data)
808 823
809 824
810 825 def _parse_comment_line_number(line_no):
811 826 """
812 827 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
813 828 """
814 829 old_line = None
815 830 new_line = None
816 831 if line_no.startswith('o'):
817 832 old_line = int(line_no[1:])
818 833 elif line_no.startswith('n'):
819 834 new_line = int(line_no[1:])
820 835 else:
821 836 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
822 837 return diffs.DiffLineNumber(old_line, new_line)
823 838
824 839
825 840 def _diff_to_comment_line_number(diff_line):
826 841 if diff_line.new is not None:
827 842 return u'n{}'.format(diff_line.new)
828 843 elif diff_line.old is not None:
829 844 return u'o{}'.format(diff_line.old)
830 845 return u''
831 846
832 847
833 848 def _diff_line_delta(a, b):
834 849 if None not in (a.new, b.new):
835 850 return abs(a.new - b.new)
836 851 elif None not in (a.old, b.old):
837 852 return abs(a.old - b.old)
838 853 else:
839 854 raise ValueError(
840 855 "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,400 +1,400 b''
1 1
2 2 /** MODAL **/
3 3 .modal-open {
4 4 overflow:hidden;
5 5 }
6 6 body.modal-open, .modal-open .navbar-fixed-top, .modal-open .navbar-fixed-bottom {
7 7 margin-right:15px;
8 8 }
9 9 .modal {
10 10 position:fixed;
11 11 top:0;
12 12 right:0;
13 13 bottom:0;
14 14 left:0;
15 15 z-index:1040;
16 16 display:none;
17 17 overflow-y:scroll;
18 18 &.fade .modal-dialog {
19 19 -webkit-transform:translate(0,-25%);
20 20 -ms-transform:translate(0,-25%);
21 21 transform:translate(0,-25%);
22 22 -webkit-transition:-webkit-transform 0.3s ease-out;
23 23 -moz-transition:-moz-transform 0.3s ease-out;
24 24 -o-transition:-o-transform 0.3s ease-out;
25 25 transition:transform 0.3s ease-out;
26 26 }
27 27 &.in .modal-dialog {
28 28 -webkit-transform:translate(0,0);
29 29 -ms-transform:translate(0,0);
30 30 transform:translate(0,0);
31 31 }
32 32 }
33 33 .modal-dialog {
34 34 z-index:1050;
35 35 width:auto;
36 36 padding:10px;
37 37 margin-right:auto;
38 38 margin-left:auto;
39 39 }
40 40 .modal-content {
41 41 position:relative;
42 42 background-color:#ffffff;
43 43 border: @border-thickness solid rgba(0,0,0,0.2);
44 44 .border-radius(@border-radius);
45 45 outline:none;
46 46 -webkit-box-shadow:0 3px 9px rgba(0,0,0,0.5);
47 47 box-shadow:0 3px 9px rgba(0,0,0,0.5);
48 48 background-clip:padding-box;
49 49 }
50 50 .modal-backdrop {
51 51 position:fixed;
52 52 top:0;
53 53 right:0;
54 54 bottom:0;
55 55 left:0;
56 56 z-index:1030;
57 57 background-color:#000000;
58 58
59 59 &.modal-backdrop.fade {
60 60 opacity:0;
61 61 filter:alpha(opacity=0);
62 62 }
63 63 &.in {
64 64 opacity:0.5;
65 65 filter:alpha(opacity=50);
66 66 }
67 67 }
68 68 .modal-header {
69 69 min-height:16.428571429px;
70 70 padding:15px;
71 71 border-bottom: @border-thickness solid @grey6;
72 72 .close {
73 73 margin-top:-2px;
74 74 }
75 75 }
76 76 .modal-title {
77 77 margin:0;
78 78 line-height:1.428571429;
79 79 }
80 80 .modal-body {
81 81 position:relative;
82 82 padding:20px;
83 83 }
84 84 .modal-footer {
85 85 padding:19px 20px 20px;
86 86 margin-top:15px;
87 87 text-align:right;
88 88 border-top:1px solid #e5e5e5;
89 89 .btn + .btn {
90 90 margin-bottom:0;
91 91 margin-left:5px;
92 92 }
93 93 .btn-group .btn + .btn {
94 94 margin-left:-1px;
95 95 }
96 96 .btn-block + .btn-block {
97 97 margin-left:0;
98 98 }
99 99 &:before {
100 100 display:table;
101 101 content:" ";
102 102 }
103 103 &:after {
104 104 display:table;
105 105 content:" ";
106 106 clear:both;
107 107 }
108 108 }
109 109
110 110 /** MARKDOWN styling **/
111 111 div.markdown-block {
112 112 clear: both;
113 113 overflow: hidden;
114 114 margin: 0;
115 115 padding: 3px 15px 3px;
116 116 }
117 117
118 118 div.markdown-block h1,
119 119 div.markdown-block h2,
120 120 div.markdown-block h3,
121 121 div.markdown-block h4,
122 122 div.markdown-block h5,
123 123 div.markdown-block h6 {
124 124 border-bottom: none !important;
125 125 padding: 0 !important;
126 126 overflow: visible !important;
127 127 }
128 128
129 129 div.markdown-block h1,
130 130 div.markdown-block h2 {
131 131 border-bottom: 1px #e6e5e5 solid !important;
132 132 }
133 133
134 134 div.markdown-block h1 {
135 135 font-size: 32px;
136 136 margin: 15px 0 15px 0 !important;
137 137 }
138 138
139 139 div.markdown-block h2 {
140 140 font-size: 24px !important;
141 141 margin: 34px 0 10px 0 !important;
142 142 }
143 143
144 144 div.markdown-block h3 {
145 145 font-size: 18px !important;
146 146 margin: 30px 0 8px 0 !important;
147 147 padding-bottom: 2px !important;
148 148 }
149 149
150 150 div.markdown-block h4 {
151 151 font-size: 13px !important;
152 152 margin: 18px 0 3px 0 !important;
153 153 }
154 154
155 155 div.markdown-block h5 {
156 156 font-size: 12px !important;
157 157 margin: 15px 0 3px 0 !important;
158 158 }
159 159
160 160 div.markdown-block h6 {
161 161 font-size: 12px;
162 162 color: #777777;
163 163 margin: 15px 0 3px 0 !important;
164 164 }
165 165
166 166 div.markdown-block hr {
167 167 border: 0;
168 168 color: #e6e5e5;
169 169 background-color: #e6e5e5;
170 170 height: 3px;
171 171 margin-bottom: 13px;
172 172 }
173 173
174 174 div.markdown-block blockquote {
175 175 color: #424242 !important;
176 176 padding: 8px 21px;
177 177 margin: 12px 0;
178 178 border-left: 4px solid @grey6;
179 179 }
180 180
181 181 div.markdown-block blockquote p {
182 182 color: #424242 !important;
183 183 padding: 0 !important;
184 184 margin: 0 !important;
185 185 line-height: 1.5;
186 186 }
187 187
188 188
189 189 div.markdown-block ol,
190 190 div.markdown-block ul,
191 191 div.markdown-block p,
192 192 div.markdown-block blockquote,
193 193 div.markdown-block dl,
194 194 div.markdown-block li,
195 195 div.markdown-block table {
196 196 color: #424242 !important;
197 197 font-size: 13px !important;
198 198 font-family: @text-regular;
199 199 font-weight: normal !important;
200 200 overflow: visible !important;
201 201 }
202 202
203 203 div.markdown-block pre {
204 204 margin: 3px 0px 13px 0px !important;
205 205 padding: .5em;
206 206 color: #424242 !important;
207 207 font-size: 13px !important;
208 208 overflow: visible !important;
209 209 line-height: 140% !important;
210 210 background-color: @grey7;
211 211 }
212 212
213 213 div.markdown-block img {
214 214 border-style: none;
215 215 background-color: #fff;
216 216 padding-right: 20px;
217 217 max-width: 100%;
218 218 }
219 219
220 220
221 221 div.markdown-block strong {
222 222 font-weight: 600;
223 223 margin: 0;
224 224 }
225 225
226 226 div.markdown-block ul.checkbox,
227 227 div.markdown-block ol.checkbox {
228 228 padding-left: 20px !important;
229 229 margin-top: 0px !important;
230 230 margin-bottom: 18px !important;
231 231 }
232 232
233 233 div.markdown-block ul,
234 234 div.markdown-block ol {
235 235 padding-left: 30px !important;
236 236 margin-top: 0px !important;
237 237 margin-bottom: 18px !important;
238 238 }
239 239
240 240 div.markdown-block ul.checkbox li,
241 241 div.markdown-block ol.checkbox li {
242 242 list-style: none !important;
243 margin: 6px !important;
243 margin: 0px !important;
244 244 padding: 0 !important;
245 245 }
246 246
247 247 div.markdown-block ul li,
248 248 div.markdown-block ol li {
249 249 list-style: disc !important;
250 margin: 6px !important;
250 margin: 0px !important;
251 251 padding: 0 !important;
252 252 }
253 253
254 254 div.markdown-block ol li {
255 255 list-style: decimal !important;
256 256 }
257 257
258 258
259 259 div.markdown-block #message {
260 260 .border-radius(@border-radius);
261 261 border: @border-thickness solid @grey5;
262 262 display: block;
263 263 width: 100%;
264 264 height: 60px;
265 265 margin: 6px 0px;
266 266 }
267 267
268 268 div.markdown-block button,
269 269 div.markdown-block #ws {
270 270 font-size: @basefontsize;
271 271 padding: 4px 6px;
272 272 .border-radius(@border-radius);
273 273 border: @border-thickness solid @grey5;
274 274 background-color: @grey6;
275 275 }
276 276
277 277 div.markdown-block code,
278 278 div.markdown-block pre,
279 279 div.markdown-block #ws,
280 280 div.markdown-block #message {
281 281 font-family: @text-monospace;
282 282 font-size: 11px;
283 283 .border-radius(@border-radius);
284 284 background-color: white;
285 285 color: @grey3;
286 286 }
287 287
288 288
289 289 div.markdown-block code {
290 290 border: @border-thickness solid @grey6;
291 291 margin: 0 2px;
292 292 padding: 0 5px;
293 293 }
294 294
295 295 div.markdown-block pre {
296 296 border: @border-thickness solid @grey5;
297 297 overflow: auto;
298 298 padding: .5em;
299 299 background-color: @grey7;
300 300 }
301 301
302 302 div.markdown-block pre > code {
303 303 border: 0;
304 304 margin: 0;
305 305 padding: 0;
306 306 }
307 307
308 308 /** RST STYLE **/
309 309 div.rst-block {
310 310 clear: both;
311 311 overflow: hidden;
312 312 margin: 0;
313 313 padding: 3px 15px 3px;
314 314 }
315 315
316 316 div.rst-block h2 {
317 317 font-weight: normal;
318 318 }
319 319
320 320 div.rst-block h1,
321 321 div.rst-block h2,
322 322 div.rst-block h3,
323 323 div.rst-block h4,
324 324 div.rst-block h5,
325 325 div.rst-block h6 {
326 326 border-bottom: 0 !important;
327 327 margin: 0 !important;
328 328 padding: 0 !important;
329 329 line-height: 1.5em !important;
330 330 }
331 331
332 332
333 333 div.rst-block h1:first-child {
334 334 padding-top: .25em !important;
335 335 }
336 336
337 337 div.rst-block h2,
338 338 div.rst-block h3 {
339 339 margin: 1em 0 !important;
340 340 }
341 341
342 342 div.rst-block h1,
343 343 div.rst-block h2 {
344 344 border-bottom: 1px #e6e5e5 solid !important;
345 345 }
346 346
347 347 div.rst-block h2 {
348 348 margin-top: 1.5em !important;
349 349 padding-top: .5em !important;
350 350 }
351 351
352 352 div.rst-block p {
353 353 color: black !important;
354 354 margin: 1em 0 !important;
355 355 line-height: 1.5em !important;
356 356 }
357 357
358 358 div.rst-block ul {
359 359 list-style: disc !important;
360 360 margin: 1em 0 1em 2em !important;
361 361 clear: both;
362 362 }
363 363
364 364 div.rst-block ol {
365 365 list-style: decimal;
366 366 margin: 1em 0 1em 2em !important;
367 367 }
368 368
369 369 div.rst-block pre,
370 370 div.rst-block code {
371 371 font: 12px "Bitstream Vera Sans Mono","Courier",monospace;
372 372 }
373 373
374 374 div.rst-block code {
375 375 font-size: 12px !important;
376 376 background-color: ghostWhite !important;
377 377 color: #444 !important;
378 378 padding: 0 .2em !important;
379 379 border: 1px solid #dedede !important;
380 380 }
381 381
382 382 div.rst-block pre code {
383 383 padding: 0 !important;
384 384 font-size: 12px !important;
385 385 background-color: #eee !important;
386 386 border: none !important;
387 387 }
388 388
389 389 div.rst-block pre {
390 390 margin: 1em 0;
391 391 padding: @padding;
392 392 border: 1px solid @grey6;
393 393 .border-radius(@border-radius);
394 394 overflow: auto;
395 395 font-size: 12px;
396 396 color: #444;
397 397 background-color: @grey7;
398 398 }
399 399
400 400
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
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