##// END OF EJS Templates
reviewers: added observers as another way to define reviewers....
marcink -
r4500:bfede169 stable
parent child Browse files
Show More

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

@@ -0,0 +1,68 b''
1 # -*- coding: utf-8 -*-
2
3 import logging
4 from sqlalchemy import *
5
6 from alembic.migration import MigrationContext
7 from alembic.operations import Operations
8
9 from rhodecode.lib.dbmigrate.versions import _reset_base
10 from rhodecode.model import meta, init_model_encryption
11
12
13 log = logging.getLogger(__name__)
14
15
16 def upgrade(migrate_engine):
17 """
18 Upgrade operations go here.
19 Don't create your own engine; bind migrate_engine to your metadata
20 """
21 _reset_base(migrate_engine)
22 from rhodecode.lib.dbmigrate.schema import db_4_20_0_0 as db
23
24 init_model_encryption(db)
25
26 context = MigrationContext.configure(migrate_engine.connect())
27 op = Operations(context)
28
29 table = db.RepoReviewRuleUser.__table__
30 with op.batch_alter_table(table.name) as batch_op:
31 new_column = Column('role', Unicode(255), nullable=True)
32 batch_op.add_column(new_column)
33
34 _fill_rule_user_role(op, meta.Session)
35
36 table = db.RepoReviewRuleUserGroup.__table__
37 with op.batch_alter_table(table.name) as batch_op:
38 new_column = Column('role', Unicode(255), nullable=True)
39 batch_op.add_column(new_column)
40
41 _fill_rule_user_group_role(op, meta.Session)
42
43
44 def downgrade(migrate_engine):
45 meta = MetaData()
46 meta.bind = migrate_engine
47
48
49 def fixups(models, _SESSION):
50 pass
51
52
53 def _fill_rule_user_role(op, session):
54 params = {'role': 'reviewer'}
55 query = text(
56 'UPDATE repo_review_rules_users SET role = :role'
57 ).bindparams(**params)
58 op.execute(query)
59 session().commit()
60
61
62 def _fill_rule_user_group_role(op, session):
63 params = {'role': 'reviewer'}
64 query = text(
65 'UPDATE repo_review_rules_users_groups SET role = :role'
66 ).bindparams(**params)
67 op.execute(query)
68 session().commit()
@@ -0,0 +1,35 b''
1 # -*- coding: utf-8 -*-
2
3 # Copyright (C) 2010-2020 RhodeCode GmbH
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
21
22 def html(info):
23 """
24 Custom string as html content_type renderer for pyramid
25 """
26 def _render(value, system):
27 request = system.get('request')
28 if request is not None:
29 response = request.response
30 ct = response.content_type
31 if ct == response.default_content_type:
32 response.content_type = 'text/html'
33 return value
34
35 return _render
@@ -1,60 +1,60 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import os
22 22 from collections import OrderedDict
23 23
24 24 import sys
25 25 import platform
26 26
27 27 VERSION = tuple(open(os.path.join(
28 28 os.path.dirname(__file__), 'VERSION')).read().split('.'))
29 29
30 30 BACKENDS = OrderedDict()
31 31
32 32 BACKENDS['hg'] = 'Mercurial repository'
33 33 BACKENDS['git'] = 'Git repository'
34 34 BACKENDS['svn'] = 'Subversion repository'
35 35
36 36
37 37 CELERY_ENABLED = False
38 38 CELERY_EAGER = False
39 39
40 40 # link to config for pyramid
41 41 CONFIG = {}
42 42
43 43 # Populated with the settings dictionary from application init in
44 44 # rhodecode.conf.environment.load_pyramid_environment
45 45 PYRAMID_SETTINGS = {}
46 46
47 47 # Linked module for extensions
48 48 EXTENSIONS = {}
49 49
50 50 __version__ = ('.'.join((str(each) for each in VERSION[:3])))
51 __dbversion__ = 109 # defines current db version for migrations
51 __dbversion__ = 110 # defines current db version for migrations
52 52 __platform__ = platform.system()
53 53 __license__ = 'AGPLv3, and Commercial License'
54 54 __author__ = 'RhodeCode GmbH'
55 55 __url__ = 'https://code.rhodecode.com'
56 56
57 57 is_windows = __platform__ in ['Windows']
58 58 is_unix = not is_windows
59 59 is_test = False
60 60 disable_error_handler = False
@@ -1,1018 +1,1017 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 import logging
23 23
24 24 from rhodecode.api import jsonrpc_method, JSONRPCError, JSONRPCValidationError
25 25 from rhodecode.api.utils import (
26 26 has_superadmin_permission, Optional, OAttr, get_repo_or_error,
27 27 get_pull_request_or_error, get_commit_or_error, get_user_or_error,
28 28 validate_repo_permissions, resolve_ref_or_error, validate_set_owner_permissions)
29 29 from rhodecode.lib.auth import (HasRepoPermissionAnyApi)
30 30 from rhodecode.lib.base import vcs_operation_context
31 31 from rhodecode.lib.utils2 import str2bool
32 32 from rhodecode.model.changeset_status import ChangesetStatusModel
33 33 from rhodecode.model.comment import CommentsModel
34 34 from rhodecode.model.db import Session, ChangesetStatus, ChangesetComment, PullRequest
35 35 from rhodecode.model.pull_request import PullRequestModel, MergeCheck
36 36 from rhodecode.model.settings import SettingsModel
37 37 from rhodecode.model.validation_schema import Invalid
38 38 from rhodecode.model.validation_schema.schemas.reviewer_schema import ReviewerListSchema
39 39
40 40 log = logging.getLogger(__name__)
41 41
42 42
43 43 @jsonrpc_method()
44 44 def get_pull_request(request, apiuser, pullrequestid, repoid=Optional(None),
45 45 merge_state=Optional(False)):
46 46 """
47 47 Get a pull request based on the given ID.
48 48
49 49 :param apiuser: This is filled automatically from the |authtoken|.
50 50 :type apiuser: AuthUser
51 51 :param repoid: Optional, repository name or repository ID from where
52 52 the pull request was opened.
53 53 :type repoid: str or int
54 54 :param pullrequestid: ID of the requested pull request.
55 55 :type pullrequestid: int
56 56 :param merge_state: Optional calculate merge state for each repository.
57 57 This could result in longer time to fetch the data
58 58 :type merge_state: bool
59 59
60 60 Example output:
61 61
62 62 .. code-block:: bash
63 63
64 64 "id": <id_given_in_input>,
65 65 "result":
66 66 {
67 67 "pull_request_id": "<pull_request_id>",
68 68 "url": "<url>",
69 69 "title": "<title>",
70 70 "description": "<description>",
71 71 "status" : "<status>",
72 72 "created_on": "<date_time_created>",
73 73 "updated_on": "<date_time_updated>",
74 74 "versions": "<number_or_versions_of_pr>",
75 75 "commit_ids": [
76 76 ...
77 77 "<commit_id>",
78 78 "<commit_id>",
79 79 ...
80 80 ],
81 81 "review_status": "<review_status>",
82 82 "mergeable": {
83 83 "status": "<bool>",
84 84 "message": "<message>",
85 85 },
86 86 "source": {
87 87 "clone_url": "<clone_url>",
88 88 "repository": "<repository_name>",
89 89 "reference":
90 90 {
91 91 "name": "<name>",
92 92 "type": "<type>",
93 93 "commit_id": "<commit_id>",
94 94 }
95 95 },
96 96 "target": {
97 97 "clone_url": "<clone_url>",
98 98 "repository": "<repository_name>",
99 99 "reference":
100 100 {
101 101 "name": "<name>",
102 102 "type": "<type>",
103 103 "commit_id": "<commit_id>",
104 104 }
105 105 },
106 106 "merge": {
107 107 "clone_url": "<clone_url>",
108 108 "reference":
109 109 {
110 110 "name": "<name>",
111 111 "type": "<type>",
112 112 "commit_id": "<commit_id>",
113 113 }
114 114 },
115 115 "author": <user_obj>,
116 116 "reviewers": [
117 117 ...
118 118 {
119 119 "user": "<user_obj>",
120 120 "review_status": "<review_status>",
121 121 }
122 122 ...
123 123 ]
124 124 },
125 125 "error": null
126 126 """
127 127
128 128 pull_request = get_pull_request_or_error(pullrequestid)
129 129 if Optional.extract(repoid):
130 130 repo = get_repo_or_error(repoid)
131 131 else:
132 132 repo = pull_request.target_repo
133 133
134 134 if not PullRequestModel().check_user_read(pull_request, apiuser, api=True):
135 135 raise JSONRPCError('repository `%s` or pull request `%s` '
136 136 'does not exist' % (repoid, pullrequestid))
137 137
138 138 # NOTE(marcink): only calculate and return merge state if the pr state is 'created'
139 139 # otherwise we can lock the repo on calculation of merge state while update/merge
140 140 # is happening.
141 141 pr_created = pull_request.pull_request_state == pull_request.STATE_CREATED
142 142 merge_state = Optional.extract(merge_state, binary=True) and pr_created
143 143 data = pull_request.get_api_data(with_merge_state=merge_state)
144 144 return data
145 145
146 146
147 147 @jsonrpc_method()
148 148 def get_pull_requests(request, apiuser, repoid, status=Optional('new'),
149 149 merge_state=Optional(False)):
150 150 """
151 151 Get all pull requests from the repository specified in `repoid`.
152 152
153 153 :param apiuser: This is filled automatically from the |authtoken|.
154 154 :type apiuser: AuthUser
155 155 :param repoid: Optional repository name or repository ID.
156 156 :type repoid: str or int
157 157 :param status: Only return pull requests with the specified status.
158 158 Valid options are.
159 159 * ``new`` (default)
160 160 * ``open``
161 161 * ``closed``
162 162 :type status: str
163 163 :param merge_state: Optional calculate merge state for each repository.
164 164 This could result in longer time to fetch the data
165 165 :type merge_state: bool
166 166
167 167 Example output:
168 168
169 169 .. code-block:: bash
170 170
171 171 "id": <id_given_in_input>,
172 172 "result":
173 173 [
174 174 ...
175 175 {
176 176 "pull_request_id": "<pull_request_id>",
177 177 "url": "<url>",
178 178 "title" : "<title>",
179 179 "description": "<description>",
180 180 "status": "<status>",
181 181 "created_on": "<date_time_created>",
182 182 "updated_on": "<date_time_updated>",
183 183 "commit_ids": [
184 184 ...
185 185 "<commit_id>",
186 186 "<commit_id>",
187 187 ...
188 188 ],
189 189 "review_status": "<review_status>",
190 190 "mergeable": {
191 191 "status": "<bool>",
192 192 "message: "<message>",
193 193 },
194 194 "source": {
195 195 "clone_url": "<clone_url>",
196 196 "reference":
197 197 {
198 198 "name": "<name>",
199 199 "type": "<type>",
200 200 "commit_id": "<commit_id>",
201 201 }
202 202 },
203 203 "target": {
204 204 "clone_url": "<clone_url>",
205 205 "reference":
206 206 {
207 207 "name": "<name>",
208 208 "type": "<type>",
209 209 "commit_id": "<commit_id>",
210 210 }
211 211 },
212 212 "merge": {
213 213 "clone_url": "<clone_url>",
214 214 "reference":
215 215 {
216 216 "name": "<name>",
217 217 "type": "<type>",
218 218 "commit_id": "<commit_id>",
219 219 }
220 220 },
221 221 "author": <user_obj>,
222 222 "reviewers": [
223 223 ...
224 224 {
225 225 "user": "<user_obj>",
226 226 "review_status": "<review_status>",
227 227 }
228 228 ...
229 229 ]
230 230 }
231 231 ...
232 232 ],
233 233 "error": null
234 234
235 235 """
236 236 repo = get_repo_or_error(repoid)
237 237 if not has_superadmin_permission(apiuser):
238 238 _perms = (
239 239 'repository.admin', 'repository.write', 'repository.read',)
240 240 validate_repo_permissions(apiuser, repoid, repo, _perms)
241 241
242 242 status = Optional.extract(status)
243 243 merge_state = Optional.extract(merge_state, binary=True)
244 244 pull_requests = PullRequestModel().get_all(repo, statuses=[status],
245 245 order_by='id', order_dir='desc')
246 246 data = [pr.get_api_data(with_merge_state=merge_state) for pr in pull_requests]
247 247 return data
248 248
249 249
250 250 @jsonrpc_method()
251 251 def merge_pull_request(
252 252 request, apiuser, pullrequestid, repoid=Optional(None),
253 253 userid=Optional(OAttr('apiuser'))):
254 254 """
255 255 Merge the pull request specified by `pullrequestid` into its target
256 256 repository.
257 257
258 258 :param apiuser: This is filled automatically from the |authtoken|.
259 259 :type apiuser: AuthUser
260 260 :param repoid: Optional, repository name or repository ID of the
261 261 target repository to which the |pr| is to be merged.
262 262 :type repoid: str or int
263 263 :param pullrequestid: ID of the pull request which shall be merged.
264 264 :type pullrequestid: int
265 265 :param userid: Merge the pull request as this user.
266 266 :type userid: Optional(str or int)
267 267
268 268 Example output:
269 269
270 270 .. code-block:: bash
271 271
272 272 "id": <id_given_in_input>,
273 273 "result": {
274 274 "executed": "<bool>",
275 275 "failure_reason": "<int>",
276 276 "merge_status_message": "<str>",
277 277 "merge_commit_id": "<merge_commit_id>",
278 278 "possible": "<bool>",
279 279 "merge_ref": {
280 280 "commit_id": "<commit_id>",
281 281 "type": "<type>",
282 282 "name": "<name>"
283 283 }
284 284 },
285 285 "error": null
286 286 """
287 287 pull_request = get_pull_request_or_error(pullrequestid)
288 288 if Optional.extract(repoid):
289 289 repo = get_repo_or_error(repoid)
290 290 else:
291 291 repo = pull_request.target_repo
292 292 auth_user = apiuser
293 293
294 294 if not isinstance(userid, Optional):
295 295 is_repo_admin = HasRepoPermissionAnyApi('repository.admin')(
296 296 user=apiuser, repo_name=repo.repo_name)
297 297 if has_superadmin_permission(apiuser) or is_repo_admin:
298 298 apiuser = get_user_or_error(userid)
299 299 auth_user = apiuser.AuthUser()
300 300 else:
301 301 raise JSONRPCError('userid is not the same as your user')
302 302
303 303 if pull_request.pull_request_state != PullRequest.STATE_CREATED:
304 304 raise JSONRPCError(
305 305 'Operation forbidden because pull request is in state {}, '
306 306 'only state {} is allowed.'.format(
307 307 pull_request.pull_request_state, PullRequest.STATE_CREATED))
308 308
309 309 with pull_request.set_state(PullRequest.STATE_UPDATING):
310 310 check = MergeCheck.validate(pull_request, auth_user=auth_user,
311 311 translator=request.translate)
312 312 merge_possible = not check.failed
313 313
314 314 if not merge_possible:
315 315 error_messages = []
316 316 for err_type, error_msg in check.errors:
317 317 error_msg = request.translate(error_msg)
318 318 error_messages.append(error_msg)
319 319
320 320 reasons = ','.join(error_messages)
321 321 raise JSONRPCError(
322 322 'merge not possible for following reasons: {}'.format(reasons))
323 323
324 324 target_repo = pull_request.target_repo
325 325 extras = vcs_operation_context(
326 326 request.environ, repo_name=target_repo.repo_name,
327 327 username=auth_user.username, action='push',
328 328 scm=target_repo.repo_type)
329 329 with pull_request.set_state(PullRequest.STATE_UPDATING):
330 330 merge_response = PullRequestModel().merge_repo(
331 331 pull_request, apiuser, extras=extras)
332 332 if merge_response.executed:
333 333 PullRequestModel().close_pull_request(pull_request.pull_request_id, auth_user)
334 334
335 335 Session().commit()
336 336
337 337 # In previous versions the merge response directly contained the merge
338 338 # commit id. It is now contained in the merge reference object. To be
339 339 # backwards compatible we have to extract it again.
340 340 merge_response = merge_response.asdict()
341 341 merge_response['merge_commit_id'] = merge_response['merge_ref'].commit_id
342 342
343 343 return merge_response
344 344
345 345
346 346 @jsonrpc_method()
347 347 def get_pull_request_comments(
348 348 request, apiuser, pullrequestid, repoid=Optional(None)):
349 349 """
350 350 Get all comments of pull request specified with the `pullrequestid`
351 351
352 352 :param apiuser: This is filled automatically from the |authtoken|.
353 353 :type apiuser: AuthUser
354 354 :param repoid: Optional repository name or repository ID.
355 355 :type repoid: str or int
356 356 :param pullrequestid: The pull request ID.
357 357 :type pullrequestid: int
358 358
359 359 Example output:
360 360
361 361 .. code-block:: bash
362 362
363 363 id : <id_given_in_input>
364 364 result : [
365 365 {
366 366 "comment_author": {
367 367 "active": true,
368 368 "full_name_or_username": "Tom Gore",
369 369 "username": "admin"
370 370 },
371 371 "comment_created_on": "2017-01-02T18:43:45.533",
372 372 "comment_f_path": null,
373 373 "comment_id": 25,
374 374 "comment_lineno": null,
375 375 "comment_status": {
376 376 "status": "under_review",
377 377 "status_lbl": "Under Review"
378 378 },
379 379 "comment_text": "Example text",
380 380 "comment_type": null,
381 381 "comment_last_version: 0,
382 382 "pull_request_version": null,
383 383 "comment_commit_id": None,
384 384 "comment_pull_request_id": <pull_request_id>
385 385 }
386 386 ],
387 387 error : null
388 388 """
389 389
390 390 pull_request = get_pull_request_or_error(pullrequestid)
391 391 if Optional.extract(repoid):
392 392 repo = get_repo_or_error(repoid)
393 393 else:
394 394 repo = pull_request.target_repo
395 395
396 396 if not PullRequestModel().check_user_read(
397 397 pull_request, apiuser, api=True):
398 398 raise JSONRPCError('repository `%s` or pull request `%s` '
399 399 'does not exist' % (repoid, pullrequestid))
400 400
401 401 (pull_request_latest,
402 402 pull_request_at_ver,
403 403 pull_request_display_obj,
404 404 at_version) = PullRequestModel().get_pr_version(
405 405 pull_request.pull_request_id, version=None)
406 406
407 407 versions = pull_request_display_obj.versions()
408 408 ver_map = {
409 409 ver.pull_request_version_id: cnt
410 410 for cnt, ver in enumerate(versions, 1)
411 411 }
412 412
413 413 # GENERAL COMMENTS with versions #
414 414 q = CommentsModel()._all_general_comments_of_pull_request(pull_request)
415 415 q = q.order_by(ChangesetComment.comment_id.asc())
416 416 general_comments = q.all()
417 417
418 418 # INLINE COMMENTS with versions #
419 419 q = CommentsModel()._all_inline_comments_of_pull_request(pull_request)
420 420 q = q.order_by(ChangesetComment.comment_id.asc())
421 421 inline_comments = q.all()
422 422
423 423 data = []
424 424 for comment in inline_comments + general_comments:
425 425 full_data = comment.get_api_data()
426 426 pr_version_id = None
427 427 if comment.pull_request_version_id:
428 428 pr_version_id = 'v{}'.format(
429 429 ver_map[comment.pull_request_version_id])
430 430
431 431 # sanitize some entries
432 432
433 433 full_data['pull_request_version'] = pr_version_id
434 434 full_data['comment_author'] = {
435 435 'username': full_data['comment_author'].username,
436 436 'full_name_or_username': full_data['comment_author'].full_name_or_username,
437 437 'active': full_data['comment_author'].active,
438 438 }
439 439
440 440 if full_data['comment_status']:
441 441 full_data['comment_status'] = {
442 442 'status': full_data['comment_status'][0].status,
443 443 'status_lbl': full_data['comment_status'][0].status_lbl,
444 444 }
445 445 else:
446 446 full_data['comment_status'] = {}
447 447
448 448 data.append(full_data)
449 449 return data
450 450
451 451
452 452 @jsonrpc_method()
453 453 def comment_pull_request(
454 454 request, apiuser, pullrequestid, repoid=Optional(None),
455 455 message=Optional(None), commit_id=Optional(None), status=Optional(None),
456 456 comment_type=Optional(ChangesetComment.COMMENT_TYPE_NOTE),
457 457 resolves_comment_id=Optional(None), extra_recipients=Optional([]),
458 458 userid=Optional(OAttr('apiuser')), send_email=Optional(True)):
459 459 """
460 460 Comment on the pull request specified with the `pullrequestid`,
461 461 in the |repo| specified by the `repoid`, and optionally change the
462 462 review status.
463 463
464 464 :param apiuser: This is filled automatically from the |authtoken|.
465 465 :type apiuser: AuthUser
466 466 :param repoid: Optional repository name or repository ID.
467 467 :type repoid: str or int
468 468 :param pullrequestid: The pull request ID.
469 469 :type pullrequestid: int
470 470 :param commit_id: Specify the commit_id for which to set a comment. If
471 471 given commit_id is different than latest in the PR status
472 472 change won't be performed.
473 473 :type commit_id: str
474 474 :param message: The text content of the comment.
475 475 :type message: str
476 476 :param status: (**Optional**) Set the approval status of the pull
477 477 request. One of: 'not_reviewed', 'approved', 'rejected',
478 478 'under_review'
479 479 :type status: str
480 480 :param comment_type: Comment type, one of: 'note', 'todo'
481 481 :type comment_type: Optional(str), default: 'note'
482 482 :param resolves_comment_id: id of comment which this one will resolve
483 483 :type resolves_comment_id: Optional(int)
484 484 :param extra_recipients: list of user ids or usernames to add
485 485 notifications for this comment. Acts like a CC for notification
486 486 :type extra_recipients: Optional(list)
487 487 :param userid: Comment on the pull request as this user
488 488 :type userid: Optional(str or int)
489 489 :param send_email: Define if this comment should also send email notification
490 490 :type send_email: Optional(bool)
491 491
492 492 Example output:
493 493
494 494 .. code-block:: bash
495 495
496 496 id : <id_given_in_input>
497 497 result : {
498 498 "pull_request_id": "<Integer>",
499 499 "comment_id": "<Integer>",
500 500 "status": {"given": <given_status>,
501 501 "was_changed": <bool status_was_actually_changed> },
502 502 },
503 503 error : null
504 504 """
505 505 pull_request = get_pull_request_or_error(pullrequestid)
506 506 if Optional.extract(repoid):
507 507 repo = get_repo_or_error(repoid)
508 508 else:
509 509 repo = pull_request.target_repo
510 510
511 511 auth_user = apiuser
512 512 if not isinstance(userid, Optional):
513 513 is_repo_admin = HasRepoPermissionAnyApi('repository.admin')(
514 514 user=apiuser, repo_name=repo.repo_name)
515 515 if has_superadmin_permission(apiuser) or is_repo_admin:
516 516 apiuser = get_user_or_error(userid)
517 517 auth_user = apiuser.AuthUser()
518 518 else:
519 519 raise JSONRPCError('userid is not the same as your user')
520 520
521 521 if pull_request.is_closed():
522 522 raise JSONRPCError(
523 523 'pull request `%s` comment failed, pull request is closed' % (
524 524 pullrequestid,))
525 525
526 526 if not PullRequestModel().check_user_read(
527 527 pull_request, apiuser, api=True):
528 528 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
529 529 message = Optional.extract(message)
530 530 status = Optional.extract(status)
531 531 commit_id = Optional.extract(commit_id)
532 532 comment_type = Optional.extract(comment_type)
533 533 resolves_comment_id = Optional.extract(resolves_comment_id)
534 534 extra_recipients = Optional.extract(extra_recipients)
535 535 send_email = Optional.extract(send_email, binary=True)
536 536
537 537 if not message and not status:
538 538 raise JSONRPCError(
539 539 'Both message and status parameters are missing. '
540 540 'At least one is required.')
541 541
542 542 if (status not in (st[0] for st in ChangesetStatus.STATUSES) and
543 543 status is not None):
544 544 raise JSONRPCError('Unknown comment status: `%s`' % status)
545 545
546 546 if commit_id and commit_id not in pull_request.revisions:
547 547 raise JSONRPCError(
548 548 'Invalid commit_id `%s` for this pull request.' % commit_id)
549 549
550 550 allowed_to_change_status = PullRequestModel().check_user_change_status(
551 551 pull_request, apiuser)
552 552
553 553 # if commit_id is passed re-validated if user is allowed to change status
554 554 # based on latest commit_id from the PR
555 555 if commit_id:
556 556 commit_idx = pull_request.revisions.index(commit_id)
557 557 if commit_idx != 0:
558 558 allowed_to_change_status = False
559 559
560 560 if resolves_comment_id:
561 561 comment = ChangesetComment.get(resolves_comment_id)
562 562 if not comment:
563 563 raise JSONRPCError(
564 564 'Invalid resolves_comment_id `%s` for this pull request.'
565 565 % resolves_comment_id)
566 566 if comment.comment_type != ChangesetComment.COMMENT_TYPE_TODO:
567 567 raise JSONRPCError(
568 568 'Comment `%s` is wrong type for setting status to resolved.'
569 569 % resolves_comment_id)
570 570
571 571 text = message
572 572 status_label = ChangesetStatus.get_status_lbl(status)
573 573 if status and allowed_to_change_status:
574 574 st_message = ('Status change %(transition_icon)s %(status)s'
575 575 % {'transition_icon': '>', 'status': status_label})
576 576 text = message or st_message
577 577
578 578 rc_config = SettingsModel().get_all_settings()
579 579 renderer = rc_config.get('rhodecode_markup_renderer', 'rst')
580 580
581 581 status_change = status and allowed_to_change_status
582 582 comment = CommentsModel().create(
583 583 text=text,
584 584 repo=pull_request.target_repo.repo_id,
585 585 user=apiuser.user_id,
586 586 pull_request=pull_request.pull_request_id,
587 587 f_path=None,
588 588 line_no=None,
589 589 status_change=(status_label if status_change else None),
590 590 status_change_type=(status if status_change else None),
591 591 closing_pr=False,
592 592 renderer=renderer,
593 593 comment_type=comment_type,
594 594 resolves_comment_id=resolves_comment_id,
595 595 auth_user=auth_user,
596 596 extra_recipients=extra_recipients,
597 597 send_email=send_email
598 598 )
599 599
600 600 if allowed_to_change_status and status:
601 601 old_calculated_status = pull_request.calculated_review_status()
602 602 ChangesetStatusModel().set_status(
603 603 pull_request.target_repo.repo_id,
604 604 status,
605 605 apiuser.user_id,
606 606 comment,
607 607 pull_request=pull_request.pull_request_id
608 608 )
609 609 Session().flush()
610 610
611 611 Session().commit()
612 612
613 613 PullRequestModel().trigger_pull_request_hook(
614 614 pull_request, apiuser, 'comment',
615 615 data={'comment': comment})
616 616
617 617 if allowed_to_change_status and status:
618 618 # we now calculate the status of pull request, and based on that
619 619 # calculation we set the commits status
620 620 calculated_status = pull_request.calculated_review_status()
621 621 if old_calculated_status != calculated_status:
622 622 PullRequestModel().trigger_pull_request_hook(
623 623 pull_request, apiuser, 'review_status_change',
624 624 data={'status': calculated_status})
625 625
626 626 data = {
627 627 'pull_request_id': pull_request.pull_request_id,
628 628 'comment_id': comment.comment_id if comment else None,
629 629 'status': {'given': status, 'was_changed': status_change},
630 630 }
631 631 return data
632 632
633 633
634 634 @jsonrpc_method()
635 635 def create_pull_request(
636 636 request, apiuser, source_repo, target_repo, source_ref, target_ref,
637 637 owner=Optional(OAttr('apiuser')), title=Optional(''), description=Optional(''),
638 638 description_renderer=Optional(''), reviewers=Optional(None)):
639 639 """
640 640 Creates a new pull request.
641 641
642 642 Accepts refs in the following formats:
643 643
644 644 * branch:<branch_name>:<sha>
645 645 * branch:<branch_name>
646 646 * bookmark:<bookmark_name>:<sha> (Mercurial only)
647 647 * bookmark:<bookmark_name> (Mercurial only)
648 648
649 649 :param apiuser: This is filled automatically from the |authtoken|.
650 650 :type apiuser: AuthUser
651 651 :param source_repo: Set the source repository name.
652 652 :type source_repo: str
653 653 :param target_repo: Set the target repository name.
654 654 :type target_repo: str
655 655 :param source_ref: Set the source ref name.
656 656 :type source_ref: str
657 657 :param target_ref: Set the target ref name.
658 658 :type target_ref: str
659 659 :param owner: user_id or username
660 660 :type owner: Optional(str)
661 661 :param title: Optionally Set the pull request title, it's generated otherwise
662 662 :type title: str
663 663 :param description: Set the pull request description.
664 664 :type description: Optional(str)
665 665 :type description_renderer: Optional(str)
666 666 :param description_renderer: Set pull request renderer for the description.
667 667 It should be 'rst', 'markdown' or 'plain'. If not give default
668 668 system renderer will be used
669 669 :param reviewers: Set the new pull request reviewers list.
670 670 Reviewer defined by review rules will be added automatically to the
671 671 defined list.
672 672 :type reviewers: Optional(list)
673 673 Accepts username strings or objects of the format:
674 674
675 675 [{'username': 'nick', 'reasons': ['original author'], 'mandatory': <bool>}]
676 676 """
677 677
678 678 source_db_repo = get_repo_or_error(source_repo)
679 679 target_db_repo = get_repo_or_error(target_repo)
680 680 if not has_superadmin_permission(apiuser):
681 681 _perms = ('repository.admin', 'repository.write', 'repository.read',)
682 682 validate_repo_permissions(apiuser, source_repo, source_db_repo, _perms)
683 683
684 684 owner = validate_set_owner_permissions(apiuser, owner)
685 685
686 686 full_source_ref = resolve_ref_or_error(source_ref, source_db_repo)
687 687 full_target_ref = resolve_ref_or_error(target_ref, target_db_repo)
688 688
689 689 source_commit = get_commit_or_error(full_source_ref, source_db_repo)
690 690 target_commit = get_commit_or_error(full_target_ref, target_db_repo)
691 691
692 692 reviewer_objects = Optional.extract(reviewers) or []
693 693
694 694 # serialize and validate passed in given reviewers
695 695 if reviewer_objects:
696 696 schema = ReviewerListSchema()
697 697 try:
698 698 reviewer_objects = schema.deserialize(reviewer_objects)
699 699 except Invalid as err:
700 700 raise JSONRPCValidationError(colander_exc=err)
701 701
702 702 # validate users
703 703 for reviewer_object in reviewer_objects:
704 704 user = get_user_or_error(reviewer_object['username'])
705 705 reviewer_object['user_id'] = user.user_id
706 706
707 get_default_reviewers_data, validate_default_reviewers = \
707 get_default_reviewers_data, validate_default_reviewers, validate_observers = \
708 708 PullRequestModel().get_reviewer_functions()
709 709
710 710 # recalculate reviewers logic, to make sure we can validate this
711 711 default_reviewers_data = get_default_reviewers_data(
712 712 owner, source_db_repo,
713 713 source_commit, target_db_repo, target_commit)
714 714
715 715 # now MERGE our given with the calculated
716 716 reviewer_objects = default_reviewers_data['reviewers'] + reviewer_objects
717 717
718 718 try:
719 719 reviewers = validate_default_reviewers(
720 720 reviewer_objects, default_reviewers_data)
721 721 except ValueError as e:
722 722 raise JSONRPCError('Reviewers Validation: {}'.format(e))
723 723
724 724 title = Optional.extract(title)
725 725 if not title:
726 726 title_source_ref = source_ref.split(':', 2)[1]
727 727 title = PullRequestModel().generate_pullrequest_title(
728 728 source=source_repo,
729 729 source_ref=title_source_ref,
730 730 target=target_repo
731 731 )
732 732
733 733 diff_info = default_reviewers_data['diff_info']
734 734 common_ancestor_id = diff_info['ancestor']
735 735 commits = diff_info['commits']
736 736
737 737 if not common_ancestor_id:
738 738 raise JSONRPCError('no common ancestor found')
739 739
740 740 if not commits:
741 741 raise JSONRPCError('no commits found')
742 742
743 743 # NOTE(marcink): reversed is consistent with how we open it in the WEB interface
744 744 revisions = [commit.raw_id for commit in reversed(commits)]
745 745
746 746 # recalculate target ref based on ancestor
747 747 target_ref_type, target_ref_name, __ = full_target_ref.split(':')
748 748 full_target_ref = ':'.join((target_ref_type, target_ref_name, common_ancestor_id))
749 749
750 750 # fetch renderer, if set fallback to plain in case of PR
751 751 rc_config = SettingsModel().get_all_settings()
752 752 default_system_renderer = rc_config.get('rhodecode_markup_renderer', 'plain')
753 753 description = Optional.extract(description)
754 754 description_renderer = Optional.extract(description_renderer) or default_system_renderer
755 755
756 756 pull_request = PullRequestModel().create(
757 757 created_by=owner.user_id,
758 758 source_repo=source_repo,
759 759 source_ref=full_source_ref,
760 760 target_repo=target_repo,
761 761 target_ref=full_target_ref,
762 762 common_ancestor_id=common_ancestor_id,
763 763 revisions=revisions,
764 764 reviewers=reviewers,
765 765 title=title,
766 766 description=description,
767 767 description_renderer=description_renderer,
768 768 reviewer_data=default_reviewers_data,
769 769 auth_user=apiuser
770 770 )
771 771
772 772 Session().commit()
773 773 data = {
774 774 'msg': 'Created new pull request `{}`'.format(title),
775 775 'pull_request_id': pull_request.pull_request_id,
776 776 }
777 777 return data
778 778
779 779
780 780 @jsonrpc_method()
781 781 def update_pull_request(
782 782 request, apiuser, pullrequestid, repoid=Optional(None),
783 783 title=Optional(''), description=Optional(''), description_renderer=Optional(''),
784 784 reviewers=Optional(None), update_commits=Optional(None)):
785 785 """
786 786 Updates a pull request.
787 787
788 788 :param apiuser: This is filled automatically from the |authtoken|.
789 789 :type apiuser: AuthUser
790 790 :param repoid: Optional repository name or repository ID.
791 791 :type repoid: str or int
792 792 :param pullrequestid: The pull request ID.
793 793 :type pullrequestid: int
794 794 :param title: Set the pull request title.
795 795 :type title: str
796 796 :param description: Update pull request description.
797 797 :type description: Optional(str)
798 798 :type description_renderer: Optional(str)
799 799 :param description_renderer: Update pull request renderer for the description.
800 800 It should be 'rst', 'markdown' or 'plain'
801 801 :param reviewers: Update pull request reviewers list with new value.
802 802 :type reviewers: Optional(list)
803 803 Accepts username strings or objects of the format:
804 804
805 805 [{'username': 'nick', 'reasons': ['original author'], 'mandatory': <bool>}]
806 806
807 807 :param update_commits: Trigger update of commits for this pull request
808 808 :type: update_commits: Optional(bool)
809 809
810 810 Example output:
811 811
812 812 .. code-block:: bash
813 813
814 814 id : <id_given_in_input>
815 815 result : {
816 816 "msg": "Updated pull request `63`",
817 817 "pull_request": <pull_request_object>,
818 818 "updated_reviewers": {
819 819 "added": [
820 820 "username"
821 821 ],
822 822 "removed": []
823 823 },
824 824 "updated_commits": {
825 825 "added": [
826 826 "<sha1_hash>"
827 827 ],
828 828 "common": [
829 829 "<sha1_hash>",
830 830 "<sha1_hash>",
831 831 ],
832 832 "removed": []
833 833 }
834 834 }
835 835 error : null
836 836 """
837 837
838 838 pull_request = get_pull_request_or_error(pullrequestid)
839 839 if Optional.extract(repoid):
840 840 repo = get_repo_or_error(repoid)
841 841 else:
842 842 repo = pull_request.target_repo
843 843
844 844 if not PullRequestModel().check_user_update(
845 845 pull_request, apiuser, api=True):
846 846 raise JSONRPCError(
847 847 'pull request `%s` update failed, no permission to update.' % (
848 848 pullrequestid,))
849 849 if pull_request.is_closed():
850 850 raise JSONRPCError(
851 851 'pull request `%s` update failed, pull request is closed' % (
852 852 pullrequestid,))
853 853
854 854 reviewer_objects = Optional.extract(reviewers) or []
855 855
856 856 if reviewer_objects:
857 857 schema = ReviewerListSchema()
858 858 try:
859 859 reviewer_objects = schema.deserialize(reviewer_objects)
860 860 except Invalid as err:
861 861 raise JSONRPCValidationError(colander_exc=err)
862 862
863 863 # validate users
864 864 for reviewer_object in reviewer_objects:
865 865 user = get_user_or_error(reviewer_object['username'])
866 866 reviewer_object['user_id'] = user.user_id
867 867
868 get_default_reviewers_data, get_validated_reviewers = \
868 get_default_reviewers_data, get_validated_reviewers, validate_observers = \
869 869 PullRequestModel().get_reviewer_functions()
870 870
871 871 # re-use stored rules
872 872 reviewer_rules = pull_request.reviewer_data
873 873 try:
874 reviewers = get_validated_reviewers(
875 reviewer_objects, reviewer_rules)
874 reviewers = get_validated_reviewers(reviewer_objects, reviewer_rules)
876 875 except ValueError as e:
877 876 raise JSONRPCError('Reviewers Validation: {}'.format(e))
878 877 else:
879 878 reviewers = []
880 879
881 880 title = Optional.extract(title)
882 881 description = Optional.extract(description)
883 882 description_renderer = Optional.extract(description_renderer)
884 883
885 884 if title or description:
886 885 PullRequestModel().edit(
887 886 pull_request,
888 887 title or pull_request.title,
889 888 description or pull_request.description,
890 889 description_renderer or pull_request.description_renderer,
891 890 apiuser)
892 891 Session().commit()
893 892
894 893 commit_changes = {"added": [], "common": [], "removed": []}
895 894 if str2bool(Optional.extract(update_commits)):
896 895
897 896 if pull_request.pull_request_state != PullRequest.STATE_CREATED:
898 897 raise JSONRPCError(
899 898 'Operation forbidden because pull request is in state {}, '
900 899 'only state {} is allowed.'.format(
901 900 pull_request.pull_request_state, PullRequest.STATE_CREATED))
902 901
903 902 with pull_request.set_state(PullRequest.STATE_UPDATING):
904 903 if PullRequestModel().has_valid_update_type(pull_request):
905 904 db_user = apiuser.get_instance()
906 905 update_response = PullRequestModel().update_commits(
907 906 pull_request, db_user)
908 907 commit_changes = update_response.changes or commit_changes
909 908 Session().commit()
910 909
911 910 reviewers_changes = {"added": [], "removed": []}
912 911 if reviewers:
913 912 old_calculated_status = pull_request.calculated_review_status()
914 913 added_reviewers, removed_reviewers = \
915 914 PullRequestModel().update_reviewers(pull_request, reviewers, apiuser)
916 915
917 916 reviewers_changes['added'] = sorted(
918 917 [get_user_or_error(n).username for n in added_reviewers])
919 918 reviewers_changes['removed'] = sorted(
920 919 [get_user_or_error(n).username for n in removed_reviewers])
921 920 Session().commit()
922 921
923 922 # trigger status changed if change in reviewers changes the status
924 923 calculated_status = pull_request.calculated_review_status()
925 924 if old_calculated_status != calculated_status:
926 925 PullRequestModel().trigger_pull_request_hook(
927 926 pull_request, apiuser, 'review_status_change',
928 927 data={'status': calculated_status})
929 928
930 929 data = {
931 930 'msg': 'Updated pull request `{}`'.format(
932 931 pull_request.pull_request_id),
933 932 'pull_request': pull_request.get_api_data(),
934 933 'updated_commits': commit_changes,
935 934 'updated_reviewers': reviewers_changes
936 935 }
937 936
938 937 return data
939 938
940 939
941 940 @jsonrpc_method()
942 941 def close_pull_request(
943 942 request, apiuser, pullrequestid, repoid=Optional(None),
944 943 userid=Optional(OAttr('apiuser')), message=Optional('')):
945 944 """
946 945 Close the pull request specified by `pullrequestid`.
947 946
948 947 :param apiuser: This is filled automatically from the |authtoken|.
949 948 :type apiuser: AuthUser
950 949 :param repoid: Repository name or repository ID to which the pull
951 950 request belongs.
952 951 :type repoid: str or int
953 952 :param pullrequestid: ID of the pull request to be closed.
954 953 :type pullrequestid: int
955 954 :param userid: Close the pull request as this user.
956 955 :type userid: Optional(str or int)
957 956 :param message: Optional message to close the Pull Request with. If not
958 957 specified it will be generated automatically.
959 958 :type message: Optional(str)
960 959
961 960 Example output:
962 961
963 962 .. code-block:: bash
964 963
965 964 "id": <id_given_in_input>,
966 965 "result": {
967 966 "pull_request_id": "<int>",
968 967 "close_status": "<str:status_lbl>,
969 968 "closed": "<bool>"
970 969 },
971 970 "error": null
972 971
973 972 """
974 973 _ = request.translate
975 974
976 975 pull_request = get_pull_request_or_error(pullrequestid)
977 976 if Optional.extract(repoid):
978 977 repo = get_repo_or_error(repoid)
979 978 else:
980 979 repo = pull_request.target_repo
981 980
982 981 is_repo_admin = HasRepoPermissionAnyApi('repository.admin')(
983 982 user=apiuser, repo_name=repo.repo_name)
984 983 if not isinstance(userid, Optional):
985 984 if has_superadmin_permission(apiuser) or is_repo_admin:
986 985 apiuser = get_user_or_error(userid)
987 986 else:
988 987 raise JSONRPCError('userid is not the same as your user')
989 988
990 989 if pull_request.is_closed():
991 990 raise JSONRPCError(
992 991 'pull request `%s` is already closed' % (pullrequestid,))
993 992
994 993 # only owner or admin or person with write permissions
995 994 allowed_to_close = PullRequestModel().check_user_update(
996 995 pull_request, apiuser, api=True)
997 996
998 997 if not allowed_to_close:
999 998 raise JSONRPCError(
1000 999 'pull request `%s` close failed, no permission to close.' % (
1001 1000 pullrequestid,))
1002 1001
1003 1002 # message we're using to close the PR, else it's automatically generated
1004 1003 message = Optional.extract(message)
1005 1004
1006 1005 # finally close the PR, with proper message comment
1007 1006 comment, status = PullRequestModel().close_pull_request_with_comment(
1008 1007 pull_request, apiuser, repo, message=message, auth_user=apiuser)
1009 1008 status_lbl = ChangesetStatus.get_status_lbl(status)
1010 1009
1011 1010 Session().commit()
1012 1011
1013 1012 data = {
1014 1013 'pull_request_id': pull_request.pull_request_id,
1015 1014 'close_status': status_lbl,
1016 1015 'closed': True,
1017 1016 }
1018 1017 return data
@@ -1,418 +1,486 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
21 21 import os
22 22 import logging
23 23 import datetime
24 24
25 25 from pyramid.view import view_config
26 26 from pyramid.renderers import render_to_response
27 27 from rhodecode.apps._base import BaseAppView
28 28 from rhodecode.lib.celerylib import run_task, tasks
29 29 from rhodecode.lib.utils2 import AttributeDict
30 30 from rhodecode.model.db import User
31 31 from rhodecode.model.notification import EmailNotificationModel
32 32
33 33 log = logging.getLogger(__name__)
34 34
35 35
36 36 class DebugStyleView(BaseAppView):
37
37 38 def load_default_context(self):
38 39 c = self._get_local_tmpl_context()
39 40
40 41 return c
41 42
42 43 @view_config(
43 44 route_name='debug_style_home', request_method='GET',
44 45 renderer=None)
45 46 def index(self):
46 47 c = self.load_default_context()
47 48 c.active = 'index'
48 49
49 50 return render_to_response(
50 51 'debug_style/index.html', self._get_template_context(c),
51 52 request=self.request)
52 53
53 54 @view_config(
54 55 route_name='debug_style_email', request_method='GET',
55 56 renderer=None)
56 57 @view_config(
57 58 route_name='debug_style_email_plain_rendered', request_method='GET',
58 59 renderer=None)
59 60 def render_email(self):
60 61 c = self.load_default_context()
61 62 email_id = self.request.matchdict['email_id']
62 63 c.active = 'emails'
63 64
64 65 pr = AttributeDict(
65 66 pull_request_id=123,
66 67 title='digital_ocean: fix redis, elastic search start on boot, '
67 68 'fix fd limits on supervisor, set postgres 11 version',
68 69 description='''
69 70 Check if we should use full-topic or mini-topic.
70 71
71 72 - full topic produces some problems with merge states etc
72 73 - server-mini-topic needs probably tweeks.
73 74 ''',
74 75 repo_name='foobar',
75 76 source_ref_parts=AttributeDict(type='branch', name='fix-ticket-2000'),
76 77 target_ref_parts=AttributeDict(type='branch', name='master'),
77 78 )
79
78 80 target_repo = AttributeDict(repo_name='repo_group/target_repo')
79 81 source_repo = AttributeDict(repo_name='repo_group/source_repo')
80 82 user = User.get_by_username(self.request.GET.get('user')) or self._rhodecode_db_user
81 83 # file/commit changes for PR update
82 84 commit_changes = AttributeDict({
83 85 'added': ['aaaaaaabbbbb', 'cccccccddddddd'],
84 86 'removed': ['eeeeeeeeeee'],
85 87 })
88
86 89 file_changes = AttributeDict({
87 90 'added': ['a/file1.md', 'file2.py'],
88 91 'modified': ['b/modified_file.rst'],
89 92 'removed': ['.idea'],
90 93 })
91 94
92 95 exc_traceback = {
93 96 'exc_utc_date': '2020-03-26T12:54:50.683281',
94 97 'exc_id': 139638856342656,
95 98 'exc_timestamp': '1585227290.683288',
96 99 'version': 'v1',
97 100 'exc_message': 'Traceback (most recent call last):\n File "/nix/store/s43k2r9rysfbzmsjdqnxgzvvb7zjhkxb-python2.7-pyramid-1.10.4/lib/python2.7/site-packages/pyramid/tweens.py", line 41, in excview_tween\n response = handler(request)\n File "/nix/store/s43k2r9rysfbzmsjdqnxgzvvb7zjhkxb-python2.7-pyramid-1.10.4/lib/python2.7/site-packages/pyramid/router.py", line 148, in handle_request\n registry, request, context, context_iface, view_name\n File "/nix/store/s43k2r9rysfbzmsjdqnxgzvvb7zjhkxb-python2.7-pyramid-1.10.4/lib/python2.7/site-packages/pyramid/view.py", line 667, in _call_view\n response = view_callable(context, request)\n File "/nix/store/s43k2r9rysfbzmsjdqnxgzvvb7zjhkxb-python2.7-pyramid-1.10.4/lib/python2.7/site-packages/pyramid/config/views.py", line 188, in attr_view\n return view(context, request)\n File "/nix/store/s43k2r9rysfbzmsjdqnxgzvvb7zjhkxb-python2.7-pyramid-1.10.4/lib/python2.7/site-packages/pyramid/config/views.py", line 214, in predicate_wrapper\n return view(context, request)\n File "/nix/store/s43k2r9rysfbzmsjdqnxgzvvb7zjhkxb-python2.7-pyramid-1.10.4/lib/python2.7/site-packages/pyramid/viewderivers.py", line 401, in viewresult_to_response\n result = view(context, request)\n File "/nix/store/s43k2r9rysfbzmsjdqnxgzvvb7zjhkxb-python2.7-pyramid-1.10.4/lib/python2.7/site-packages/pyramid/viewderivers.py", line 132, in _class_view\n response = getattr(inst, attr)()\n File "/mnt/hgfs/marcink/workspace/rhodecode-enterprise-ce/rhodecode/apps/debug_style/views.py", line 355, in render_email\n template_type, **email_kwargs.get(email_id, {}))\n File "/mnt/hgfs/marcink/workspace/rhodecode-enterprise-ce/rhodecode/model/notification.py", line 402, in render_email\n body = email_template.render(None, **_kwargs)\n File "/mnt/hgfs/marcink/workspace/rhodecode-enterprise-ce/rhodecode/lib/partial_renderer.py", line 95, in render\n return self._render_with_exc(tmpl, args, kwargs)\n File "/mnt/hgfs/marcink/workspace/rhodecode-enterprise-ce/rhodecode/lib/partial_renderer.py", line 79, in _render_with_exc\n return render_func.render(*args, **kwargs)\n File "/nix/store/dakh34sxz4yfr435c0cwjz0sd6hnd5g3-python2.7-mako-1.1.0/lib/python2.7/site-packages/mako/template.py", line 476, in render\n return runtime._render(self, self.callable_, args, data)\n File "/nix/store/dakh34sxz4yfr435c0cwjz0sd6hnd5g3-python2.7-mako-1.1.0/lib/python2.7/site-packages/mako/runtime.py", line 883, in _render\n **_kwargs_for_callable(callable_, data)\n File "/nix/store/dakh34sxz4yfr435c0cwjz0sd6hnd5g3-python2.7-mako-1.1.0/lib/python2.7/site-packages/mako/runtime.py", line 920, in _render_context\n _exec_template(inherit, lclcontext, args=args, kwargs=kwargs)\n File "/nix/store/dakh34sxz4yfr435c0cwjz0sd6hnd5g3-python2.7-mako-1.1.0/lib/python2.7/site-packages/mako/runtime.py", line 947, in _exec_template\n callable_(context, *args, **kwargs)\n File "rhodecode_templates_email_templates_base_mako", line 63, in render_body\n File "rhodecode_templates_email_templates_exception_tracker_mako", line 43, in render_body\nAttributeError: \'str\' object has no attribute \'get\'\n',
98 101 'exc_type': 'AttributeError'
99 102 }
103
100 104 email_kwargs = {
101 105 'test': {},
106
102 107 'message': {
103 108 'body': 'message body !'
104 109 },
110
105 111 'email_test': {
106 112 'user': user,
107 113 'date': datetime.datetime.now(),
108 114 },
115
109 116 'exception': {
110 117 'email_prefix': '[RHODECODE ERROR]',
111 118 'exc_id': exc_traceback['exc_id'],
112 119 'exc_url': 'http://server-url/{}'.format(exc_traceback['exc_id']),
113 120 'exc_type_name': 'NameError',
114 121 'exc_traceback': exc_traceback,
115 122 },
123
116 124 'password_reset': {
117 125 'password_reset_url': 'http://example.com/reset-rhodecode-password/token',
118 126
119 127 'user': user,
120 128 'date': datetime.datetime.now(),
121 129 'email': 'test@rhodecode.com',
122 130 'first_admin_email': User.get_first_super_admin().email
123 131 },
132
124 133 'password_reset_confirmation': {
125 134 'new_password': 'new-password-example',
126 135 'user': user,
127 136 'date': datetime.datetime.now(),
128 137 'email': 'test@rhodecode.com',
129 138 'first_admin_email': User.get_first_super_admin().email
130 139 },
140
131 141 'registration': {
132 142 'user': user,
133 143 'date': datetime.datetime.now(),
134 144 },
135 145
136 146 'pull_request_comment': {
137 147 'user': user,
138 148
139 149 'status_change': None,
140 150 'status_change_type': None,
141 151
142 152 'pull_request': pr,
143 153 'pull_request_commits': [],
144 154
145 155 'pull_request_target_repo': target_repo,
146 156 'pull_request_target_repo_url': 'http://target-repo/url',
147 157
148 158 'pull_request_source_repo': source_repo,
149 159 'pull_request_source_repo_url': 'http://source-repo/url',
150 160
151 161 'pull_request_url': 'http://localhost/pr1',
152 162 'pr_comment_url': 'http://comment-url',
153 163 'pr_comment_reply_url': 'http://comment-url#reply',
154 164
155 165 'comment_file': None,
156 166 'comment_line': None,
157 167 'comment_type': 'note',
158 168 'comment_body': 'This is my comment body. *I like !*',
159 169 'comment_id': 2048,
160 170 'renderer_type': 'markdown',
161 171 'mention': True,
162 172
163 173 },
174
164 175 'pull_request_comment+status': {
165 176 'user': user,
166 177
167 178 'status_change': 'approved',
168 179 'status_change_type': 'approved',
169 180
170 181 'pull_request': pr,
171 182 'pull_request_commits': [],
172 183
173 184 'pull_request_target_repo': target_repo,
174 185 'pull_request_target_repo_url': 'http://target-repo/url',
175 186
176 187 'pull_request_source_repo': source_repo,
177 188 'pull_request_source_repo_url': 'http://source-repo/url',
178 189
179 190 'pull_request_url': 'http://localhost/pr1',
180 191 'pr_comment_url': 'http://comment-url',
181 192 'pr_comment_reply_url': 'http://comment-url#reply',
182 193
183 194 'comment_type': 'todo',
184 195 'comment_file': None,
185 196 'comment_line': None,
186 197 'comment_body': '''
187 198 I think something like this would be better
188 199
189 200 ```py
190 201 // markdown renderer
191 202
192 203 def db():
193 204 global connection
194 205 return connection
195 206
196 207 ```
197 208
198 209 ''',
199 210 'comment_id': 2048,
200 211 'renderer_type': 'markdown',
201 212 'mention': True,
202 213
203 214 },
215
204 216 'pull_request_comment+file': {
205 217 'user': user,
206 218
207 219 'status_change': None,
208 220 'status_change_type': None,
209 221
210 222 'pull_request': pr,
211 223 'pull_request_commits': [],
212 224
213 225 'pull_request_target_repo': target_repo,
214 226 'pull_request_target_repo_url': 'http://target-repo/url',
215 227
216 228 'pull_request_source_repo': source_repo,
217 229 'pull_request_source_repo_url': 'http://source-repo/url',
218 230
219 231 'pull_request_url': 'http://localhost/pr1',
220 232
221 233 'pr_comment_url': 'http://comment-url',
222 234 'pr_comment_reply_url': 'http://comment-url#reply',
223 235
224 236 'comment_file': 'rhodecode/model/get_flow_commits',
225 237 'comment_line': 'o1210',
226 238 'comment_type': 'todo',
227 239 'comment_body': '''
228 240 I like this !
229 241
230 242 But please check this code
231 243
232 244 .. code-block:: javascript
233 245
234 246 // THIS IS RST CODE
235 247
236 248 this.createResolutionComment = function(commentId) {
237 249 // hide the trigger text
238 250 $('#resolve-comment-{0}'.format(commentId)).hide();
239 251
240 252 var comment = $('#comment-'+commentId);
241 253 var commentData = comment.data();
242 254 if (commentData.commentInline) {
243 255 this.createComment(comment, commentId)
244 256 } else {
245 257 Rhodecode.comments.createGeneralComment('general', "$placeholder", commentId)
246 258 }
247 259
248 260 return false;
249 261 };
250 262
251 263 This should work better !
252 264 ''',
253 265 'comment_id': 2048,
254 266 'renderer_type': 'rst',
255 267 'mention': True,
256 268
257 269 },
258 270
259 271 'pull_request_update': {
260 272 'updating_user': user,
261 273
262 274 'status_change': None,
263 275 'status_change_type': None,
264 276
265 277 'pull_request': pr,
266 278 'pull_request_commits': [],
267 279
268 280 'pull_request_target_repo': target_repo,
269 281 'pull_request_target_repo_url': 'http://target-repo/url',
270 282
271 283 'pull_request_source_repo': source_repo,
272 284 'pull_request_source_repo_url': 'http://source-repo/url',
273 285
274 286 'pull_request_url': 'http://localhost/pr1',
275 287
276 288 # update comment links
277 289 'pr_comment_url': 'http://comment-url',
278 290 'pr_comment_reply_url': 'http://comment-url#reply',
279 291 'ancestor_commit_id': 'f39bd443',
280 292 'added_commits': commit_changes.added,
281 293 'removed_commits': commit_changes.removed,
282 294 'changed_files': (file_changes.added + file_changes.modified + file_changes.removed),
283 295 'added_files': file_changes.added,
284 296 'modified_files': file_changes.modified,
285 297 'removed_files': file_changes.removed,
286 298 },
287 299
288 300 'cs_comment': {
289 301 'user': user,
290 302 'commit': AttributeDict(idx=123, raw_id='a'*40, message='Commit message'),
291 303 'status_change': None,
292 304 'status_change_type': None,
293 305
294 306 'commit_target_repo_url': 'http://foo.example.com/#comment1',
295 307 'repo_name': 'test-repo',
296 308 'comment_type': 'note',
297 309 'comment_file': None,
298 310 'comment_line': None,
299 311 'commit_comment_url': 'http://comment-url',
300 312 'commit_comment_reply_url': 'http://comment-url#reply',
301 313 'comment_body': 'This is my comment body. *I like !*',
302 314 'comment_id': 2048,
303 315 'renderer_type': 'markdown',
304 316 'mention': True,
305 317 },
318
306 319 'cs_comment+status': {
307 320 'user': user,
308 321 'commit': AttributeDict(idx=123, raw_id='a' * 40, message='Commit message'),
309 322 'status_change': 'approved',
310 323 'status_change_type': 'approved',
311 324
312 325 'commit_target_repo_url': 'http://foo.example.com/#comment1',
313 326 'repo_name': 'test-repo',
314 327 'comment_type': 'note',
315 328 'comment_file': None,
316 329 'comment_line': None,
317 330 'commit_comment_url': 'http://comment-url',
318 331 'commit_comment_reply_url': 'http://comment-url#reply',
319 332 'comment_body': '''
320 333 Hello **world**
321 334
322 335 This is a multiline comment :)
323 336
324 337 - list
325 338 - list2
326 339 ''',
327 340 'comment_id': 2048,
328 341 'renderer_type': 'markdown',
329 342 'mention': True,
330 343 },
344
331 345 'cs_comment+file': {
332 346 'user': user,
333 347 'commit': AttributeDict(idx=123, raw_id='a' * 40, message='Commit message'),
334 348 'status_change': None,
335 349 'status_change_type': None,
336 350
337 351 'commit_target_repo_url': 'http://foo.example.com/#comment1',
338 352 'repo_name': 'test-repo',
339 353
340 354 'comment_type': 'note',
341 355 'comment_file': 'test-file.py',
342 356 'comment_line': 'n100',
343 357
344 358 'commit_comment_url': 'http://comment-url',
345 359 'commit_comment_reply_url': 'http://comment-url#reply',
346 360 'comment_body': 'This is my comment body. *I like !*',
347 361 'comment_id': 2048,
348 362 'renderer_type': 'markdown',
349 363 'mention': True,
350 364 },
351 365
352 366 'pull_request': {
353 367 'user': user,
354 368 'pull_request': pr,
355 369 'pull_request_commits': [
356 370 ('472d1df03bf7206e278fcedc6ac92b46b01c4e21', '''\
357 371 my-account: moved email closer to profile as it's similar data just moved outside.
358 372 '''),
359 373 ('cbfa3061b6de2696c7161ed15ba5c6a0045f90a7', '''\
360 374 users: description edit fixes
361 375
362 376 - tests
363 377 - added metatags info
364 378 '''),
365 379 ],
366 380
367 381 'pull_request_target_repo': target_repo,
368 382 'pull_request_target_repo_url': 'http://target-repo/url',
369 383
370 384 'pull_request_source_repo': source_repo,
371 385 'pull_request_source_repo_url': 'http://source-repo/url',
372 386
373 387 'pull_request_url': 'http://code.rhodecode.com/_pull-request/123',
388 'user_role': 'reviewer',
389 },
390
391 'pull_request+reviewer_role': {
392 'user': user,
393 'pull_request': pr,
394 'pull_request_commits': [
395 ('472d1df03bf7206e278fcedc6ac92b46b01c4e21', '''\
396 my-account: moved email closer to profile as it's similar data just moved outside.
397 '''),
398 ('cbfa3061b6de2696c7161ed15ba5c6a0045f90a7', '''\
399 users: description edit fixes
400
401 - tests
402 - added metatags info
403 '''),
404 ],
405
406 'pull_request_target_repo': target_repo,
407 'pull_request_target_repo_url': 'http://target-repo/url',
408
409 'pull_request_source_repo': source_repo,
410 'pull_request_source_repo_url': 'http://source-repo/url',
411
412 'pull_request_url': 'http://code.rhodecode.com/_pull-request/123',
413 'user_role': 'reviewer',
414 },
415
416 'pull_request+observer_role': {
417 'user': user,
418 'pull_request': pr,
419 'pull_request_commits': [
420 ('472d1df03bf7206e278fcedc6ac92b46b01c4e21', '''\
421 my-account: moved email closer to profile as it's similar data just moved outside.
422 '''),
423 ('cbfa3061b6de2696c7161ed15ba5c6a0045f90a7', '''\
424 users: description edit fixes
425
426 - tests
427 - added metatags info
428 '''),
429 ],
430
431 'pull_request_target_repo': target_repo,
432 'pull_request_target_repo_url': 'http://target-repo/url',
433
434 'pull_request_source_repo': source_repo,
435 'pull_request_source_repo_url': 'http://source-repo/url',
436
437 'pull_request_url': 'http://code.rhodecode.com/_pull-request/123',
438 'user_role': 'observer'
374 439 }
375
376 440 }
377 441
378 442 template_type = email_id.split('+')[0]
379 443 (c.subject, c.email_body, c.email_body_plaintext) = EmailNotificationModel().render_email(
380 444 template_type, **email_kwargs.get(email_id, {}))
381 445
382 446 test_email = self.request.GET.get('email')
383 447 if test_email:
384 448 recipients = [test_email]
385 449 run_task(tasks.send_email, recipients, c.subject,
386 450 c.email_body_plaintext, c.email_body)
387 451
388 452 if self.request.matched_route.name == 'debug_style_email_plain_rendered':
389 453 template = 'debug_style/email_plain_rendered.mako'
390 454 else:
391 455 template = 'debug_style/email.mako'
392 456 return render_to_response(
393 457 template, self._get_template_context(c),
394 458 request=self.request)
395 459
396 460 @view_config(
397 461 route_name='debug_style_template', request_method='GET',
398 462 renderer=None)
399 463 def template(self):
400 464 t_path = self.request.matchdict['t_path']
401 465 c = self.load_default_context()
402 466 c.active = os.path.splitext(t_path)[0]
403 467 c.came_from = ''
468 # NOTE(marcink): extend the email types with variations based on data sets
404 469 c.email_types = {
405 470 'cs_comment+file': {},
406 471 'cs_comment+status': {},
407 472
408 473 'pull_request_comment+file': {},
409 474 'pull_request_comment+status': {},
410 475
411 476 'pull_request_update': {},
477
478 'pull_request+reviewer_role': {},
479 'pull_request+observer_role': {},
412 480 }
413 481 c.email_types.update(EmailNotificationModel.email_types)
414 482
415 483 return render_to_response(
416 484 'debug_style/' + t_path, self._get_template_context(c),
417 485 request=self.request)
418 486
@@ -1,87 +1,95 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
21 21 from rhodecode.lib import helpers as h
22 22 from rhodecode.lib.utils2 import safe_int
23 23 from rhodecode.model.pull_request import get_diff_info
24
25 REVIEWER_API_VERSION = 'V3'
24 from rhodecode.model.db import PullRequestReviewers
25 # V3 - Reviewers, with default rules data
26 # v4 - Added observers metadata
27 REVIEWER_API_VERSION = 'V4'
26 28
27 29
28 def reviewer_as_json(user, reasons=None, mandatory=False, rules=None, user_group=None):
30 def reviewer_as_json(user, reasons=None, role=None, mandatory=False, rules=None, user_group=None):
29 31 """
30 32 Returns json struct of a reviewer for frontend
31 33
32 34 :param user: the reviewer
33 35 :param reasons: list of strings of why they are reviewers
34 36 :param mandatory: bool, to set user as mandatory
35 37 """
38 role = role or PullRequestReviewers.ROLE_REVIEWER
39 if role not in PullRequestReviewers.ROLES:
40 raise ValueError('role is not one of %s', PullRequestReviewers.ROLES)
36 41
37 42 return {
38 43 'user_id': user.user_id,
39 44 'reasons': reasons or [],
40 45 'rules': rules or [],
46 'role': role,
41 47 'mandatory': mandatory,
42 48 'user_group': user_group,
43 49 'username': user.username,
44 50 'first_name': user.first_name,
45 51 'last_name': user.last_name,
46 52 'user_link': h.link_to_user(user),
47 53 'gravatar_link': h.gravatar_url(user.email, 14),
48 54 }
49 55
50 56
51 def get_default_reviewers_data(
52 current_user, source_repo, source_commit, target_repo, target_commit):
57 def get_default_reviewers_data(current_user, source_repo, source_commit, target_repo, target_commit):
53 58 """
54 59 Return json for default reviewers of a repository
55 60 """
56 61
57 62 diff_info = get_diff_info(
58 63 source_repo, source_commit.raw_id, target_repo, target_commit.raw_id)
59 64
60 65 reasons = ['Default reviewer', 'Repository owner']
61 66 json_reviewers = [reviewer_as_json(
62 user=target_repo.user, reasons=reasons, mandatory=False, rules=None)]
67 user=target_repo.user, reasons=reasons, mandatory=False, rules=None, role=None)]
63 68
64 69 return {
65 70 'api_ver': REVIEWER_API_VERSION, # define version for later possible schema upgrade
66 71 'diff_info': diff_info,
67 72 'reviewers': json_reviewers,
68 73 'rules': {},
69 74 'rules_data': {},
70 75 }
71 76
72 77
73 78 def validate_default_reviewers(review_members, reviewer_rules):
74 79 """
75 80 Function to validate submitted reviewers against the saved rules
76
77 81 """
78 82 reviewers = []
79 83 reviewer_by_id = {}
80 84 for r in review_members:
81 85 reviewer_user_id = safe_int(r['user_id'])
82 entry = (reviewer_user_id, r['reasons'], r['mandatory'], r['rules'])
86 entry = (reviewer_user_id, r['reasons'], r['mandatory'], r['role'], r['rules'])
83 87
84 88 reviewer_by_id[reviewer_user_id] = entry
85 89 reviewers.append(entry)
86 90
87 91 return reviewers
92
93
94 def validate_observers(observer_members):
95 return {}
@@ -1,781 +1,781 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import logging
22 22 import collections
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 from rhodecode.lib.ext_json import json
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, StrictAttributeDict
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 # get ranges of commit ids if preset
91 91 commit_range = commit_id_range.split('...')[:2]
92 92
93 93 try:
94 94 pre_load = ['affected_files', 'author', 'branch', 'date',
95 95 'message', 'parents']
96 96 if self.rhodecode_vcs_repo.alias == 'hg':
97 97 pre_load += ['hidden', 'obsolete', 'phase']
98 98
99 99 if len(commit_range) == 2:
100 100 commits = self.rhodecode_vcs_repo.get_commits(
101 101 start_id=commit_range[0], end_id=commit_range[1],
102 102 pre_load=pre_load, translate_tags=False)
103 103 commits = list(commits)
104 104 else:
105 105 commits = [self.rhodecode_vcs_repo.get_commit(
106 106 commit_id=commit_id_range, pre_load=pre_load)]
107 107
108 108 c.commit_ranges = commits
109 109 if not c.commit_ranges:
110 110 raise RepositoryError('The commit range returned an empty result')
111 111 except CommitDoesNotExistError as e:
112 112 msg = _('No such commit exists. Org exception: `{}`').format(e)
113 113 h.flash(msg, category='error')
114 114 raise HTTPNotFound()
115 115 except Exception:
116 116 log.exception("General failure")
117 117 raise HTTPNotFound()
118 118 single_commit = len(c.commit_ranges) == 1
119 119
120 120 c.changes = OrderedDict()
121 121 c.lines_added = 0
122 122 c.lines_deleted = 0
123 123
124 124 # auto collapse if we have more than limit
125 125 collapse_limit = diffs.DiffProcessor._collapse_commits_over
126 126 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
127 127
128 128 c.commit_statuses = ChangesetStatus.STATUSES
129 129 c.inline_comments = []
130 130 c.files = []
131 131
132 132 c.comments = []
133 133 c.unresolved_comments = []
134 134 c.resolved_comments = []
135 135
136 136 # Single commit
137 137 if single_commit:
138 138 commit = c.commit_ranges[0]
139 139 c.comments = CommentsModel().get_comments(
140 140 self.db_repo.repo_id,
141 141 revision=commit.raw_id)
142 142
143 143 # comments from PR
144 144 statuses = ChangesetStatusModel().get_statuses(
145 145 self.db_repo.repo_id, commit.raw_id,
146 146 with_revisions=True)
147 147
148 148 prs = set()
149 149 reviewers = list()
150 150 reviewers_duplicates = set() # to not have duplicates from multiple votes
151 151 for c_status in statuses:
152 152
153 153 # extract associated pull-requests from votes
154 154 if c_status.pull_request:
155 155 prs.add(c_status.pull_request)
156 156
157 157 # extract reviewers
158 158 _user_id = c_status.author.user_id
159 159 if _user_id not in reviewers_duplicates:
160 160 reviewers.append(
161 161 StrictAttributeDict({
162 162 'user': c_status.author,
163 163
164 164 # fake attributed for commit, page that we don't have
165 165 # but we share the display with PR page
166 166 'mandatory': False,
167 167 'reasons': [],
168 168 'rule_user_group_data': lambda: None
169 169 })
170 170 )
171 171 reviewers_duplicates.add(_user_id)
172 172
173 173 c.allowed_reviewers = reviewers
174 174 # from associated statuses, check the pull requests, and
175 175 # show comments from them
176 176 for pr in prs:
177 177 c.comments.extend(pr.comments)
178 178
179 179 c.unresolved_comments = CommentsModel()\
180 180 .get_commit_unresolved_todos(commit.raw_id)
181 181 c.resolved_comments = CommentsModel()\
182 182 .get_commit_resolved_todos(commit.raw_id)
183 183
184 184 c.inline_comments_flat = CommentsModel()\
185 185 .get_commit_inline_comments(commit.raw_id)
186 186
187 187 review_statuses = ChangesetStatusModel().aggregate_votes_by_user(
188 188 statuses, reviewers)
189 189
190 190 c.commit_review_status = ChangesetStatus.STATUS_NOT_REVIEWED
191 191
192 192 c.commit_set_reviewers_data_json = collections.OrderedDict({'reviewers': []})
193 193
194 194 for review_obj, member, reasons, mandatory, status in review_statuses:
195 195 member_reviewer = h.reviewer_as_json(
196 member, reasons=reasons, mandatory=mandatory,
196 member, reasons=reasons, mandatory=mandatory, role=None,
197 197 user_group=None
198 198 )
199 199
200 200 current_review_status = status[0][1].status if status else ChangesetStatus.STATUS_NOT_REVIEWED
201 201 member_reviewer['review_status'] = current_review_status
202 202 member_reviewer['review_status_label'] = h.commit_status_lbl(current_review_status)
203 203 member_reviewer['allowed_to_update'] = False
204 204 c.commit_set_reviewers_data_json['reviewers'].append(member_reviewer)
205 205
206 206 c.commit_set_reviewers_data_json = json.dumps(c.commit_set_reviewers_data_json)
207 207
208 208 # NOTE(marcink): this uses the same voting logic as in pull-requests
209 209 c.commit_review_status = ChangesetStatusModel().calculate_status(review_statuses)
210 210 c.commit_broadcast_channel = u'/repo${}$/commit/{}'.format(
211 211 c.repo_name,
212 212 commit.raw_id
213 213 )
214 214
215 215 diff = None
216 216 # Iterate over ranges (default commit view is always one commit)
217 217 for commit in c.commit_ranges:
218 218 c.changes[commit.raw_id] = []
219 219
220 220 commit2 = commit
221 221 commit1 = commit.first_parent
222 222
223 223 if method == 'show':
224 224 inline_comments = CommentsModel().get_inline_comments(
225 225 self.db_repo.repo_id, revision=commit.raw_id)
226 226 c.inline_cnt = len(CommentsModel().get_inline_comments_as_list(
227 227 inline_comments))
228 228 c.inline_comments = inline_comments
229 229
230 230 cache_path = self.rhodecode_vcs_repo.get_create_shadow_cache_pr_path(
231 231 self.db_repo)
232 232 cache_file_path = diff_cache_exist(
233 233 cache_path, 'diff', commit.raw_id,
234 234 hide_whitespace_changes, diff_context, c.fulldiff)
235 235
236 236 caching_enabled = self._is_diff_cache_enabled(self.db_repo)
237 237 force_recache = str2bool(self.request.GET.get('force_recache'))
238 238
239 239 cached_diff = None
240 240 if caching_enabled:
241 241 cached_diff = load_cached_diff(cache_file_path)
242 242
243 243 has_proper_diff_cache = cached_diff and cached_diff.get('diff')
244 244 if not force_recache and has_proper_diff_cache:
245 245 diffset = cached_diff['diff']
246 246 else:
247 247 vcs_diff = self.rhodecode_vcs_repo.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=c.fulldiff)
255 255
256 256 _parsed = diff_processor.prepare()
257 257
258 258 diffset = codeblocks.DiffSet(
259 259 repo_name=self.db_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 # save cached diff
267 267 if caching_enabled:
268 268 cache_diff(cache_file_path, diffset, None)
269 269
270 270 c.limited_diff = diffset.limited_diff
271 271 c.changes[commit.raw_id] = diffset
272 272 else:
273 273 # TODO(marcink): no cache usage here...
274 274 _diff = self.rhodecode_vcs_repo.get_diff(
275 275 commit1, commit2,
276 276 ignore_whitespace=hide_whitespace_changes, context=diff_context)
277 277 diff_processor = diffs.DiffProcessor(
278 278 _diff, format='newdiff', diff_limit=diff_limit,
279 279 file_limit=file_limit, show_full_diff=c.fulldiff)
280 280 # downloads/raw we only need RAW diff nothing else
281 281 diff = self.path_filter.get_raw_patch(diff_processor)
282 282 c.changes[commit.raw_id] = [None, None, None, None, diff, None, None]
283 283
284 284 # sort comments by how they were generated
285 285 c.comments = sorted(c.comments, key=lambda x: x.comment_id)
286 286 c.at_version_num = None
287 287
288 288 if len(c.commit_ranges) == 1:
289 289 c.commit = c.commit_ranges[0]
290 290 c.parent_tmpl = ''.join(
291 291 '# Parent %s\n' % x.raw_id for x in c.commit.parents)
292 292
293 293 if method == 'download':
294 294 response = Response(diff)
295 295 response.content_type = 'text/plain'
296 296 response.content_disposition = (
297 297 'attachment; filename=%s.diff' % commit_id_range[:12])
298 298 return response
299 299 elif method == 'patch':
300 300 c.diff = safe_unicode(diff)
301 301 patch = render(
302 302 'rhodecode:templates/changeset/patch_changeset.mako',
303 303 self._get_template_context(c), self.request)
304 304 response = Response(patch)
305 305 response.content_type = 'text/plain'
306 306 return response
307 307 elif method == 'raw':
308 308 response = Response(diff)
309 309 response.content_type = 'text/plain'
310 310 return response
311 311 elif method == 'show':
312 312 if len(c.commit_ranges) == 1:
313 313 html = render(
314 314 'rhodecode:templates/changeset/changeset.mako',
315 315 self._get_template_context(c), self.request)
316 316 return Response(html)
317 317 else:
318 318 c.ancestor = None
319 319 c.target_repo = self.db_repo
320 320 html = render(
321 321 'rhodecode:templates/changeset/changeset_range.mako',
322 322 self._get_template_context(c), self.request)
323 323 return Response(html)
324 324
325 325 raise HTTPBadRequest()
326 326
327 327 @LoginRequired()
328 328 @HasRepoPermissionAnyDecorator(
329 329 'repository.read', 'repository.write', 'repository.admin')
330 330 @view_config(
331 331 route_name='repo_commit', request_method='GET',
332 332 renderer=None)
333 333 def repo_commit_show(self):
334 334 commit_id = self.request.matchdict['commit_id']
335 335 return self._commit(commit_id, method='show')
336 336
337 337 @LoginRequired()
338 338 @HasRepoPermissionAnyDecorator(
339 339 'repository.read', 'repository.write', 'repository.admin')
340 340 @view_config(
341 341 route_name='repo_commit_raw', request_method='GET',
342 342 renderer=None)
343 343 @view_config(
344 344 route_name='repo_commit_raw_deprecated', request_method='GET',
345 345 renderer=None)
346 346 def repo_commit_raw(self):
347 347 commit_id = self.request.matchdict['commit_id']
348 348 return self._commit(commit_id, method='raw')
349 349
350 350 @LoginRequired()
351 351 @HasRepoPermissionAnyDecorator(
352 352 'repository.read', 'repository.write', 'repository.admin')
353 353 @view_config(
354 354 route_name='repo_commit_patch', request_method='GET',
355 355 renderer=None)
356 356 def repo_commit_patch(self):
357 357 commit_id = self.request.matchdict['commit_id']
358 358 return self._commit(commit_id, method='patch')
359 359
360 360 @LoginRequired()
361 361 @HasRepoPermissionAnyDecorator(
362 362 'repository.read', 'repository.write', 'repository.admin')
363 363 @view_config(
364 364 route_name='repo_commit_download', request_method='GET',
365 365 renderer=None)
366 366 def repo_commit_download(self):
367 367 commit_id = self.request.matchdict['commit_id']
368 368 return self._commit(commit_id, method='download')
369 369
370 370 @LoginRequired()
371 371 @NotAnonymous()
372 372 @HasRepoPermissionAnyDecorator(
373 373 'repository.read', 'repository.write', 'repository.admin')
374 374 @CSRFRequired()
375 375 @view_config(
376 376 route_name='repo_commit_comment_create', request_method='POST',
377 377 renderer='json_ext')
378 378 def repo_commit_comment_create(self):
379 379 _ = self.request.translate
380 380 commit_id = self.request.matchdict['commit_id']
381 381
382 382 c = self.load_default_context()
383 383 status = self.request.POST.get('changeset_status', None)
384 384 text = self.request.POST.get('text')
385 385 comment_type = self.request.POST.get('comment_type')
386 386 resolves_comment_id = self.request.POST.get('resolves_comment_id', None)
387 387
388 388 if status:
389 389 text = text or (_('Status change %(transition_icon)s %(status)s')
390 390 % {'transition_icon': '>',
391 391 'status': ChangesetStatus.get_status_lbl(status)})
392 392
393 393 multi_commit_ids = []
394 394 for _commit_id in self.request.POST.get('commit_ids', '').split(','):
395 395 if _commit_id not in ['', None, EmptyCommit.raw_id]:
396 396 if _commit_id not in multi_commit_ids:
397 397 multi_commit_ids.append(_commit_id)
398 398
399 399 commit_ids = multi_commit_ids or [commit_id]
400 400
401 401 comment = None
402 402 for current_id in filter(None, commit_ids):
403 403 comment = CommentsModel().create(
404 404 text=text,
405 405 repo=self.db_repo.repo_id,
406 406 user=self._rhodecode_db_user.user_id,
407 407 commit_id=current_id,
408 408 f_path=self.request.POST.get('f_path'),
409 409 line_no=self.request.POST.get('line'),
410 410 status_change=(ChangesetStatus.get_status_lbl(status)
411 411 if status else None),
412 412 status_change_type=status,
413 413 comment_type=comment_type,
414 414 resolves_comment_id=resolves_comment_id,
415 415 auth_user=self._rhodecode_user
416 416 )
417 417
418 418 # get status if set !
419 419 if status:
420 420 # if latest status was from pull request and it's closed
421 421 # disallow changing status !
422 422 # dont_allow_on_closed_pull_request = True !
423 423
424 424 try:
425 425 ChangesetStatusModel().set_status(
426 426 self.db_repo.repo_id,
427 427 status,
428 428 self._rhodecode_db_user.user_id,
429 429 comment,
430 430 revision=current_id,
431 431 dont_allow_on_closed_pull_request=True
432 432 )
433 433 except StatusChangeOnClosedPullRequestError:
434 434 msg = _('Changing the status of a commit associated with '
435 435 'a closed pull request is not allowed')
436 436 log.exception(msg)
437 437 h.flash(msg, category='warning')
438 438 raise HTTPFound(h.route_path(
439 439 'repo_commit', repo_name=self.db_repo_name,
440 440 commit_id=current_id))
441 441
442 442 commit = self.db_repo.get_commit(current_id)
443 443 CommentsModel().trigger_commit_comment_hook(
444 444 self.db_repo, self._rhodecode_user, 'create',
445 445 data={'comment': comment, 'commit': commit})
446 446
447 447 # finalize, commit and redirect
448 448 Session().commit()
449 449
450 450 data = {
451 451 'target_id': h.safeid(h.safe_unicode(
452 452 self.request.POST.get('f_path'))),
453 453 }
454 454 if comment:
455 455 c.co = comment
456 456 c.at_version_num = 0
457 457 rendered_comment = render(
458 458 'rhodecode:templates/changeset/changeset_comment_block.mako',
459 459 self._get_template_context(c), self.request)
460 460
461 461 data.update(comment.get_dict())
462 462 data.update({'rendered_text': rendered_comment})
463 463
464 464 return data
465 465
466 466 @LoginRequired()
467 467 @NotAnonymous()
468 468 @HasRepoPermissionAnyDecorator(
469 469 'repository.read', 'repository.write', 'repository.admin')
470 470 @CSRFRequired()
471 471 @view_config(
472 472 route_name='repo_commit_comment_preview', request_method='POST',
473 473 renderer='string', xhr=True)
474 474 def repo_commit_comment_preview(self):
475 475 # Technically a CSRF token is not needed as no state changes with this
476 476 # call. However, as this is a POST is better to have it, so automated
477 477 # tools don't flag it as potential CSRF.
478 478 # Post is required because the payload could be bigger than the maximum
479 479 # allowed by GET.
480 480
481 481 text = self.request.POST.get('text')
482 482 renderer = self.request.POST.get('renderer') or 'rst'
483 483 if text:
484 484 return h.render(text, renderer=renderer, mentions=True,
485 485 repo_name=self.db_repo_name)
486 486 return ''
487 487
488 488 @LoginRequired()
489 489 @HasRepoPermissionAnyDecorator(
490 490 'repository.read', 'repository.write', 'repository.admin')
491 491 @CSRFRequired()
492 492 @view_config(
493 493 route_name='repo_commit_comment_history_view', request_method='POST',
494 494 renderer='string', xhr=True)
495 495 def repo_commit_comment_history_view(self):
496 496 c = self.load_default_context()
497 497
498 498 comment_history_id = self.request.matchdict['comment_history_id']
499 499 comment_history = ChangesetCommentHistory.get_or_404(comment_history_id)
500 500 is_repo_comment = comment_history.comment.repo.repo_id == self.db_repo.repo_id
501 501
502 502 if is_repo_comment:
503 503 c.comment_history = comment_history
504 504
505 505 rendered_comment = render(
506 506 'rhodecode:templates/changeset/comment_history.mako',
507 507 self._get_template_context(c)
508 508 , self.request)
509 509 return rendered_comment
510 510 else:
511 511 log.warning('No permissions for user %s to show comment_history_id: %s',
512 512 self._rhodecode_db_user, comment_history_id)
513 513 raise HTTPNotFound()
514 514
515 515 @LoginRequired()
516 516 @NotAnonymous()
517 517 @HasRepoPermissionAnyDecorator(
518 518 'repository.read', 'repository.write', 'repository.admin')
519 519 @CSRFRequired()
520 520 @view_config(
521 521 route_name='repo_commit_comment_attachment_upload', request_method='POST',
522 522 renderer='json_ext', xhr=True)
523 523 def repo_commit_comment_attachment_upload(self):
524 524 c = self.load_default_context()
525 525 upload_key = 'attachment'
526 526
527 527 file_obj = self.request.POST.get(upload_key)
528 528
529 529 if file_obj is None:
530 530 self.request.response.status = 400
531 531 return {'store_fid': None,
532 532 'access_path': None,
533 533 'error': '{} data field is missing'.format(upload_key)}
534 534
535 535 if not hasattr(file_obj, 'filename'):
536 536 self.request.response.status = 400
537 537 return {'store_fid': None,
538 538 'access_path': None,
539 539 'error': 'filename cannot be read from the data field'}
540 540
541 541 filename = file_obj.filename
542 542 file_display_name = filename
543 543
544 544 metadata = {
545 545 'user_uploaded': {'username': self._rhodecode_user.username,
546 546 'user_id': self._rhodecode_user.user_id,
547 547 'ip': self._rhodecode_user.ip_addr}}
548 548
549 549 # TODO(marcink): allow .ini configuration for allowed_extensions, and file-size
550 550 allowed_extensions = [
551 551 'gif', '.jpeg', '.jpg', '.png', '.docx', '.gz', '.log', '.pdf',
552 552 '.pptx', '.txt', '.xlsx', '.zip']
553 553 max_file_size = 10 * 1024 * 1024 # 10MB, also validated via dropzone.js
554 554
555 555 try:
556 556 storage = store_utils.get_file_storage(self.request.registry.settings)
557 557 store_uid, metadata = storage.save_file(
558 558 file_obj.file, filename, extra_metadata=metadata,
559 559 extensions=allowed_extensions, max_filesize=max_file_size)
560 560 except FileNotAllowedException:
561 561 self.request.response.status = 400
562 562 permitted_extensions = ', '.join(allowed_extensions)
563 563 error_msg = 'File `{}` is not allowed. ' \
564 564 'Only following extensions are permitted: {}'.format(
565 565 filename, permitted_extensions)
566 566 return {'store_fid': None,
567 567 'access_path': None,
568 568 'error': error_msg}
569 569 except FileOverSizeException:
570 570 self.request.response.status = 400
571 571 limit_mb = h.format_byte_size_binary(max_file_size)
572 572 return {'store_fid': None,
573 573 'access_path': None,
574 574 'error': 'File {} is exceeding allowed limit of {}.'.format(
575 575 filename, limit_mb)}
576 576
577 577 try:
578 578 entry = FileStore.create(
579 579 file_uid=store_uid, filename=metadata["filename"],
580 580 file_hash=metadata["sha256"], file_size=metadata["size"],
581 581 file_display_name=file_display_name,
582 582 file_description=u'comment attachment `{}`'.format(safe_unicode(filename)),
583 583 hidden=True, check_acl=True, user_id=self._rhodecode_user.user_id,
584 584 scope_repo_id=self.db_repo.repo_id
585 585 )
586 586 Session().add(entry)
587 587 Session().commit()
588 588 log.debug('Stored upload in DB as %s', entry)
589 589 except Exception:
590 590 log.exception('Failed to store file %s', filename)
591 591 self.request.response.status = 400
592 592 return {'store_fid': None,
593 593 'access_path': None,
594 594 'error': 'File {} failed to store in DB.'.format(filename)}
595 595
596 596 Session().commit()
597 597
598 598 return {
599 599 'store_fid': store_uid,
600 600 'access_path': h.route_path(
601 601 'download_file', fid=store_uid),
602 602 'fqn_access_path': h.route_url(
603 603 'download_file', fid=store_uid),
604 604 'repo_access_path': h.route_path(
605 605 'repo_artifacts_get', repo_name=self.db_repo_name, uid=store_uid),
606 606 'repo_fqn_access_path': h.route_url(
607 607 'repo_artifacts_get', repo_name=self.db_repo_name, uid=store_uid),
608 608 }
609 609
610 610 @LoginRequired()
611 611 @NotAnonymous()
612 612 @HasRepoPermissionAnyDecorator(
613 613 'repository.read', 'repository.write', 'repository.admin')
614 614 @CSRFRequired()
615 615 @view_config(
616 616 route_name='repo_commit_comment_delete', request_method='POST',
617 617 renderer='json_ext')
618 618 def repo_commit_comment_delete(self):
619 619 commit_id = self.request.matchdict['commit_id']
620 620 comment_id = self.request.matchdict['comment_id']
621 621
622 622 comment = ChangesetComment.get_or_404(comment_id)
623 623 if not comment:
624 624 log.debug('Comment with id:%s not found, skipping', comment_id)
625 625 # comment already deleted in another call probably
626 626 return True
627 627
628 628 if comment.immutable:
629 629 # don't allow deleting comments that are immutable
630 630 raise HTTPForbidden()
631 631
632 632 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
633 633 super_admin = h.HasPermissionAny('hg.admin')()
634 634 comment_owner = (comment.author.user_id == self._rhodecode_db_user.user_id)
635 635 is_repo_comment = comment.repo.repo_id == self.db_repo.repo_id
636 636 comment_repo_admin = is_repo_admin and is_repo_comment
637 637
638 638 if super_admin or comment_owner or comment_repo_admin:
639 639 CommentsModel().delete(comment=comment, auth_user=self._rhodecode_user)
640 640 Session().commit()
641 641 return True
642 642 else:
643 643 log.warning('No permissions for user %s to delete comment_id: %s',
644 644 self._rhodecode_db_user, comment_id)
645 645 raise HTTPNotFound()
646 646
647 647 @LoginRequired()
648 648 @NotAnonymous()
649 649 @HasRepoPermissionAnyDecorator(
650 650 'repository.read', 'repository.write', 'repository.admin')
651 651 @CSRFRequired()
652 652 @view_config(
653 653 route_name='repo_commit_comment_edit', request_method='POST',
654 654 renderer='json_ext')
655 655 def repo_commit_comment_edit(self):
656 656 self.load_default_context()
657 657
658 658 comment_id = self.request.matchdict['comment_id']
659 659 comment = ChangesetComment.get_or_404(comment_id)
660 660
661 661 if comment.immutable:
662 662 # don't allow deleting comments that are immutable
663 663 raise HTTPForbidden()
664 664
665 665 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
666 666 super_admin = h.HasPermissionAny('hg.admin')()
667 667 comment_owner = (comment.author.user_id == self._rhodecode_db_user.user_id)
668 668 is_repo_comment = comment.repo.repo_id == self.db_repo.repo_id
669 669 comment_repo_admin = is_repo_admin and is_repo_comment
670 670
671 671 if super_admin or comment_owner or comment_repo_admin:
672 672 text = self.request.POST.get('text')
673 673 version = self.request.POST.get('version')
674 674 if text == comment.text:
675 675 log.warning(
676 676 'Comment(repo): '
677 677 'Trying to create new version '
678 678 'with the same comment body {}'.format(
679 679 comment_id,
680 680 )
681 681 )
682 682 raise HTTPNotFound()
683 683
684 684 if version.isdigit():
685 685 version = int(version)
686 686 else:
687 687 log.warning(
688 688 'Comment(repo): Wrong version type {} {} '
689 689 'for comment {}'.format(
690 690 version,
691 691 type(version),
692 692 comment_id,
693 693 )
694 694 )
695 695 raise HTTPNotFound()
696 696
697 697 try:
698 698 comment_history = CommentsModel().edit(
699 699 comment_id=comment_id,
700 700 text=text,
701 701 auth_user=self._rhodecode_user,
702 702 version=version,
703 703 )
704 704 except CommentVersionMismatch:
705 705 raise HTTPConflict()
706 706
707 707 if not comment_history:
708 708 raise HTTPNotFound()
709 709
710 710 commit_id = self.request.matchdict['commit_id']
711 711 commit = self.db_repo.get_commit(commit_id)
712 712 CommentsModel().trigger_commit_comment_hook(
713 713 self.db_repo, self._rhodecode_user, 'edit',
714 714 data={'comment': comment, 'commit': commit})
715 715
716 716 Session().commit()
717 717 return {
718 718 'comment_history_id': comment_history.comment_history_id,
719 719 'comment_id': comment.comment_id,
720 720 'comment_version': comment_history.version,
721 721 'comment_author_username': comment_history.author.username,
722 722 'comment_author_gravatar': h.gravatar_url(comment_history.author.email, 16),
723 723 'comment_created_on': h.age_component(comment_history.created_on,
724 724 time_is_local=True),
725 725 }
726 726 else:
727 727 log.warning('No permissions for user %s to edit comment_id: %s',
728 728 self._rhodecode_db_user, comment_id)
729 729 raise HTTPNotFound()
730 730
731 731 @LoginRequired()
732 732 @HasRepoPermissionAnyDecorator(
733 733 'repository.read', 'repository.write', 'repository.admin')
734 734 @view_config(
735 735 route_name='repo_commit_data', request_method='GET',
736 736 renderer='json_ext', xhr=True)
737 737 def repo_commit_data(self):
738 738 commit_id = self.request.matchdict['commit_id']
739 739 self.load_default_context()
740 740
741 741 try:
742 742 return self.rhodecode_vcs_repo.get_commit(commit_id=commit_id)
743 743 except CommitDoesNotExistError as e:
744 744 return EmptyCommit(message=str(e))
745 745
746 746 @LoginRequired()
747 747 @HasRepoPermissionAnyDecorator(
748 748 'repository.read', 'repository.write', 'repository.admin')
749 749 @view_config(
750 750 route_name='repo_commit_children', request_method='GET',
751 751 renderer='json_ext', xhr=True)
752 752 def repo_commit_children(self):
753 753 commit_id = self.request.matchdict['commit_id']
754 754 self.load_default_context()
755 755
756 756 try:
757 757 commit = self.rhodecode_vcs_repo.get_commit(commit_id=commit_id)
758 758 children = commit.children
759 759 except CommitDoesNotExistError:
760 760 children = []
761 761
762 762 result = {"results": children}
763 763 return result
764 764
765 765 @LoginRequired()
766 766 @HasRepoPermissionAnyDecorator(
767 767 'repository.read', 'repository.write', 'repository.admin')
768 768 @view_config(
769 769 route_name='repo_commit_parents', request_method='GET',
770 770 renderer='json_ext')
771 771 def repo_commit_parents(self):
772 772 commit_id = self.request.matchdict['commit_id']
773 773 self.load_default_context()
774 774
775 775 try:
776 776 commit = self.rhodecode_vcs_repo.get_commit(commit_id=commit_id)
777 777 parents = commit.parents
778 778 except CommitDoesNotExistError:
779 779 parents = []
780 780 result = {"results": parents}
781 781 return result
@@ -1,1757 +1,1794 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 from rhodecode.lib.utils2 import str2bool, safe_str, safe_unicode, safe_int
42 from rhodecode.lib.utils2 import str2bool, safe_str, safe_unicode, safe_int, aslist
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 func, or_, PullRequest, ChangesetComment, ChangesetStatus, Repository)
49 func, or_, PullRequest, ChangesetComment, ChangesetStatus, Repository,
50 PullRequestReviewers)
50 51 from rhodecode.model.forms import PullRequestForm
51 52 from rhodecode.model.meta import Session
52 53 from rhodecode.model.pull_request import PullRequestModel, MergeCheck
53 54 from rhodecode.model.scm import ScmModel
54 55
55 56 log = logging.getLogger(__name__)
56 57
57 58
58 59 class RepoPullRequestsView(RepoAppView, DataGridAppView):
59 60
60 61 def load_default_context(self):
61 62 c = self._get_local_tmpl_context(include_app_defaults=True)
62 63 c.REVIEW_STATUS_APPROVED = ChangesetStatus.STATUS_APPROVED
63 64 c.REVIEW_STATUS_REJECTED = ChangesetStatus.STATUS_REJECTED
64 65 # backward compat., we use for OLD PRs a plain renderer
65 66 c.renderer = 'plain'
66 67 return c
67 68
68 69 def _get_pull_requests_list(
69 70 self, repo_name, source, filter_type, opened_by, statuses):
70 71
71 72 draw, start, limit = self._extract_chunk(self.request)
72 73 search_q, order_by, order_dir = self._extract_ordering(self.request)
73 74 _render = self.request.get_partial_renderer(
74 75 'rhodecode:templates/data_table/_dt_elements.mako')
75 76
76 77 # pagination
77 78
78 79 if filter_type == 'awaiting_review':
79 80 pull_requests = PullRequestModel().get_awaiting_review(
80 81 repo_name, search_q=search_q, source=source, opened_by=opened_by,
81 82 statuses=statuses, offset=start, length=limit,
82 83 order_by=order_by, order_dir=order_dir)
83 84 pull_requests_total_count = PullRequestModel().count_awaiting_review(
84 85 repo_name, search_q=search_q, source=source, statuses=statuses,
85 86 opened_by=opened_by)
86 87 elif filter_type == 'awaiting_my_review':
87 88 pull_requests = PullRequestModel().get_awaiting_my_review(
88 89 repo_name, search_q=search_q, source=source, opened_by=opened_by,
89 90 user_id=self._rhodecode_user.user_id, statuses=statuses,
90 91 offset=start, length=limit, order_by=order_by,
91 92 order_dir=order_dir)
92 93 pull_requests_total_count = PullRequestModel().count_awaiting_my_review(
93 94 repo_name, search_q=search_q, source=source, user_id=self._rhodecode_user.user_id,
94 95 statuses=statuses, opened_by=opened_by)
95 96 else:
96 97 pull_requests = PullRequestModel().get_all(
97 98 repo_name, search_q=search_q, source=source, opened_by=opened_by,
98 99 statuses=statuses, offset=start, length=limit,
99 100 order_by=order_by, order_dir=order_dir)
100 101 pull_requests_total_count = PullRequestModel().count_all(
101 102 repo_name, search_q=search_q, source=source, statuses=statuses,
102 103 opened_by=opened_by)
103 104
104 105 data = []
105 106 comments_model = CommentsModel()
106 107 for pr in pull_requests:
107 108 comments = comments_model.get_all_comments(
108 109 self.db_repo.repo_id, pull_request=pr)
109 110
110 111 data.append({
111 112 'name': _render('pullrequest_name',
112 113 pr.pull_request_id, pr.pull_request_state,
113 114 pr.work_in_progress, pr.target_repo.repo_name),
114 115 'name_raw': pr.pull_request_id,
115 116 'status': _render('pullrequest_status',
116 117 pr.calculated_review_status()),
117 118 'title': _render('pullrequest_title', pr.title, pr.description),
118 119 'description': h.escape(pr.description),
119 120 'updated_on': _render('pullrequest_updated_on',
120 121 h.datetime_to_time(pr.updated_on)),
121 122 'updated_on_raw': h.datetime_to_time(pr.updated_on),
122 123 'created_on': _render('pullrequest_updated_on',
123 124 h.datetime_to_time(pr.created_on)),
124 125 'created_on_raw': h.datetime_to_time(pr.created_on),
125 126 'state': pr.pull_request_state,
126 127 'author': _render('pullrequest_author',
127 128 pr.author.full_contact, ),
128 129 'author_raw': pr.author.full_name,
129 130 'comments': _render('pullrequest_comments', len(comments)),
130 131 'comments_raw': len(comments),
131 132 'closed': pr.is_closed(),
132 133 })
133 134
134 135 data = ({
135 136 'draw': draw,
136 137 'data': data,
137 138 'recordsTotal': pull_requests_total_count,
138 139 'recordsFiltered': pull_requests_total_count,
139 140 })
140 141 return data
141 142
142 143 @LoginRequired()
143 144 @HasRepoPermissionAnyDecorator(
144 145 'repository.read', 'repository.write', 'repository.admin')
145 146 @view_config(
146 147 route_name='pullrequest_show_all', request_method='GET',
147 148 renderer='rhodecode:templates/pullrequests/pullrequests.mako')
148 149 def pull_request_list(self):
149 150 c = self.load_default_context()
150 151
151 152 req_get = self.request.GET
152 153 c.source = str2bool(req_get.get('source'))
153 154 c.closed = str2bool(req_get.get('closed'))
154 155 c.my = str2bool(req_get.get('my'))
155 156 c.awaiting_review = str2bool(req_get.get('awaiting_review'))
156 157 c.awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
157 158
158 159 c.active = 'open'
159 160 if c.my:
160 161 c.active = 'my'
161 162 if c.closed:
162 163 c.active = 'closed'
163 164 if c.awaiting_review and not c.source:
164 165 c.active = 'awaiting'
165 166 if c.source and not c.awaiting_review:
166 167 c.active = 'source'
167 168 if c.awaiting_my_review:
168 169 c.active = 'awaiting_my'
169 170
170 171 return self._get_template_context(c)
171 172
172 173 @LoginRequired()
173 174 @HasRepoPermissionAnyDecorator(
174 175 'repository.read', 'repository.write', 'repository.admin')
175 176 @view_config(
176 177 route_name='pullrequest_show_all_data', request_method='GET',
177 178 renderer='json_ext', xhr=True)
178 179 def pull_request_list_data(self):
179 180 self.load_default_context()
180 181
181 182 # additional filters
182 183 req_get = self.request.GET
183 184 source = str2bool(req_get.get('source'))
184 185 closed = str2bool(req_get.get('closed'))
185 186 my = str2bool(req_get.get('my'))
186 187 awaiting_review = str2bool(req_get.get('awaiting_review'))
187 188 awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
188 189
189 190 filter_type = 'awaiting_review' if awaiting_review \
190 191 else 'awaiting_my_review' if awaiting_my_review \
191 192 else None
192 193
193 194 opened_by = None
194 195 if my:
195 196 opened_by = [self._rhodecode_user.user_id]
196 197
197 198 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
198 199 if closed:
199 200 statuses = [PullRequest.STATUS_CLOSED]
200 201
201 202 data = self._get_pull_requests_list(
202 203 repo_name=self.db_repo_name, source=source,
203 204 filter_type=filter_type, opened_by=opened_by, statuses=statuses)
204 205
205 206 return data
206 207
207 208 def _is_diff_cache_enabled(self, target_repo):
208 209 caching_enabled = self._get_general_setting(
209 210 target_repo, 'rhodecode_diff_cache')
210 211 log.debug('Diff caching enabled: %s', caching_enabled)
211 212 return caching_enabled
212 213
213 214 def _get_diffset(self, source_repo_name, source_repo,
214 215 ancestor_commit,
215 216 source_ref_id, target_ref_id,
216 217 target_commit, source_commit, diff_limit, file_limit,
217 218 fulldiff, hide_whitespace_changes, diff_context, use_ancestor=True):
218 219
219 220 if use_ancestor:
220 221 # we might want to not use it for versions
221 222 target_ref_id = ancestor_commit.raw_id
222 223
223 224 vcs_diff = PullRequestModel().get_diff(
224 225 source_repo, source_ref_id, target_ref_id,
225 226 hide_whitespace_changes, diff_context)
226 227
227 228 diff_processor = diffs.DiffProcessor(
228 229 vcs_diff, format='newdiff', diff_limit=diff_limit,
229 230 file_limit=file_limit, show_full_diff=fulldiff)
230 231
231 232 _parsed = diff_processor.prepare()
232 233
233 234 diffset = codeblocks.DiffSet(
234 235 repo_name=self.db_repo_name,
235 236 source_repo_name=source_repo_name,
236 237 source_node_getter=codeblocks.diffset_node_getter(target_commit),
237 238 target_node_getter=codeblocks.diffset_node_getter(source_commit),
238 239 )
239 240 diffset = self.path_filter.render_patchset_filtered(
240 241 diffset, _parsed, target_commit.raw_id, source_commit.raw_id)
241 242
242 243 return diffset
243 244
244 245 def _get_range_diffset(self, source_scm, source_repo,
245 246 commit1, commit2, diff_limit, file_limit,
246 247 fulldiff, hide_whitespace_changes, diff_context):
247 248 vcs_diff = source_scm.get_diff(
248 249 commit1, commit2,
249 250 ignore_whitespace=hide_whitespace_changes,
250 251 context=diff_context)
251 252
252 253 diff_processor = diffs.DiffProcessor(
253 254 vcs_diff, format='newdiff', diff_limit=diff_limit,
254 255 file_limit=file_limit, show_full_diff=fulldiff)
255 256
256 257 _parsed = diff_processor.prepare()
257 258
258 259 diffset = codeblocks.DiffSet(
259 260 repo_name=source_repo.repo_name,
260 261 source_node_getter=codeblocks.diffset_node_getter(commit1),
261 262 target_node_getter=codeblocks.diffset_node_getter(commit2))
262 263
263 264 diffset = self.path_filter.render_patchset_filtered(
264 265 diffset, _parsed, commit1.raw_id, commit2.raw_id)
265 266
266 267 return diffset
267 268
268 269 def register_comments_vars(self, c, pull_request, versions):
269 270 comments_model = CommentsModel()
270 271
271 272 # GENERAL COMMENTS with versions #
272 273 q = comments_model._all_general_comments_of_pull_request(pull_request)
273 274 q = q.order_by(ChangesetComment.comment_id.asc())
274 275 general_comments = q
275 276
276 277 # pick comments we want to render at current version
277 278 c.comment_versions = comments_model.aggregate_comments(
278 279 general_comments, versions, c.at_version_num)
279 280
280 281 # INLINE COMMENTS with versions #
281 282 q = comments_model._all_inline_comments_of_pull_request(pull_request)
282 283 q = q.order_by(ChangesetComment.comment_id.asc())
283 284 inline_comments = q
284 285
285 286 c.inline_versions = comments_model.aggregate_comments(
286 287 inline_comments, versions, c.at_version_num, inline=True)
287 288
288 289 # Comments inline+general
289 290 if c.at_version:
290 291 c.inline_comments_flat = c.inline_versions[c.at_version_num]['display']
291 292 c.comments = c.comment_versions[c.at_version_num]['display']
292 293 else:
293 294 c.inline_comments_flat = c.inline_versions[c.at_version_num]['until']
294 295 c.comments = c.comment_versions[c.at_version_num]['until']
295 296
296 297 return general_comments, inline_comments
297 298
298 299 @LoginRequired()
299 300 @HasRepoPermissionAnyDecorator(
300 301 'repository.read', 'repository.write', 'repository.admin')
301 302 @view_config(
302 303 route_name='pullrequest_show', request_method='GET',
303 304 renderer='rhodecode:templates/pullrequests/pullrequest_show.mako')
304 305 def pull_request_show(self):
305 306 _ = self.request.translate
306 307 c = self.load_default_context()
307 308
308 309 pull_request = PullRequest.get_or_404(
309 310 self.request.matchdict['pull_request_id'])
310 311 pull_request_id = pull_request.pull_request_id
311 312
312 313 c.state_progressing = pull_request.is_state_changing()
313 314 c.pr_broadcast_channel = '/repo${}$/pr/{}'.format(
314 315 pull_request.target_repo.repo_name, pull_request.pull_request_id)
315 316
316 317 _new_state = {
317 318 'created': PullRequest.STATE_CREATED,
318 319 }.get(self.request.GET.get('force_state'))
319 320
320 321 if c.is_super_admin and _new_state:
321 322 with pull_request.set_state(PullRequest.STATE_UPDATING, final_state=_new_state):
322 323 h.flash(
323 324 _('Pull Request state was force changed to `{}`').format(_new_state),
324 325 category='success')
325 326 Session().commit()
326 327
327 328 raise HTTPFound(h.route_path(
328 329 'pullrequest_show', repo_name=self.db_repo_name,
329 330 pull_request_id=pull_request_id))
330 331
331 332 version = self.request.GET.get('version')
332 333 from_version = self.request.GET.get('from_version') or version
333 334 merge_checks = self.request.GET.get('merge_checks')
334 335 c.fulldiff = str2bool(self.request.GET.get('fulldiff'))
335 336 force_refresh = str2bool(self.request.GET.get('force_refresh'))
336 337 c.range_diff_on = self.request.GET.get('range-diff') == "1"
337 338
338 339 # fetch global flags of ignore ws or context lines
339 340 diff_context = diffs.get_diff_context(self.request)
340 341 hide_whitespace_changes = diffs.get_diff_whitespace_flag(self.request)
341 342
342 343 (pull_request_latest,
343 344 pull_request_at_ver,
344 345 pull_request_display_obj,
345 346 at_version) = PullRequestModel().get_pr_version(
346 347 pull_request_id, version=version)
347 348
348 349 pr_closed = pull_request_latest.is_closed()
349 350
350 351 if pr_closed and (version or from_version):
351 352 # not allow to browse versions for closed PR
352 353 raise HTTPFound(h.route_path(
353 354 'pullrequest_show', repo_name=self.db_repo_name,
354 355 pull_request_id=pull_request_id))
355 356
356 357 versions = pull_request_display_obj.versions()
357 358 # used to store per-commit range diffs
358 359 c.changes = collections.OrderedDict()
359 360
360 361 c.at_version = at_version
361 362 c.at_version_num = (at_version
362 363 if at_version and at_version != PullRequest.LATEST_VER
363 364 else None)
364 365
365 366 c.at_version_index = ChangesetComment.get_index_from_version(
366 367 c.at_version_num, versions)
367 368
368 369 (prev_pull_request_latest,
369 370 prev_pull_request_at_ver,
370 371 prev_pull_request_display_obj,
371 372 prev_at_version) = PullRequestModel().get_pr_version(
372 373 pull_request_id, version=from_version)
373 374
374 375 c.from_version = prev_at_version
375 376 c.from_version_num = (prev_at_version
376 377 if prev_at_version and prev_at_version != PullRequest.LATEST_VER
377 378 else None)
378 379 c.from_version_index = ChangesetComment.get_index_from_version(
379 380 c.from_version_num, versions)
380 381
381 382 # define if we're in COMPARE mode or VIEW at version mode
382 383 compare = at_version != prev_at_version
383 384
384 385 # pull_requests repo_name we opened it against
385 386 # ie. target_repo must match
386 387 if self.db_repo_name != pull_request_at_ver.target_repo.repo_name:
387 388 log.warning('Mismatch between the current repo: %s, and target %s',
388 389 self.db_repo_name, pull_request_at_ver.target_repo.repo_name)
389 390 raise HTTPNotFound()
390 391
391 392 c.shadow_clone_url = PullRequestModel().get_shadow_clone_url(pull_request_at_ver)
392 393
393 394 c.pull_request = pull_request_display_obj
394 395 c.renderer = pull_request_at_ver.description_renderer or c.renderer
395 396 c.pull_request_latest = pull_request_latest
396 397
397 398 # inject latest version
398 399 latest_ver = PullRequest.get_pr_display_object(pull_request_latest, pull_request_latest)
399 400 c.versions = versions + [latest_ver]
400 401
401 402 if compare or (at_version and not at_version == PullRequest.LATEST_VER):
402 403 c.allowed_to_change_status = False
403 404 c.allowed_to_update = False
404 405 c.allowed_to_merge = False
405 406 c.allowed_to_delete = False
406 407 c.allowed_to_comment = False
407 408 c.allowed_to_close = False
408 409 else:
409 410 can_change_status = PullRequestModel().check_user_change_status(
410 411 pull_request_at_ver, self._rhodecode_user)
411 412 c.allowed_to_change_status = can_change_status and not pr_closed
412 413
413 414 c.allowed_to_update = PullRequestModel().check_user_update(
414 415 pull_request_latest, self._rhodecode_user) and not pr_closed
415 416 c.allowed_to_merge = PullRequestModel().check_user_merge(
416 417 pull_request_latest, self._rhodecode_user) and not pr_closed
417 418 c.allowed_to_delete = PullRequestModel().check_user_delete(
418 419 pull_request_latest, self._rhodecode_user) and not pr_closed
419 420 c.allowed_to_comment = not pr_closed
420 421 c.allowed_to_close = c.allowed_to_merge and not pr_closed
421 422
422 423 c.forbid_adding_reviewers = False
423 424 c.forbid_author_to_review = False
424 425 c.forbid_commit_author_to_review = False
425 426
426 427 if pull_request_latest.reviewer_data and \
427 428 'rules' in pull_request_latest.reviewer_data:
428 429 rules = pull_request_latest.reviewer_data['rules'] or {}
429 430 try:
430 431 c.forbid_adding_reviewers = rules.get('forbid_adding_reviewers')
431 432 c.forbid_author_to_review = rules.get('forbid_author_to_review')
432 433 c.forbid_commit_author_to_review = rules.get('forbid_commit_author_to_review')
433 434 except Exception:
434 435 pass
435 436
436 437 # check merge capabilities
437 438 _merge_check = MergeCheck.validate(
438 439 pull_request_latest, auth_user=self._rhodecode_user,
439 440 translator=self.request.translate,
440 441 force_shadow_repo_refresh=force_refresh)
441 442
442 443 c.pr_merge_errors = _merge_check.error_details
443 444 c.pr_merge_possible = not _merge_check.failed
444 445 c.pr_merge_message = _merge_check.merge_msg
445 446 c.pr_merge_source_commit = _merge_check.source_commit
446 447 c.pr_merge_target_commit = _merge_check.target_commit
447 448
448 449 c.pr_merge_info = MergeCheck.get_merge_conditions(
449 450 pull_request_latest, translator=self.request.translate)
450 451
451 452 c.pull_request_review_status = _merge_check.review_status
452 453 if merge_checks:
453 454 self.request.override_renderer = \
454 455 'rhodecode:templates/pullrequests/pullrequest_merge_checks.mako'
455 456 return self._get_template_context(c)
456 457
457 458 c.allowed_reviewers = [obj.user_id for obj in pull_request.reviewers if obj.user]
459 c.reviewers_count = pull_request.reviewers_count
460 c.observers_count = pull_request.observers_count
458 461
459 462 # reviewers and statuses
460 463 c.pull_request_default_reviewers_data_json = json.dumps(pull_request.reviewer_data)
461 464 c.pull_request_set_reviewers_data_json = collections.OrderedDict({'reviewers': []})
465 c.pull_request_set_observers_data_json = collections.OrderedDict({'observers': []})
462 466
463 467 for review_obj, member, reasons, mandatory, status in pull_request_at_ver.reviewers_statuses():
464 468 member_reviewer = h.reviewer_as_json(
465 469 member, reasons=reasons, mandatory=mandatory,
470 role=review_obj.role,
466 471 user_group=review_obj.rule_user_group_data()
467 472 )
468 473
469 474 current_review_status = status[0][1].status if status else ChangesetStatus.STATUS_NOT_REVIEWED
470 475 member_reviewer['review_status'] = current_review_status
471 476 member_reviewer['review_status_label'] = h.commit_status_lbl(current_review_status)
472 477 member_reviewer['allowed_to_update'] = c.allowed_to_update
473 478 c.pull_request_set_reviewers_data_json['reviewers'].append(member_reviewer)
474 479
475 480 c.pull_request_set_reviewers_data_json = json.dumps(c.pull_request_set_reviewers_data_json)
476 481
482 for observer_obj, member in pull_request_at_ver.observers():
483 member_observer = h.reviewer_as_json(
484 member, reasons=[], mandatory=False,
485 role=observer_obj.role,
486 user_group=observer_obj.rule_user_group_data()
487 )
488 member_observer['allowed_to_update'] = c.allowed_to_update
489 c.pull_request_set_observers_data_json['observers'].append(member_observer)
490
491 c.pull_request_set_observers_data_json = json.dumps(c.pull_request_set_observers_data_json)
492
477 493 general_comments, inline_comments = \
478 494 self.register_comments_vars(c, pull_request_latest, versions)
479 495
480 496 # TODOs
481 497 c.unresolved_comments = CommentsModel() \
482 498 .get_pull_request_unresolved_todos(pull_request_latest)
483 499 c.resolved_comments = CommentsModel() \
484 500 .get_pull_request_resolved_todos(pull_request_latest)
485 501
486 502 # if we use version, then do not show later comments
487 503 # than current version
488 504 display_inline_comments = collections.defaultdict(
489 505 lambda: collections.defaultdict(list))
490 506 for co in inline_comments:
491 507 if c.at_version_num:
492 508 # pick comments that are at least UPTO given version, so we
493 509 # don't render comments for higher version
494 510 should_render = co.pull_request_version_id and \
495 511 co.pull_request_version_id <= c.at_version_num
496 512 else:
497 513 # showing all, for 'latest'
498 514 should_render = True
499 515
500 516 if should_render:
501 517 display_inline_comments[co.f_path][co.line_no].append(co)
502 518
503 519 # load diff data into template context, if we use compare mode then
504 520 # diff is calculated based on changes between versions of PR
505 521
506 522 source_repo = pull_request_at_ver.source_repo
507 523 source_ref_id = pull_request_at_ver.source_ref_parts.commit_id
508 524
509 525 target_repo = pull_request_at_ver.target_repo
510 526 target_ref_id = pull_request_at_ver.target_ref_parts.commit_id
511 527
512 528 if compare:
513 529 # in compare switch the diff base to latest commit from prev version
514 530 target_ref_id = prev_pull_request_display_obj.revisions[0]
515 531
516 532 # despite opening commits for bookmarks/branches/tags, we always
517 533 # convert this to rev to prevent changes after bookmark or branch change
518 534 c.source_ref_type = 'rev'
519 535 c.source_ref = source_ref_id
520 536
521 537 c.target_ref_type = 'rev'
522 538 c.target_ref = target_ref_id
523 539
524 540 c.source_repo = source_repo
525 541 c.target_repo = target_repo
526 542
527 543 c.commit_ranges = []
528 544 source_commit = EmptyCommit()
529 545 target_commit = EmptyCommit()
530 546 c.missing_requirements = False
531 547
532 548 source_scm = source_repo.scm_instance()
533 549 target_scm = target_repo.scm_instance()
534 550
535 551 shadow_scm = None
536 552 try:
537 553 shadow_scm = pull_request_latest.get_shadow_repo()
538 554 except Exception:
539 555 log.debug('Failed to get shadow repo', exc_info=True)
540 556 # try first the existing source_repo, and then shadow
541 557 # repo if we can obtain one
542 558 commits_source_repo = source_scm
543 559 if shadow_scm:
544 560 commits_source_repo = shadow_scm
545 561
546 562 c.commits_source_repo = commits_source_repo
547 563 c.ancestor = None # set it to None, to hide it from PR view
548 564
549 565 # empty version means latest, so we keep this to prevent
550 566 # double caching
551 567 version_normalized = version or PullRequest.LATEST_VER
552 568 from_version_normalized = from_version or PullRequest.LATEST_VER
553 569
554 570 cache_path = self.rhodecode_vcs_repo.get_create_shadow_cache_pr_path(target_repo)
555 571 cache_file_path = diff_cache_exist(
556 572 cache_path, 'pull_request', pull_request_id, version_normalized,
557 573 from_version_normalized, source_ref_id, target_ref_id,
558 574 hide_whitespace_changes, diff_context, c.fulldiff)
559 575
560 576 caching_enabled = self._is_diff_cache_enabled(c.target_repo)
561 577 force_recache = self.get_recache_flag()
562 578
563 579 cached_diff = None
564 580 if caching_enabled:
565 581 cached_diff = load_cached_diff(cache_file_path)
566 582
567 583 has_proper_commit_cache = (
568 584 cached_diff and cached_diff.get('commits')
569 585 and len(cached_diff.get('commits', [])) == 5
570 586 and cached_diff.get('commits')[0]
571 587 and cached_diff.get('commits')[3])
572 588
573 589 if not force_recache and not c.range_diff_on and has_proper_commit_cache:
574 590 diff_commit_cache = \
575 591 (ancestor_commit, commit_cache, missing_requirements,
576 592 source_commit, target_commit) = cached_diff['commits']
577 593 else:
578 594 # NOTE(marcink): we reach potentially unreachable errors when a PR has
579 595 # merge errors resulting in potentially hidden commits in the shadow repo.
580 596 maybe_unreachable = _merge_check.MERGE_CHECK in _merge_check.error_details \
581 597 and _merge_check.merge_response
582 598 maybe_unreachable = maybe_unreachable \
583 599 and _merge_check.merge_response.metadata.get('unresolved_files')
584 600 log.debug("Using unreachable commits due to MERGE_CHECK in merge simulation")
585 601 diff_commit_cache = \
586 602 (ancestor_commit, commit_cache, missing_requirements,
587 603 source_commit, target_commit) = self.get_commits(
588 604 commits_source_repo,
589 605 pull_request_at_ver,
590 606 source_commit,
591 607 source_ref_id,
592 608 source_scm,
593 609 target_commit,
594 610 target_ref_id,
595 611 target_scm,
596 612 maybe_unreachable=maybe_unreachable)
597 613
598 614 # register our commit range
599 615 for comm in commit_cache.values():
600 616 c.commit_ranges.append(comm)
601 617
602 618 c.missing_requirements = missing_requirements
603 619 c.ancestor_commit = ancestor_commit
604 620 c.statuses = source_repo.statuses(
605 621 [x.raw_id for x in c.commit_ranges])
606 622
607 623 # auto collapse if we have more than limit
608 624 collapse_limit = diffs.DiffProcessor._collapse_commits_over
609 625 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
610 626 c.compare_mode = compare
611 627
612 628 # diff_limit is the old behavior, will cut off the whole diff
613 629 # if the limit is applied otherwise will just hide the
614 630 # big files from the front-end
615 631 diff_limit = c.visual.cut_off_limit_diff
616 632 file_limit = c.visual.cut_off_limit_file
617 633
618 634 c.missing_commits = False
619 635 if (c.missing_requirements
620 636 or isinstance(source_commit, EmptyCommit)
621 637 or source_commit == target_commit):
622 638
623 639 c.missing_commits = True
624 640 else:
625 641 c.inline_comments = display_inline_comments
626 642
627 643 use_ancestor = True
628 644 if from_version_normalized != version_normalized:
629 645 use_ancestor = False
630 646
631 647 has_proper_diff_cache = cached_diff and cached_diff.get('commits')
632 648 if not force_recache and has_proper_diff_cache:
633 649 c.diffset = cached_diff['diff']
634 650 else:
635 651 try:
636 652 c.diffset = self._get_diffset(
637 653 c.source_repo.repo_name, commits_source_repo,
638 654 c.ancestor_commit,
639 655 source_ref_id, target_ref_id,
640 656 target_commit, source_commit,
641 657 diff_limit, file_limit, c.fulldiff,
642 658 hide_whitespace_changes, diff_context,
643 659 use_ancestor=use_ancestor
644 660 )
645 661
646 662 # save cached diff
647 663 if caching_enabled:
648 664 cache_diff(cache_file_path, c.diffset, diff_commit_cache)
649 665 except CommitDoesNotExistError:
650 666 log.exception('Failed to generate diffset')
651 667 c.missing_commits = True
652 668
653 669 if not c.missing_commits:
654 670
655 671 c.limited_diff = c.diffset.limited_diff
656 672
657 673 # calculate removed files that are bound to comments
658 674 comment_deleted_files = [
659 675 fname for fname in display_inline_comments
660 676 if fname not in c.diffset.file_stats]
661 677
662 678 c.deleted_files_comments = collections.defaultdict(dict)
663 679 for fname, per_line_comments in display_inline_comments.items():
664 680 if fname in comment_deleted_files:
665 681 c.deleted_files_comments[fname]['stats'] = 0
666 682 c.deleted_files_comments[fname]['comments'] = list()
667 683 for lno, comments in per_line_comments.items():
668 684 c.deleted_files_comments[fname]['comments'].extend(comments)
669 685
670 686 # maybe calculate the range diff
671 687 if c.range_diff_on:
672 688 # TODO(marcink): set whitespace/context
673 689 context_lcl = 3
674 690 ign_whitespace_lcl = False
675 691
676 692 for commit in c.commit_ranges:
677 693 commit2 = commit
678 694 commit1 = commit.first_parent
679 695
680 696 range_diff_cache_file_path = diff_cache_exist(
681 697 cache_path, 'diff', commit.raw_id,
682 698 ign_whitespace_lcl, context_lcl, c.fulldiff)
683 699
684 700 cached_diff = None
685 701 if caching_enabled:
686 702 cached_diff = load_cached_diff(range_diff_cache_file_path)
687 703
688 704 has_proper_diff_cache = cached_diff and cached_diff.get('diff')
689 705 if not force_recache and has_proper_diff_cache:
690 706 diffset = cached_diff['diff']
691 707 else:
692 708 diffset = self._get_range_diffset(
693 709 commits_source_repo, source_repo,
694 710 commit1, commit2, diff_limit, file_limit,
695 711 c.fulldiff, ign_whitespace_lcl, context_lcl
696 712 )
697 713
698 714 # save cached diff
699 715 if caching_enabled:
700 716 cache_diff(range_diff_cache_file_path, diffset, None)
701 717
702 718 c.changes[commit.raw_id] = diffset
703 719
704 720 # this is a hack to properly display links, when creating PR, the
705 721 # compare view and others uses different notation, and
706 722 # compare_commits.mako renders links based on the target_repo.
707 723 # We need to swap that here to generate it properly on the html side
708 724 c.target_repo = c.source_repo
709 725
710 726 c.commit_statuses = ChangesetStatus.STATUSES
711 727
712 728 c.show_version_changes = not pr_closed
713 729 if c.show_version_changes:
714 730 cur_obj = pull_request_at_ver
715 731 prev_obj = prev_pull_request_at_ver
716 732
717 733 old_commit_ids = prev_obj.revisions
718 734 new_commit_ids = cur_obj.revisions
719 735 commit_changes = PullRequestModel()._calculate_commit_id_changes(
720 736 old_commit_ids, new_commit_ids)
721 737 c.commit_changes_summary = commit_changes
722 738
723 739 # calculate the diff for commits between versions
724 740 c.commit_changes = []
725 741
726 742 def mark(cs, fw):
727 743 return list(h.itertools.izip_longest([], cs, fillvalue=fw))
728 744
729 745 for c_type, raw_id in mark(commit_changes.added, 'a') \
730 746 + mark(commit_changes.removed, 'r') \
731 747 + mark(commit_changes.common, 'c'):
732 748
733 749 if raw_id in commit_cache:
734 750 commit = commit_cache[raw_id]
735 751 else:
736 752 try:
737 753 commit = commits_source_repo.get_commit(raw_id)
738 754 except CommitDoesNotExistError:
739 755 # in case we fail extracting still use "dummy" commit
740 756 # for display in commit diff
741 757 commit = h.AttributeDict(
742 758 {'raw_id': raw_id,
743 759 'message': 'EMPTY or MISSING COMMIT'})
744 760 c.commit_changes.append([c_type, commit])
745 761
746 762 # current user review statuses for each version
747 763 c.review_versions = {}
748 764 if self._rhodecode_user.user_id in c.allowed_reviewers:
749 765 for co in general_comments:
750 766 if co.author.user_id == self._rhodecode_user.user_id:
751 767 status = co.status_change
752 768 if status:
753 769 _ver_pr = status[0].comment.pull_request_version_id
754 770 c.review_versions[_ver_pr] = status[0]
755 771
756 772 return self._get_template_context(c)
757 773
758 774 def get_commits(
759 775 self, commits_source_repo, pull_request_at_ver, source_commit,
760 776 source_ref_id, source_scm, target_commit, target_ref_id, target_scm,
761 777 maybe_unreachable=False):
762 778
763 779 commit_cache = collections.OrderedDict()
764 780 missing_requirements = False
765 781
766 782 try:
767 783 pre_load = ["author", "date", "message", "branch", "parents"]
768 784
769 785 pull_request_commits = pull_request_at_ver.revisions
770 786 log.debug('Loading %s commits from %s',
771 787 len(pull_request_commits), commits_source_repo)
772 788
773 789 for rev in pull_request_commits:
774 790 comm = commits_source_repo.get_commit(commit_id=rev, pre_load=pre_load,
775 791 maybe_unreachable=maybe_unreachable)
776 792 commit_cache[comm.raw_id] = comm
777 793
778 794 # Order here matters, we first need to get target, and then
779 795 # the source
780 796 target_commit = commits_source_repo.get_commit(
781 797 commit_id=safe_str(target_ref_id))
782 798
783 799 source_commit = commits_source_repo.get_commit(
784 800 commit_id=safe_str(source_ref_id), maybe_unreachable=True)
785 801 except CommitDoesNotExistError:
786 802 log.warning('Failed to get commit from `{}` repo'.format(
787 803 commits_source_repo), exc_info=True)
788 804 except RepositoryRequirementError:
789 805 log.warning('Failed to get all required data from repo', exc_info=True)
790 806 missing_requirements = True
791 807
792 808 pr_ancestor_id = pull_request_at_ver.common_ancestor_id
793 809
794 810 try:
795 811 ancestor_commit = source_scm.get_commit(pr_ancestor_id)
796 812 except Exception:
797 813 ancestor_commit = None
798 814
799 815 return ancestor_commit, commit_cache, missing_requirements, source_commit, target_commit
800 816
801 817 def assure_not_empty_repo(self):
802 818 _ = self.request.translate
803 819
804 820 try:
805 821 self.db_repo.scm_instance().get_commit()
806 822 except EmptyRepositoryError:
807 823 h.flash(h.literal(_('There are no commits yet')),
808 824 category='warning')
809 825 raise HTTPFound(
810 826 h.route_path('repo_summary', repo_name=self.db_repo.repo_name))
811 827
812 828 @LoginRequired()
813 829 @NotAnonymous()
814 830 @HasRepoPermissionAnyDecorator(
815 831 'repository.read', 'repository.write', 'repository.admin')
816 832 @view_config(
817 833 route_name='pullrequest_new', request_method='GET',
818 834 renderer='rhodecode:templates/pullrequests/pullrequest.mako')
819 835 def pull_request_new(self):
820 836 _ = self.request.translate
821 837 c = self.load_default_context()
822 838
823 839 self.assure_not_empty_repo()
824 840 source_repo = self.db_repo
825 841
826 842 commit_id = self.request.GET.get('commit')
827 843 branch_ref = self.request.GET.get('branch')
828 844 bookmark_ref = self.request.GET.get('bookmark')
829 845
830 846 try:
831 847 source_repo_data = PullRequestModel().generate_repo_data(
832 848 source_repo, commit_id=commit_id,
833 849 branch=branch_ref, bookmark=bookmark_ref,
834 850 translator=self.request.translate)
835 851 except CommitDoesNotExistError as e:
836 852 log.exception(e)
837 853 h.flash(_('Commit does not exist'), 'error')
838 854 raise HTTPFound(
839 855 h.route_path('pullrequest_new', repo_name=source_repo.repo_name))
840 856
841 857 default_target_repo = source_repo
842 858
843 859 if source_repo.parent and c.has_origin_repo_read_perm:
844 860 parent_vcs_obj = source_repo.parent.scm_instance()
845 861 if parent_vcs_obj and not parent_vcs_obj.is_empty():
846 862 # change default if we have a parent repo
847 863 default_target_repo = source_repo.parent
848 864
849 865 target_repo_data = PullRequestModel().generate_repo_data(
850 866 default_target_repo, translator=self.request.translate)
851 867
852 868 selected_source_ref = source_repo_data['refs']['selected_ref']
853 869 title_source_ref = ''
854 870 if selected_source_ref:
855 871 title_source_ref = selected_source_ref.split(':', 2)[1]
856 872 c.default_title = PullRequestModel().generate_pullrequest_title(
857 873 source=source_repo.repo_name,
858 874 source_ref=title_source_ref,
859 875 target=default_target_repo.repo_name
860 876 )
861 877
862 878 c.default_repo_data = {
863 879 'source_repo_name': source_repo.repo_name,
864 880 'source_refs_json': json.dumps(source_repo_data),
865 881 'target_repo_name': default_target_repo.repo_name,
866 882 'target_refs_json': json.dumps(target_repo_data),
867 883 }
868 884 c.default_source_ref = selected_source_ref
869 885
870 886 return self._get_template_context(c)
871 887
872 888 @LoginRequired()
873 889 @NotAnonymous()
874 890 @HasRepoPermissionAnyDecorator(
875 891 'repository.read', 'repository.write', 'repository.admin')
876 892 @view_config(
877 893 route_name='pullrequest_repo_refs', request_method='GET',
878 894 renderer='json_ext', xhr=True)
879 895 def pull_request_repo_refs(self):
880 896 self.load_default_context()
881 897 target_repo_name = self.request.matchdict['target_repo_name']
882 898 repo = Repository.get_by_repo_name(target_repo_name)
883 899 if not repo:
884 900 raise HTTPNotFound()
885 901
886 902 target_perm = HasRepoPermissionAny(
887 903 'repository.read', 'repository.write', 'repository.admin')(
888 904 target_repo_name)
889 905 if not target_perm:
890 906 raise HTTPNotFound()
891 907
892 908 return PullRequestModel().generate_repo_data(
893 909 repo, translator=self.request.translate)
894 910
895 911 @LoginRequired()
896 912 @NotAnonymous()
897 913 @HasRepoPermissionAnyDecorator(
898 914 'repository.read', 'repository.write', 'repository.admin')
899 915 @view_config(
900 916 route_name='pullrequest_repo_targets', request_method='GET',
901 917 renderer='json_ext', xhr=True)
902 918 def pullrequest_repo_targets(self):
903 919 _ = self.request.translate
904 920 filter_query = self.request.GET.get('query')
905 921
906 922 # get the parents
907 923 parent_target_repos = []
908 924 if self.db_repo.parent:
909 925 parents_query = Repository.query() \
910 926 .order_by(func.length(Repository.repo_name)) \
911 927 .filter(Repository.fork_id == self.db_repo.parent.repo_id)
912 928
913 929 if filter_query:
914 930 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
915 931 parents_query = parents_query.filter(
916 932 Repository.repo_name.ilike(ilike_expression))
917 933 parents = parents_query.limit(20).all()
918 934
919 935 for parent in parents:
920 936 parent_vcs_obj = parent.scm_instance()
921 937 if parent_vcs_obj and not parent_vcs_obj.is_empty():
922 938 parent_target_repos.append(parent)
923 939
924 940 # get other forks, and repo itself
925 941 query = Repository.query() \
926 942 .order_by(func.length(Repository.repo_name)) \
927 943 .filter(
928 944 or_(Repository.repo_id == self.db_repo.repo_id, # repo itself
929 945 Repository.fork_id == self.db_repo.repo_id) # forks of this repo
930 946 ) \
931 947 .filter(~Repository.repo_id.in_([x.repo_id for x in parent_target_repos]))
932 948
933 949 if filter_query:
934 950 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
935 951 query = query.filter(Repository.repo_name.ilike(ilike_expression))
936 952
937 953 limit = max(20 - len(parent_target_repos), 5) # not less then 5
938 954 target_repos = query.limit(limit).all()
939 955
940 956 all_target_repos = target_repos + parent_target_repos
941 957
942 958 repos = []
943 959 # This checks permissions to the repositories
944 960 for obj in ScmModel().get_repos(all_target_repos):
945 961 repos.append({
946 962 'id': obj['name'],
947 963 'text': obj['name'],
948 964 'type': 'repo',
949 965 'repo_id': obj['dbrepo']['repo_id'],
950 966 'repo_type': obj['dbrepo']['repo_type'],
951 967 'private': obj['dbrepo']['private'],
952 968
953 969 })
954 970
955 971 data = {
956 972 'more': False,
957 973 'results': [{
958 974 'text': _('Repositories'),
959 975 'children': repos
960 976 }] if repos else []
961 977 }
962 978 return data
963 979
964 980 @LoginRequired()
965 981 @NotAnonymous()
966 982 @HasRepoPermissionAnyDecorator(
967 983 'repository.read', 'repository.write', 'repository.admin')
968 984 @view_config(
969 985 route_name='pullrequest_comments', request_method='POST',
970 renderer='string', xhr=True)
986 renderer='string_html', xhr=True)
971 987 def pullrequest_comments(self):
972 988 self.load_default_context()
973 989
974 990 pull_request = PullRequest.get_or_404(
975 991 self.request.matchdict['pull_request_id'])
976 992 pull_request_id = pull_request.pull_request_id
977 993 version = self.request.GET.get('version')
978 994
979 995 _render = self.request.get_partial_renderer(
980 996 'rhodecode:templates/base/sidebar.mako')
981 997 c = _render.get_call_context()
982 998
983 999 (pull_request_latest,
984 1000 pull_request_at_ver,
985 1001 pull_request_display_obj,
986 1002 at_version) = PullRequestModel().get_pr_version(
987 1003 pull_request_id, version=version)
988 1004 versions = pull_request_display_obj.versions()
989 1005 latest_ver = PullRequest.get_pr_display_object(pull_request_latest, pull_request_latest)
990 1006 c.versions = versions + [latest_ver]
991 1007
992 1008 c.at_version = at_version
993 1009 c.at_version_num = (at_version
994 1010 if at_version and at_version != PullRequest.LATEST_VER
995 1011 else None)
996 1012
997 1013 self.register_comments_vars(c, pull_request_latest, versions)
998 1014 all_comments = c.inline_comments_flat + c.comments
999 1015
1000 1016 existing_ids = filter(
1001 lambda e: e, map(safe_int, self.request.POST.getall('comments[]')))
1017 lambda e: e, map(safe_int, aslist(self.request.POST.get('comments'))))
1018
1002 1019 return _render('comments_table', all_comments, len(all_comments),
1003 1020 existing_ids=existing_ids)
1004 1021
1005 1022 @LoginRequired()
1006 1023 @NotAnonymous()
1007 1024 @HasRepoPermissionAnyDecorator(
1008 1025 'repository.read', 'repository.write', 'repository.admin')
1009 1026 @view_config(
1010 1027 route_name='pullrequest_todos', request_method='POST',
1011 renderer='string', xhr=True)
1028 renderer='string_html', xhr=True)
1012 1029 def pullrequest_todos(self):
1013 1030 self.load_default_context()
1014 1031
1015 1032 pull_request = PullRequest.get_or_404(
1016 1033 self.request.matchdict['pull_request_id'])
1017 1034 pull_request_id = pull_request.pull_request_id
1018 1035 version = self.request.GET.get('version')
1019 1036
1020 1037 _render = self.request.get_partial_renderer(
1021 1038 'rhodecode:templates/base/sidebar.mako')
1022 1039 c = _render.get_call_context()
1023 1040 (pull_request_latest,
1024 1041 pull_request_at_ver,
1025 1042 pull_request_display_obj,
1026 1043 at_version) = PullRequestModel().get_pr_version(
1027 1044 pull_request_id, version=version)
1028 1045 versions = pull_request_display_obj.versions()
1029 1046 latest_ver = PullRequest.get_pr_display_object(pull_request_latest, pull_request_latest)
1030 1047 c.versions = versions + [latest_ver]
1031 1048
1032 1049 c.at_version = at_version
1033 1050 c.at_version_num = (at_version
1034 1051 if at_version and at_version != PullRequest.LATEST_VER
1035 1052 else None)
1036 1053
1037 1054 c.unresolved_comments = CommentsModel() \
1038 1055 .get_pull_request_unresolved_todos(pull_request)
1039 1056 c.resolved_comments = CommentsModel() \
1040 1057 .get_pull_request_resolved_todos(pull_request)
1041 1058
1042 1059 all_comments = c.unresolved_comments + c.resolved_comments
1043 1060 existing_ids = filter(
1044 1061 lambda e: e, map(safe_int, self.request.POST.getall('comments[]')))
1045 1062 return _render('comments_table', all_comments, len(c.unresolved_comments),
1046 1063 todo_comments=True, existing_ids=existing_ids)
1047 1064
1048 1065 @LoginRequired()
1049 1066 @NotAnonymous()
1050 1067 @HasRepoPermissionAnyDecorator(
1051 1068 'repository.read', 'repository.write', 'repository.admin')
1052 1069 @CSRFRequired()
1053 1070 @view_config(
1054 1071 route_name='pullrequest_create', request_method='POST',
1055 1072 renderer=None)
1056 1073 def pull_request_create(self):
1057 1074 _ = self.request.translate
1058 1075 self.assure_not_empty_repo()
1059 1076 self.load_default_context()
1060 1077
1061 1078 controls = peppercorn.parse(self.request.POST.items())
1062 1079
1063 1080 try:
1064 1081 form = PullRequestForm(
1065 1082 self.request.translate, self.db_repo.repo_id)()
1066 1083 _form = form.to_python(controls)
1067 1084 except formencode.Invalid as errors:
1068 1085 if errors.error_dict.get('revisions'):
1069 1086 msg = 'Revisions: %s' % errors.error_dict['revisions']
1070 1087 elif errors.error_dict.get('pullrequest_title'):
1071 1088 msg = errors.error_dict.get('pullrequest_title')
1072 1089 else:
1073 1090 msg = _('Error creating pull request: {}').format(errors)
1074 1091 log.exception(msg)
1075 1092 h.flash(msg, 'error')
1076 1093
1077 1094 # would rather just go back to form ...
1078 1095 raise HTTPFound(
1079 1096 h.route_path('pullrequest_new', repo_name=self.db_repo_name))
1080 1097
1081 1098 source_repo = _form['source_repo']
1082 1099 source_ref = _form['source_ref']
1083 1100 target_repo = _form['target_repo']
1084 1101 target_ref = _form['target_ref']
1085 1102 commit_ids = _form['revisions'][::-1]
1086 1103 common_ancestor_id = _form['common_ancestor']
1087 1104
1088 1105 # find the ancestor for this pr
1089 1106 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
1090 1107 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
1091 1108
1092 1109 if not (source_db_repo or target_db_repo):
1093 1110 h.flash(_('source_repo or target repo not found'), category='error')
1094 1111 raise HTTPFound(
1095 1112 h.route_path('pullrequest_new', repo_name=self.db_repo_name))
1096 1113
1097 1114 # re-check permissions again here
1098 1115 # source_repo we must have read permissions
1099 1116
1100 1117 source_perm = HasRepoPermissionAny(
1101 1118 'repository.read', 'repository.write', 'repository.admin')(
1102 1119 source_db_repo.repo_name)
1103 1120 if not source_perm:
1104 1121 msg = _('Not Enough permissions to source repo `{}`.'.format(
1105 1122 source_db_repo.repo_name))
1106 1123 h.flash(msg, category='error')
1107 1124 # copy the args back to redirect
1108 1125 org_query = self.request.GET.mixed()
1109 1126 raise HTTPFound(
1110 1127 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
1111 1128 _query=org_query))
1112 1129
1113 1130 # target repo we must have read permissions, and also later on
1114 1131 # we want to check branch permissions here
1115 1132 target_perm = HasRepoPermissionAny(
1116 1133 'repository.read', 'repository.write', 'repository.admin')(
1117 1134 target_db_repo.repo_name)
1118 1135 if not target_perm:
1119 1136 msg = _('Not Enough permissions to target repo `{}`.'.format(
1120 1137 target_db_repo.repo_name))
1121 1138 h.flash(msg, category='error')
1122 1139 # copy the args back to redirect
1123 1140 org_query = self.request.GET.mixed()
1124 1141 raise HTTPFound(
1125 1142 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
1126 1143 _query=org_query))
1127 1144
1128 1145 source_scm = source_db_repo.scm_instance()
1129 1146 target_scm = target_db_repo.scm_instance()
1130 1147
1131 1148 source_commit = source_scm.get_commit(source_ref.split(':')[-1])
1132 1149 target_commit = target_scm.get_commit(target_ref.split(':')[-1])
1133 1150
1134 1151 ancestor = source_scm.get_common_ancestor(
1135 1152 source_commit.raw_id, target_commit.raw_id, target_scm)
1136 1153
1137 1154 # recalculate target ref based on ancestor
1138 1155 target_ref_type, target_ref_name, __ = _form['target_ref'].split(':')
1139 1156 target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
1140 1157
1141 get_default_reviewers_data, validate_default_reviewers = \
1158 get_default_reviewers_data, validate_default_reviewers, validate_observers = \
1142 1159 PullRequestModel().get_reviewer_functions()
1143 1160
1144 1161 # recalculate reviewers logic, to make sure we can validate this
1145 1162 reviewer_rules = get_default_reviewers_data(
1146 1163 self._rhodecode_db_user, source_db_repo,
1147 1164 source_commit, target_db_repo, target_commit)
1148 1165
1149 given_reviewers = _form['review_members']
1150 reviewers = validate_default_reviewers(
1151 given_reviewers, reviewer_rules)
1166 reviewers = validate_default_reviewers(_form['review_members'], reviewer_rules)
1167 observers = validate_observers(_form['observer_members'], reviewer_rules)
1152 1168
1153 1169 pullrequest_title = _form['pullrequest_title']
1154 1170 title_source_ref = source_ref.split(':', 2)[1]
1155 1171 if not pullrequest_title:
1156 1172 pullrequest_title = PullRequestModel().generate_pullrequest_title(
1157 1173 source=source_repo,
1158 1174 source_ref=title_source_ref,
1159 1175 target=target_repo
1160 1176 )
1161 1177
1162 1178 description = _form['pullrequest_desc']
1163 1179 description_renderer = _form['description_renderer']
1164 1180
1165 1181 try:
1166 1182 pull_request = PullRequestModel().create(
1167 1183 created_by=self._rhodecode_user.user_id,
1168 1184 source_repo=source_repo,
1169 1185 source_ref=source_ref,
1170 1186 target_repo=target_repo,
1171 1187 target_ref=target_ref,
1172 1188 revisions=commit_ids,
1173 1189 common_ancestor_id=common_ancestor_id,
1174 1190 reviewers=reviewers,
1191 observers=observers,
1175 1192 title=pullrequest_title,
1176 1193 description=description,
1177 1194 description_renderer=description_renderer,
1178 1195 reviewer_data=reviewer_rules,
1179 1196 auth_user=self._rhodecode_user
1180 1197 )
1181 1198 Session().commit()
1182 1199
1183 1200 h.flash(_('Successfully opened new pull request'),
1184 1201 category='success')
1185 1202 except Exception:
1186 1203 msg = _('Error occurred during creation of this pull request.')
1187 1204 log.exception(msg)
1188 1205 h.flash(msg, category='error')
1189 1206
1190 1207 # copy the args back to redirect
1191 1208 org_query = self.request.GET.mixed()
1192 1209 raise HTTPFound(
1193 1210 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
1194 1211 _query=org_query))
1195 1212
1196 1213 raise HTTPFound(
1197 1214 h.route_path('pullrequest_show', repo_name=target_repo,
1198 1215 pull_request_id=pull_request.pull_request_id))
1199 1216
1200 1217 @LoginRequired()
1201 1218 @NotAnonymous()
1202 1219 @HasRepoPermissionAnyDecorator(
1203 1220 'repository.read', 'repository.write', 'repository.admin')
1204 1221 @CSRFRequired()
1205 1222 @view_config(
1206 1223 route_name='pullrequest_update', request_method='POST',
1207 1224 renderer='json_ext')
1208 1225 def pull_request_update(self):
1209 1226 pull_request = PullRequest.get_or_404(
1210 1227 self.request.matchdict['pull_request_id'])
1211 1228 _ = self.request.translate
1212 1229
1213 1230 c = self.load_default_context()
1214 1231 redirect_url = None
1215 1232
1216 1233 if pull_request.is_closed():
1217 1234 log.debug('update: forbidden because pull request is closed')
1218 1235 msg = _(u'Cannot update closed pull requests.')
1219 1236 h.flash(msg, category='error')
1220 1237 return {'response': True,
1221 1238 'redirect_url': redirect_url}
1222 1239
1223 1240 is_state_changing = pull_request.is_state_changing()
1224 1241 c.pr_broadcast_channel = '/repo${}$/pr/{}'.format(
1225 1242 pull_request.target_repo.repo_name, pull_request.pull_request_id)
1226 1243
1227 1244 # only owner or admin can update it
1228 1245 allowed_to_update = PullRequestModel().check_user_update(
1229 1246 pull_request, self._rhodecode_user)
1247
1230 1248 if allowed_to_update:
1231 1249 controls = peppercorn.parse(self.request.POST.items())
1232 1250 force_refresh = str2bool(self.request.POST.get('force_refresh'))
1233 1251
1234 1252 if 'review_members' in controls:
1235 1253 self._update_reviewers(
1254 c,
1236 1255 pull_request, controls['review_members'],
1237 pull_request.reviewer_data)
1256 pull_request.reviewer_data,
1257 PullRequestReviewers.ROLE_REVIEWER)
1258 elif 'observer_members' in controls:
1259 self._update_reviewers(
1260 c,
1261 pull_request, controls['observer_members'],
1262 pull_request.reviewer_data,
1263 PullRequestReviewers.ROLE_OBSERVER)
1238 1264 elif str2bool(self.request.POST.get('update_commits', 'false')):
1239 1265 if is_state_changing:
1240 1266 log.debug('commits update: forbidden because pull request is in state %s',
1241 1267 pull_request.pull_request_state)
1242 1268 msg = _(u'Cannot update pull requests commits in state other than `{}`. '
1243 1269 u'Current state is: `{}`').format(
1244 1270 PullRequest.STATE_CREATED, pull_request.pull_request_state)
1245 1271 h.flash(msg, category='error')
1246 1272 return {'response': True,
1247 1273 'redirect_url': redirect_url}
1248 1274
1249 1275 self._update_commits(c, pull_request)
1250 1276 if force_refresh:
1251 1277 redirect_url = h.route_path(
1252 1278 'pullrequest_show', repo_name=self.db_repo_name,
1253 1279 pull_request_id=pull_request.pull_request_id,
1254 1280 _query={"force_refresh": 1})
1255 1281 elif str2bool(self.request.POST.get('edit_pull_request', 'false')):
1256 1282 self._edit_pull_request(pull_request)
1257 1283 else:
1284 log.error('Unhandled update data.')
1258 1285 raise HTTPBadRequest()
1259 1286
1260 1287 return {'response': True,
1261 1288 'redirect_url': redirect_url}
1262 1289 raise HTTPForbidden()
1263 1290
1264 1291 def _edit_pull_request(self, pull_request):
1292 """
1293 Edit title and description
1294 """
1265 1295 _ = self.request.translate
1266 1296
1267 1297 try:
1268 1298 PullRequestModel().edit(
1269 1299 pull_request,
1270 1300 self.request.POST.get('title'),
1271 1301 self.request.POST.get('description'),
1272 1302 self.request.POST.get('description_renderer'),
1273 1303 self._rhodecode_user)
1274 1304 except ValueError:
1275 1305 msg = _(u'Cannot update closed pull requests.')
1276 1306 h.flash(msg, category='error')
1277 1307 return
1278 1308 else:
1279 1309 Session().commit()
1280 1310
1281 1311 msg = _(u'Pull request title & description updated.')
1282 1312 h.flash(msg, category='success')
1283 1313 return
1284 1314
1285 1315 def _update_commits(self, c, pull_request):
1286 1316 _ = self.request.translate
1287 1317
1288 1318 with pull_request.set_state(PullRequest.STATE_UPDATING):
1289 1319 resp = PullRequestModel().update_commits(
1290 1320 pull_request, self._rhodecode_db_user)
1291 1321
1292 1322 if resp.executed:
1293 1323
1294 1324 if resp.target_changed and resp.source_changed:
1295 1325 changed = 'target and source repositories'
1296 1326 elif resp.target_changed and not resp.source_changed:
1297 1327 changed = 'target repository'
1298 1328 elif not resp.target_changed and resp.source_changed:
1299 1329 changed = 'source repository'
1300 1330 else:
1301 1331 changed = 'nothing'
1302 1332
1303 1333 msg = _(u'Pull request updated to "{source_commit_id}" with '
1304 1334 u'{count_added} added, {count_removed} removed commits. '
1305 u'Source of changes: {change_source}')
1335 u'Source of changes: {change_source}.')
1306 1336 msg = msg.format(
1307 1337 source_commit_id=pull_request.source_ref_parts.commit_id,
1308 1338 count_added=len(resp.changes.added),
1309 1339 count_removed=len(resp.changes.removed),
1310 1340 change_source=changed)
1311 1341 h.flash(msg, category='success')
1312
1313 message = msg + (
1314 ' - <a onclick="window.location.reload()">'
1315 '<strong>{}</strong></a>'.format(_('Reload page')))
1316
1317 message_obj = {
1318 'message': message,
1319 'level': 'success',
1320 'topic': '/notifications'
1321 }
1322
1323 channelstream.post_message(
1324 c.pr_broadcast_channel, message_obj, self._rhodecode_user.username,
1325 registry=self.request.registry)
1342 self._pr_update_channelstream_push(c.pr_broadcast_channel, msg)
1326 1343 else:
1327 1344 msg = PullRequestModel.UPDATE_STATUS_MESSAGES[resp.reason]
1328 1345 warning_reasons = [
1329 1346 UpdateFailureReason.NO_CHANGE,
1330 1347 UpdateFailureReason.WRONG_REF_TYPE,
1331 1348 ]
1332 1349 category = 'warning' if resp.reason in warning_reasons else 'error'
1333 1350 h.flash(msg, category=category)
1334 1351
1352 def _update_reviewers(self, c, pull_request, review_members, reviewer_rules, role):
1353 _ = self.request.translate
1354
1355 get_default_reviewers_data, validate_default_reviewers, validate_observers = \
1356 PullRequestModel().get_reviewer_functions()
1357
1358 if role == PullRequestReviewers.ROLE_REVIEWER:
1359 try:
1360 reviewers = validate_default_reviewers(review_members, reviewer_rules)
1361 except ValueError as e:
1362 log.error('Reviewers Validation: {}'.format(e))
1363 h.flash(e, category='error')
1364 return
1365
1366 old_calculated_status = pull_request.calculated_review_status()
1367 PullRequestModel().update_reviewers(
1368 pull_request, reviewers, self._rhodecode_user)
1369
1370 Session().commit()
1371
1372 msg = _('Pull request reviewers updated.')
1373 h.flash(msg, category='success')
1374 self._pr_update_channelstream_push(c.pr_broadcast_channel, msg)
1375
1376 # trigger status changed if change in reviewers changes the status
1377 calculated_status = pull_request.calculated_review_status()
1378 if old_calculated_status != calculated_status:
1379 PullRequestModel().trigger_pull_request_hook(
1380 pull_request, self._rhodecode_user, 'review_status_change',
1381 data={'status': calculated_status})
1382
1383 elif role == PullRequestReviewers.ROLE_OBSERVER:
1384 try:
1385 observers = validate_observers(review_members, reviewer_rules)
1386 except ValueError as e:
1387 log.error('Observers Validation: {}'.format(e))
1388 h.flash(e, category='error')
1389 return
1390
1391 PullRequestModel().update_observers(
1392 pull_request, observers, self._rhodecode_user)
1393
1394 Session().commit()
1395 msg = _('Pull request observers updated.')
1396 h.flash(msg, category='success')
1397 self._pr_update_channelstream_push(c.pr_broadcast_channel, msg)
1398
1335 1399 @LoginRequired()
1336 1400 @NotAnonymous()
1337 1401 @HasRepoPermissionAnyDecorator(
1338 1402 'repository.read', 'repository.write', 'repository.admin')
1339 1403 @CSRFRequired()
1340 1404 @view_config(
1341 1405 route_name='pullrequest_merge', request_method='POST',
1342 1406 renderer='json_ext')
1343 1407 def pull_request_merge(self):
1344 1408 """
1345 1409 Merge will perform a server-side merge of the specified
1346 1410 pull request, if the pull request is approved and mergeable.
1347 1411 After successful merging, the pull request is automatically
1348 1412 closed, with a relevant comment.
1349 1413 """
1350 1414 pull_request = PullRequest.get_or_404(
1351 1415 self.request.matchdict['pull_request_id'])
1352 1416 _ = self.request.translate
1353 1417
1354 1418 if pull_request.is_state_changing():
1355 1419 log.debug('show: forbidden because pull request is in state %s',
1356 1420 pull_request.pull_request_state)
1357 1421 msg = _(u'Cannot merge pull requests in state other than `{}`. '
1358 1422 u'Current state is: `{}`').format(PullRequest.STATE_CREATED,
1359 1423 pull_request.pull_request_state)
1360 1424 h.flash(msg, category='error')
1361 1425 raise HTTPFound(
1362 1426 h.route_path('pullrequest_show',
1363 1427 repo_name=pull_request.target_repo.repo_name,
1364 1428 pull_request_id=pull_request.pull_request_id))
1365 1429
1366 1430 self.load_default_context()
1367 1431
1368 1432 with pull_request.set_state(PullRequest.STATE_UPDATING):
1369 1433 check = MergeCheck.validate(
1370 1434 pull_request, auth_user=self._rhodecode_user,
1371 1435 translator=self.request.translate)
1372 1436 merge_possible = not check.failed
1373 1437
1374 1438 for err_type, error_msg in check.errors:
1375 1439 h.flash(error_msg, category=err_type)
1376 1440
1377 1441 if merge_possible:
1378 1442 log.debug("Pre-conditions checked, trying to merge.")
1379 1443 extras = vcs_operation_context(
1380 1444 self.request.environ, repo_name=pull_request.target_repo.repo_name,
1381 1445 username=self._rhodecode_db_user.username, action='push',
1382 1446 scm=pull_request.target_repo.repo_type)
1383 1447 with pull_request.set_state(PullRequest.STATE_UPDATING):
1384 1448 self._merge_pull_request(
1385 1449 pull_request, self._rhodecode_db_user, extras)
1386 1450 else:
1387 1451 log.debug("Pre-conditions failed, NOT merging.")
1388 1452
1389 1453 raise HTTPFound(
1390 1454 h.route_path('pullrequest_show',
1391 1455 repo_name=pull_request.target_repo.repo_name,
1392 1456 pull_request_id=pull_request.pull_request_id))
1393 1457
1394 1458 def _merge_pull_request(self, pull_request, user, extras):
1395 1459 _ = self.request.translate
1396 1460 merge_resp = PullRequestModel().merge_repo(pull_request, user, extras=extras)
1397 1461
1398 1462 if merge_resp.executed:
1399 1463 log.debug("The merge was successful, closing the pull request.")
1400 1464 PullRequestModel().close_pull_request(
1401 1465 pull_request.pull_request_id, user)
1402 1466 Session().commit()
1403 1467 msg = _('Pull request was successfully merged and closed.')
1404 1468 h.flash(msg, category='success')
1405 1469 else:
1406 1470 log.debug(
1407 1471 "The merge was not successful. Merge response: %s", merge_resp)
1408 1472 msg = merge_resp.merge_status_message
1409 1473 h.flash(msg, category='error')
1410 1474
1411 def _update_reviewers(self, pull_request, review_members, reviewer_rules):
1412 _ = self.request.translate
1413
1414 get_default_reviewers_data, validate_default_reviewers = \
1415 PullRequestModel().get_reviewer_functions()
1416
1417 try:
1418 reviewers = validate_default_reviewers(review_members, reviewer_rules)
1419 except ValueError as e:
1420 log.error('Reviewers Validation: {}'.format(e))
1421 h.flash(e, category='error')
1422 return
1423
1424 old_calculated_status = pull_request.calculated_review_status()
1425 PullRequestModel().update_reviewers(
1426 pull_request, reviewers, self._rhodecode_user)
1427 h.flash(_('Pull request reviewers updated.'), category='success')
1428 Session().commit()
1429
1430 # trigger status changed if change in reviewers changes the status
1431 calculated_status = pull_request.calculated_review_status()
1432 if old_calculated_status != calculated_status:
1433 PullRequestModel().trigger_pull_request_hook(
1434 pull_request, self._rhodecode_user, 'review_status_change',
1435 data={'status': calculated_status})
1436
1437 1475 @LoginRequired()
1438 1476 @NotAnonymous()
1439 1477 @HasRepoPermissionAnyDecorator(
1440 1478 'repository.read', 'repository.write', 'repository.admin')
1441 1479 @CSRFRequired()
1442 1480 @view_config(
1443 1481 route_name='pullrequest_delete', request_method='POST',
1444 1482 renderer='json_ext')
1445 1483 def pull_request_delete(self):
1446 1484 _ = self.request.translate
1447 1485
1448 1486 pull_request = PullRequest.get_or_404(
1449 1487 self.request.matchdict['pull_request_id'])
1450 1488 self.load_default_context()
1451 1489
1452 1490 pr_closed = pull_request.is_closed()
1453 1491 allowed_to_delete = PullRequestModel().check_user_delete(
1454 1492 pull_request, self._rhodecode_user) and not pr_closed
1455 1493
1456 1494 # only owner can delete it !
1457 1495 if allowed_to_delete:
1458 1496 PullRequestModel().delete(pull_request, self._rhodecode_user)
1459 1497 Session().commit()
1460 1498 h.flash(_('Successfully deleted pull request'),
1461 1499 category='success')
1462 1500 raise HTTPFound(h.route_path('pullrequest_show_all',
1463 1501 repo_name=self.db_repo_name))
1464 1502
1465 1503 log.warning('user %s tried to delete pull request without access',
1466 1504 self._rhodecode_user)
1467 1505 raise HTTPNotFound()
1468 1506
1469 1507 @LoginRequired()
1470 1508 @NotAnonymous()
1471 1509 @HasRepoPermissionAnyDecorator(
1472 1510 'repository.read', 'repository.write', 'repository.admin')
1473 1511 @CSRFRequired()
1474 1512 @view_config(
1475 1513 route_name='pullrequest_comment_create', request_method='POST',
1476 1514 renderer='json_ext')
1477 1515 def pull_request_comment_create(self):
1478 1516 _ = self.request.translate
1479 1517
1480 1518 pull_request = PullRequest.get_or_404(
1481 1519 self.request.matchdict['pull_request_id'])
1482 1520 pull_request_id = pull_request.pull_request_id
1483 1521
1484 1522 if pull_request.is_closed():
1485 1523 log.debug('comment: forbidden because pull request is closed')
1486 1524 raise HTTPForbidden()
1487 1525
1488 1526 allowed_to_comment = PullRequestModel().check_user_comment(
1489 1527 pull_request, self._rhodecode_user)
1490 1528 if not allowed_to_comment:
1491 log.debug(
1492 'comment: forbidden because pull request is from forbidden repo')
1529 log.debug('comment: forbidden because pull request is from forbidden repo')
1493 1530 raise HTTPForbidden()
1494 1531
1495 1532 c = self.load_default_context()
1496 1533
1497 1534 status = self.request.POST.get('changeset_status', None)
1498 1535 text = self.request.POST.get('text')
1499 1536 comment_type = self.request.POST.get('comment_type')
1500 1537 resolves_comment_id = self.request.POST.get('resolves_comment_id', None)
1501 1538 close_pull_request = self.request.POST.get('close_pull_request')
1502 1539
1503 1540 # the logic here should work like following, if we submit close
1504 1541 # pr comment, use `close_pull_request_with_comment` function
1505 1542 # else handle regular comment logic
1506 1543
1507 1544 if close_pull_request:
1508 1545 # only owner or admin or person with write permissions
1509 1546 allowed_to_close = PullRequestModel().check_user_update(
1510 1547 pull_request, self._rhodecode_user)
1511 1548 if not allowed_to_close:
1512 1549 log.debug('comment: forbidden because not allowed to close '
1513 1550 'pull request %s', pull_request_id)
1514 1551 raise HTTPForbidden()
1515 1552
1516 1553 # This also triggers `review_status_change`
1517 1554 comment, status = PullRequestModel().close_pull_request_with_comment(
1518 1555 pull_request, self._rhodecode_user, self.db_repo, message=text,
1519 1556 auth_user=self._rhodecode_user)
1520 1557 Session().flush()
1521 1558
1522 1559 PullRequestModel().trigger_pull_request_hook(
1523 1560 pull_request, self._rhodecode_user, 'comment',
1524 1561 data={'comment': comment})
1525 1562
1526 1563 else:
1527 1564 # regular comment case, could be inline, or one with status.
1528 1565 # for that one we check also permissions
1529 1566
1530 1567 allowed_to_change_status = PullRequestModel().check_user_change_status(
1531 1568 pull_request, self._rhodecode_user)
1532 1569
1533 1570 if status and allowed_to_change_status:
1534 1571 message = (_('Status change %(transition_icon)s %(status)s')
1535 1572 % {'transition_icon': '>',
1536 1573 'status': ChangesetStatus.get_status_lbl(status)})
1537 1574 text = text or message
1538 1575
1539 1576 comment = CommentsModel().create(
1540 1577 text=text,
1541 1578 repo=self.db_repo.repo_id,
1542 1579 user=self._rhodecode_user.user_id,
1543 1580 pull_request=pull_request,
1544 1581 f_path=self.request.POST.get('f_path'),
1545 1582 line_no=self.request.POST.get('line'),
1546 1583 status_change=(ChangesetStatus.get_status_lbl(status)
1547 1584 if status and allowed_to_change_status else None),
1548 1585 status_change_type=(status
1549 1586 if status and allowed_to_change_status else None),
1550 1587 comment_type=comment_type,
1551 1588 resolves_comment_id=resolves_comment_id,
1552 1589 auth_user=self._rhodecode_user
1553 1590 )
1554 1591
1555 1592 if allowed_to_change_status:
1556 1593 # calculate old status before we change it
1557 1594 old_calculated_status = pull_request.calculated_review_status()
1558 1595
1559 1596 # get status if set !
1560 1597 if status:
1561 1598 ChangesetStatusModel().set_status(
1562 1599 self.db_repo.repo_id,
1563 1600 status,
1564 1601 self._rhodecode_user.user_id,
1565 1602 comment,
1566 1603 pull_request=pull_request
1567 1604 )
1568 1605
1569 1606 Session().flush()
1570 1607 # this is somehow required to get access to some relationship
1571 1608 # loaded on comment
1572 1609 Session().refresh(comment)
1573 1610
1574 1611 PullRequestModel().trigger_pull_request_hook(
1575 1612 pull_request, self._rhodecode_user, 'comment',
1576 1613 data={'comment': comment})
1577 1614
1578 1615 # we now calculate the status of pull request, and based on that
1579 1616 # calculation we set the commits status
1580 1617 calculated_status = pull_request.calculated_review_status()
1581 1618 if old_calculated_status != calculated_status:
1582 1619 PullRequestModel().trigger_pull_request_hook(
1583 1620 pull_request, self._rhodecode_user, 'review_status_change',
1584 1621 data={'status': calculated_status})
1585 1622
1586 1623 Session().commit()
1587 1624
1588 1625 data = {
1589 1626 'target_id': h.safeid(h.safe_unicode(
1590 1627 self.request.POST.get('f_path'))),
1591 1628 }
1592 1629 if comment:
1593 1630 c.co = comment
1594 1631 c.at_version_num = None
1595 1632 rendered_comment = render(
1596 1633 'rhodecode:templates/changeset/changeset_comment_block.mako',
1597 1634 self._get_template_context(c), self.request)
1598 1635
1599 1636 data.update(comment.get_dict())
1600 1637 data.update({'rendered_text': rendered_comment})
1601 1638
1602 1639 return data
1603 1640
1604 1641 @LoginRequired()
1605 1642 @NotAnonymous()
1606 1643 @HasRepoPermissionAnyDecorator(
1607 1644 'repository.read', 'repository.write', 'repository.admin')
1608 1645 @CSRFRequired()
1609 1646 @view_config(
1610 1647 route_name='pullrequest_comment_delete', request_method='POST',
1611 1648 renderer='json_ext')
1612 1649 def pull_request_comment_delete(self):
1613 1650 pull_request = PullRequest.get_or_404(
1614 1651 self.request.matchdict['pull_request_id'])
1615 1652
1616 1653 comment = ChangesetComment.get_or_404(
1617 1654 self.request.matchdict['comment_id'])
1618 1655 comment_id = comment.comment_id
1619 1656
1620 1657 if comment.immutable:
1621 1658 # don't allow deleting comments that are immutable
1622 1659 raise HTTPForbidden()
1623 1660
1624 1661 if pull_request.is_closed():
1625 1662 log.debug('comment: forbidden because pull request is closed')
1626 1663 raise HTTPForbidden()
1627 1664
1628 1665 if not comment:
1629 1666 log.debug('Comment with id:%s not found, skipping', comment_id)
1630 1667 # comment already deleted in another call probably
1631 1668 return True
1632 1669
1633 1670 if comment.pull_request.is_closed():
1634 1671 # don't allow deleting comments on closed pull request
1635 1672 raise HTTPForbidden()
1636 1673
1637 1674 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
1638 1675 super_admin = h.HasPermissionAny('hg.admin')()
1639 1676 comment_owner = comment.author.user_id == self._rhodecode_user.user_id
1640 1677 is_repo_comment = comment.repo.repo_name == self.db_repo_name
1641 1678 comment_repo_admin = is_repo_admin and is_repo_comment
1642 1679
1643 1680 if super_admin or comment_owner or comment_repo_admin:
1644 1681 old_calculated_status = comment.pull_request.calculated_review_status()
1645 1682 CommentsModel().delete(comment=comment, auth_user=self._rhodecode_user)
1646 1683 Session().commit()
1647 1684 calculated_status = comment.pull_request.calculated_review_status()
1648 1685 if old_calculated_status != calculated_status:
1649 1686 PullRequestModel().trigger_pull_request_hook(
1650 1687 comment.pull_request, self._rhodecode_user, 'review_status_change',
1651 1688 data={'status': calculated_status})
1652 1689 return True
1653 1690 else:
1654 1691 log.warning('No permissions for user %s to delete comment_id: %s',
1655 1692 self._rhodecode_db_user, comment_id)
1656 1693 raise HTTPNotFound()
1657 1694
1658 1695 @LoginRequired()
1659 1696 @NotAnonymous()
1660 1697 @HasRepoPermissionAnyDecorator(
1661 1698 'repository.read', 'repository.write', 'repository.admin')
1662 1699 @CSRFRequired()
1663 1700 @view_config(
1664 1701 route_name='pullrequest_comment_edit', request_method='POST',
1665 1702 renderer='json_ext')
1666 1703 def pull_request_comment_edit(self):
1667 1704 self.load_default_context()
1668 1705
1669 1706 pull_request = PullRequest.get_or_404(
1670 1707 self.request.matchdict['pull_request_id']
1671 1708 )
1672 1709 comment = ChangesetComment.get_or_404(
1673 1710 self.request.matchdict['comment_id']
1674 1711 )
1675 1712 comment_id = comment.comment_id
1676 1713
1677 1714 if comment.immutable:
1678 1715 # don't allow deleting comments that are immutable
1679 1716 raise HTTPForbidden()
1680 1717
1681 1718 if pull_request.is_closed():
1682 1719 log.debug('comment: forbidden because pull request is closed')
1683 1720 raise HTTPForbidden()
1684 1721
1685 1722 if not comment:
1686 1723 log.debug('Comment with id:%s not found, skipping', comment_id)
1687 1724 # comment already deleted in another call probably
1688 1725 return True
1689 1726
1690 1727 if comment.pull_request.is_closed():
1691 1728 # don't allow deleting comments on closed pull request
1692 1729 raise HTTPForbidden()
1693 1730
1694 1731 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
1695 1732 super_admin = h.HasPermissionAny('hg.admin')()
1696 1733 comment_owner = comment.author.user_id == self._rhodecode_user.user_id
1697 1734 is_repo_comment = comment.repo.repo_name == self.db_repo_name
1698 1735 comment_repo_admin = is_repo_admin and is_repo_comment
1699 1736
1700 1737 if super_admin or comment_owner or comment_repo_admin:
1701 1738 text = self.request.POST.get('text')
1702 1739 version = self.request.POST.get('version')
1703 1740 if text == comment.text:
1704 1741 log.warning(
1705 1742 'Comment(PR): '
1706 1743 'Trying to create new version '
1707 1744 'with the same comment body {}'.format(
1708 1745 comment_id,
1709 1746 )
1710 1747 )
1711 1748 raise HTTPNotFound()
1712 1749
1713 1750 if version.isdigit():
1714 1751 version = int(version)
1715 1752 else:
1716 1753 log.warning(
1717 1754 'Comment(PR): Wrong version type {} {} '
1718 1755 'for comment {}'.format(
1719 1756 version,
1720 1757 type(version),
1721 1758 comment_id,
1722 1759 )
1723 1760 )
1724 1761 raise HTTPNotFound()
1725 1762
1726 1763 try:
1727 1764 comment_history = CommentsModel().edit(
1728 1765 comment_id=comment_id,
1729 1766 text=text,
1730 1767 auth_user=self._rhodecode_user,
1731 1768 version=version,
1732 1769 )
1733 1770 except CommentVersionMismatch:
1734 1771 raise HTTPConflict()
1735 1772
1736 1773 if not comment_history:
1737 1774 raise HTTPNotFound()
1738 1775
1739 1776 Session().commit()
1740 1777
1741 1778 PullRequestModel().trigger_pull_request_hook(
1742 1779 pull_request, self._rhodecode_user, 'comment_edit',
1743 1780 data={'comment': comment})
1744 1781
1745 1782 return {
1746 1783 'comment_history_id': comment_history.comment_history_id,
1747 1784 'comment_id': comment.comment_id,
1748 1785 'comment_version': comment_history.version,
1749 1786 'comment_author_username': comment_history.author.username,
1750 1787 'comment_author_gravatar': h.gravatar_url(comment_history.author.email, 16),
1751 1788 'comment_created_on': h.age_component(comment_history.created_on,
1752 1789 time_is_local=True),
1753 1790 }
1754 1791 else:
1755 1792 log.warning('No permissions for user %s to edit comment_id: %s',
1756 1793 self._rhodecode_db_user, comment_id)
1757 1794 raise HTTPNotFound()
@@ -1,763 +1,767 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import os
22 22 import sys
23 23 import logging
24 24 import collections
25 25 import tempfile
26 26 import time
27 27
28 28 from paste.gzipper import make_gzip_middleware
29 29 import pyramid.events
30 30 from pyramid.wsgi import wsgiapp
31 31 from pyramid.authorization import ACLAuthorizationPolicy
32 32 from pyramid.config import Configurator
33 33 from pyramid.settings import asbool, aslist
34 34 from pyramid.httpexceptions import (
35 35 HTTPException, HTTPError, HTTPInternalServerError, HTTPFound, HTTPNotFound)
36 36 from pyramid.renderers import render_to_response
37 37
38 38 from rhodecode.model import meta
39 39 from rhodecode.config import patches
40 40 from rhodecode.config import utils as config_utils
41 41 from rhodecode.config.environment import load_pyramid_environment
42 42
43 43 import rhodecode.events
44 44 from rhodecode.lib.middleware.vcs import VCSMiddleware
45 45 from rhodecode.lib.request import Request
46 46 from rhodecode.lib.vcs import VCSCommunicationError
47 47 from rhodecode.lib.exceptions import VCSServerUnavailable
48 48 from rhodecode.lib.middleware.appenlight import wrap_in_appenlight_if_enabled
49 49 from rhodecode.lib.middleware.https_fixup import HttpsFixup
50 50 from rhodecode.lib.celerylib.loader import configure_celery
51 51 from rhodecode.lib.plugins.utils import register_rhodecode_plugin
52 52 from rhodecode.lib.utils2 import aslist as rhodecode_aslist, AttributeDict
53 53 from rhodecode.lib.exc_tracking import store_exception
54 54 from rhodecode.subscribers import (
55 55 scan_repositories_if_enabled, write_js_routes_if_enabled,
56 56 write_metadata_if_needed, write_usage_data, inject_app_settings)
57 57
58 58
59 59 log = logging.getLogger(__name__)
60 60
61 61
62 62 def is_http_error(response):
63 63 # error which should have traceback
64 64 return response.status_code > 499
65 65
66 66
67 67 def should_load_all():
68 68 """
69 69 Returns if all application components should be loaded. In some cases it's
70 70 desired to skip apps loading for faster shell script execution
71 71 """
72 72 ssh_cmd = os.environ.get('RC_CMD_SSH_WRAPPER')
73 73 if ssh_cmd:
74 74 return False
75 75
76 76 return True
77 77
78 78
79 79 def make_pyramid_app(global_config, **settings):
80 80 """
81 81 Constructs the WSGI application based on Pyramid.
82 82
83 83 Specials:
84 84
85 85 * The application can also be integrated like a plugin via the call to
86 86 `includeme`. This is accompanied with the other utility functions which
87 87 are called. Changing this should be done with great care to not break
88 88 cases when these fragments are assembled from another place.
89 89
90 90 """
91 91
92 92 # Allows to use format style "{ENV_NAME}" placeholders in the configuration. It
93 93 # will be replaced by the value of the environment variable "NAME" in this case.
94 94 start_time = time.time()
95 95
96 96 debug = asbool(global_config.get('debug'))
97 97 if debug:
98 98 enable_debug()
99 99
100 100 environ = {'ENV_{}'.format(key): value for key, value in os.environ.items()}
101 101
102 102 global_config = _substitute_values(global_config, environ)
103 103 settings = _substitute_values(settings, environ)
104 104
105 105 sanitize_settings_and_apply_defaults(global_config, settings)
106 106
107 107 config = Configurator(settings=settings)
108 108
109 109 # Apply compatibility patches
110 110 patches.inspect_getargspec()
111 111
112 112 load_pyramid_environment(global_config, settings)
113 113
114 114 # Static file view comes first
115 115 includeme_first(config)
116 116
117 117 includeme(config)
118 118
119 119 pyramid_app = config.make_wsgi_app()
120 120 pyramid_app = wrap_app_in_wsgi_middlewares(pyramid_app, config)
121 121 pyramid_app.config = config
122 122
123 123 config.configure_celery(global_config['__file__'])
124 124 # creating the app uses a connection - return it after we are done
125 125 meta.Session.remove()
126 126 total_time = time.time() - start_time
127 127 log.info('Pyramid app `%s` created and configured in %.2fs',
128 128 pyramid_app.func_name, total_time)
129 129
130 130 return pyramid_app
131 131
132 132
133 133 def not_found_view(request):
134 134 """
135 135 This creates the view which should be registered as not-found-view to
136 136 pyramid.
137 137 """
138 138
139 139 if not getattr(request, 'vcs_call', None):
140 140 # handle like regular case with our error_handler
141 141 return error_handler(HTTPNotFound(), request)
142 142
143 143 # handle not found view as a vcs call
144 144 settings = request.registry.settings
145 145 ae_client = getattr(request, 'ae_client', None)
146 146 vcs_app = VCSMiddleware(
147 147 HTTPNotFound(), request.registry, settings,
148 148 appenlight_client=ae_client)
149 149
150 150 return wsgiapp(vcs_app)(None, request)
151 151
152 152
153 153 def error_handler(exception, request):
154 154 import rhodecode
155 155 from rhodecode.lib import helpers
156 156
157 157 rhodecode_title = rhodecode.CONFIG.get('rhodecode_title') or 'RhodeCode'
158 158
159 159 base_response = HTTPInternalServerError()
160 160 # prefer original exception for the response since it may have headers set
161 161 if isinstance(exception, HTTPException):
162 162 base_response = exception
163 163 elif isinstance(exception, VCSCommunicationError):
164 164 base_response = VCSServerUnavailable()
165 165
166 166 if is_http_error(base_response):
167 167 log.exception(
168 168 'error occurred handling this request for path: %s', request.path)
169 169
170 170 error_explanation = base_response.explanation or str(base_response)
171 171 if base_response.status_code == 404:
172 172 error_explanation += " Optionally you don't have permission to access this page."
173 173 c = AttributeDict()
174 174 c.error_message = base_response.status
175 175 c.error_explanation = error_explanation
176 176 c.visual = AttributeDict()
177 177
178 178 c.visual.rhodecode_support_url = (
179 179 request.registry.settings.get('rhodecode_support_url') or
180 180 request.route_url('rhodecode_support')
181 181 )
182 182 c.redirect_time = 0
183 183 c.rhodecode_name = rhodecode_title
184 184 if not c.rhodecode_name:
185 185 c.rhodecode_name = 'Rhodecode'
186 186
187 187 c.causes = []
188 188 if is_http_error(base_response):
189 189 c.causes.append('Server is overloaded.')
190 190 c.causes.append('Server database connection is lost.')
191 191 c.causes.append('Server expected unhandled error.')
192 192
193 193 if hasattr(base_response, 'causes'):
194 194 c.causes = base_response.causes
195 195
196 196 c.messages = helpers.flash.pop_messages(request=request)
197 197
198 198 exc_info = sys.exc_info()
199 199 c.exception_id = id(exc_info)
200 200 c.show_exception_id = isinstance(base_response, VCSServerUnavailable) \
201 201 or base_response.status_code > 499
202 202 c.exception_id_url = request.route_url(
203 203 'admin_settings_exception_tracker_show', exception_id=c.exception_id)
204 204
205 205 if c.show_exception_id:
206 206 store_exception(c.exception_id, exc_info)
207 207
208 208 response = render_to_response(
209 209 '/errors/error_document.mako', {'c': c, 'h': helpers}, request=request,
210 210 response=base_response)
211 211
212 212 return response
213 213
214 214
215 215 def includeme_first(config):
216 216 # redirect automatic browser favicon.ico requests to correct place
217 217 def favicon_redirect(context, request):
218 218 return HTTPFound(
219 219 request.static_path('rhodecode:public/images/favicon.ico'))
220 220
221 221 config.add_view(favicon_redirect, route_name='favicon')
222 222 config.add_route('favicon', '/favicon.ico')
223 223
224 224 def robots_redirect(context, request):
225 225 return HTTPFound(
226 226 request.static_path('rhodecode:public/robots.txt'))
227 227
228 228 config.add_view(robots_redirect, route_name='robots')
229 229 config.add_route('robots', '/robots.txt')
230 230
231 231 config.add_static_view(
232 232 '_static/deform', 'deform:static')
233 233 config.add_static_view(
234 234 '_static/rhodecode', path='rhodecode:public', cache_max_age=3600 * 24)
235 235
236 236
237 237 def includeme(config):
238 238 log.debug('Initializing main includeme from %s', os.path.basename(__file__))
239 239 settings = config.registry.settings
240 240 config.set_request_factory(Request)
241 241
242 242 # plugin information
243 243 config.registry.rhodecode_plugins = collections.OrderedDict()
244 244
245 245 config.add_directive(
246 246 'register_rhodecode_plugin', register_rhodecode_plugin)
247 247
248 248 config.add_directive('configure_celery', configure_celery)
249 249
250 250 if asbool(settings.get('appenlight', 'false')):
251 251 config.include('appenlight_client.ext.pyramid_tween')
252 252
253 253 load_all = should_load_all()
254 254
255 255 # Includes which are required. The application would fail without them.
256 256 config.include('pyramid_mako')
257 257 config.include('rhodecode.lib.rc_beaker')
258 258 config.include('rhodecode.lib.rc_cache')
259 259
260 260 config.include('rhodecode.apps._base.navigation')
261 261 config.include('rhodecode.apps._base.subscribers')
262 262 config.include('rhodecode.tweens')
263 263 config.include('rhodecode.authentication')
264 264
265 265 if load_all:
266 266 config.include('rhodecode.integrations')
267 267
268 268 if load_all:
269 269 # load CE authentication plugins
270 270 config.include('rhodecode.authentication.plugins.auth_crowd')
271 271 config.include('rhodecode.authentication.plugins.auth_headers')
272 272 config.include('rhodecode.authentication.plugins.auth_jasig_cas')
273 273 config.include('rhodecode.authentication.plugins.auth_ldap')
274 274 config.include('rhodecode.authentication.plugins.auth_pam')
275 275 config.include('rhodecode.authentication.plugins.auth_rhodecode')
276 276 config.include('rhodecode.authentication.plugins.auth_token')
277 277
278 278 # Auto discover authentication plugins and include their configuration.
279 279 if asbool(settings.get('auth_plugin.import_legacy_plugins', 'true')):
280 280 from rhodecode.authentication import discover_legacy_plugins
281 281 discover_legacy_plugins(config)
282 282
283 283 # apps
284 284 if load_all:
285 285 config.include('rhodecode.apps._base')
286 286 config.include('rhodecode.apps.hovercards')
287 287 config.include('rhodecode.apps.ops')
288 288 config.include('rhodecode.apps.admin')
289 289 config.include('rhodecode.apps.channelstream')
290 290 config.include('rhodecode.apps.file_store')
291 291 config.include('rhodecode.apps.login')
292 292 config.include('rhodecode.apps.home')
293 293 config.include('rhodecode.apps.journal')
294 294 config.include('rhodecode.apps.repository')
295 295 config.include('rhodecode.apps.repo_group')
296 296 config.include('rhodecode.apps.user_group')
297 297 config.include('rhodecode.apps.search')
298 298 config.include('rhodecode.apps.user_profile')
299 299 config.include('rhodecode.apps.user_group_profile')
300 300 config.include('rhodecode.apps.my_account')
301 301 config.include('rhodecode.apps.svn_support')
302 302 config.include('rhodecode.apps.ssh_support')
303 303 config.include('rhodecode.apps.gist')
304 304 config.include('rhodecode.apps.debug_style')
305 305 config.include('rhodecode.api')
306 306
307 307 config.add_route('rhodecode_support', 'https://rhodecode.com/help/', static=True)
308 308 config.add_translation_dirs('rhodecode:i18n/')
309 309 settings['default_locale_name'] = settings.get('lang', 'en')
310 310
311 311 # Add subscribers.
312 312 if load_all:
313 313 config.add_subscriber(inject_app_settings,
314 314 pyramid.events.ApplicationCreated)
315 315 config.add_subscriber(scan_repositories_if_enabled,
316 316 pyramid.events.ApplicationCreated)
317 317 config.add_subscriber(write_metadata_if_needed,
318 318 pyramid.events.ApplicationCreated)
319 319 config.add_subscriber(write_usage_data,
320 320 pyramid.events.ApplicationCreated)
321 321 config.add_subscriber(write_js_routes_if_enabled,
322 322 pyramid.events.ApplicationCreated)
323 323
324 324 # request custom methods
325 325 config.add_request_method(
326 326 'rhodecode.lib.partial_renderer.get_partial_renderer',
327 327 'get_partial_renderer')
328 328
329 329 config.add_request_method(
330 330 'rhodecode.lib.request_counter.get_request_counter',
331 331 'request_count')
332 332
333 333 # Set the authorization policy.
334 334 authz_policy = ACLAuthorizationPolicy()
335 335 config.set_authorization_policy(authz_policy)
336 336
337 337 # Set the default renderer for HTML templates to mako.
338 338 config.add_mako_renderer('.html')
339 339
340 340 config.add_renderer(
341 341 name='json_ext',
342 342 factory='rhodecode.lib.ext_json_renderer.pyramid_ext_json')
343 343
344 config.add_renderer(
345 name='string_html',
346 factory='rhodecode.lib.string_renderer.html')
347
344 348 # include RhodeCode plugins
345 349 includes = aslist(settings.get('rhodecode.includes', []))
346 350 for inc in includes:
347 351 config.include(inc)
348 352
349 353 # custom not found view, if our pyramid app doesn't know how to handle
350 354 # the request pass it to potential VCS handling ap
351 355 config.add_notfound_view(not_found_view)
352 356 if not settings.get('debugtoolbar.enabled', False):
353 357 # disabled debugtoolbar handle all exceptions via the error_handlers
354 358 config.add_view(error_handler, context=Exception)
355 359
356 360 # all errors including 403/404/50X
357 361 config.add_view(error_handler, context=HTTPError)
358 362
359 363
360 364 def wrap_app_in_wsgi_middlewares(pyramid_app, config):
361 365 """
362 366 Apply outer WSGI middlewares around the application.
363 367 """
364 368 registry = config.registry
365 369 settings = registry.settings
366 370
367 371 # enable https redirects based on HTTP_X_URL_SCHEME set by proxy
368 372 pyramid_app = HttpsFixup(pyramid_app, settings)
369 373
370 374 pyramid_app, _ae_client = wrap_in_appenlight_if_enabled(
371 375 pyramid_app, settings)
372 376 registry.ae_client = _ae_client
373 377
374 378 if settings['gzip_responses']:
375 379 pyramid_app = make_gzip_middleware(
376 380 pyramid_app, settings, compress_level=1)
377 381
378 382 # this should be the outer most middleware in the wsgi stack since
379 383 # middleware like Routes make database calls
380 384 def pyramid_app_with_cleanup(environ, start_response):
381 385 try:
382 386 return pyramid_app(environ, start_response)
383 387 finally:
384 388 # Dispose current database session and rollback uncommitted
385 389 # transactions.
386 390 meta.Session.remove()
387 391
388 392 # In a single threaded mode server, on non sqlite db we should have
389 393 # '0 Current Checked out connections' at the end of a request,
390 394 # if not, then something, somewhere is leaving a connection open
391 395 pool = meta.Base.metadata.bind.engine.pool
392 396 log.debug('sa pool status: %s', pool.status())
393 397 log.debug('Request processing finalized')
394 398
395 399 return pyramid_app_with_cleanup
396 400
397 401
398 402 def sanitize_settings_and_apply_defaults(global_config, settings):
399 403 """
400 404 Applies settings defaults and does all type conversion.
401 405
402 406 We would move all settings parsing and preparation into this place, so that
403 407 we have only one place left which deals with this part. The remaining parts
404 408 of the application would start to rely fully on well prepared settings.
405 409
406 410 This piece would later be split up per topic to avoid a big fat monster
407 411 function.
408 412 """
409 413
410 414 settings.setdefault('rhodecode.edition', 'Community Edition')
411 415
412 416 if 'mako.default_filters' not in settings:
413 417 # set custom default filters if we don't have it defined
414 418 settings['mako.imports'] = 'from rhodecode.lib.base import h_filter'
415 419 settings['mako.default_filters'] = 'h_filter'
416 420
417 421 if 'mako.directories' not in settings:
418 422 mako_directories = settings.setdefault('mako.directories', [
419 423 # Base templates of the original application
420 424 'rhodecode:templates',
421 425 ])
422 426 log.debug(
423 427 "Using the following Mako template directories: %s",
424 428 mako_directories)
425 429
426 430 # NOTE(marcink): fix redis requirement for schema of connection since 3.X
427 431 if 'beaker.session.type' in settings and settings['beaker.session.type'] == 'ext:redis':
428 432 raw_url = settings['beaker.session.url']
429 433 if not raw_url.startswith(('redis://', 'rediss://', 'unix://')):
430 434 settings['beaker.session.url'] = 'redis://' + raw_url
431 435
432 436 # Default includes, possible to change as a user
433 437 pyramid_includes = settings.setdefault('pyramid.includes', [])
434 438 log.debug(
435 439 "Using the following pyramid.includes: %s",
436 440 pyramid_includes)
437 441
438 442 # TODO: johbo: Re-think this, usually the call to config.include
439 443 # should allow to pass in a prefix.
440 444 settings.setdefault('rhodecode.api.url', '/_admin/api')
441 445 settings.setdefault('__file__', global_config.get('__file__'))
442 446
443 447 # Sanitize generic settings.
444 448 _list_setting(settings, 'default_encoding', 'UTF-8')
445 449 _bool_setting(settings, 'is_test', 'false')
446 450 _bool_setting(settings, 'gzip_responses', 'false')
447 451
448 452 # Call split out functions that sanitize settings for each topic.
449 453 _sanitize_appenlight_settings(settings)
450 454 _sanitize_vcs_settings(settings)
451 455 _sanitize_cache_settings(settings)
452 456
453 457 # configure instance id
454 458 config_utils.set_instance_id(settings)
455 459
456 460 return settings
457 461
458 462
459 463 def enable_debug():
460 464 """
461 465 Helper to enable debug on running instance
462 466 :return:
463 467 """
464 468 import tempfile
465 469 import textwrap
466 470 import logging.config
467 471
468 472 ini_template = textwrap.dedent("""
469 473 #####################################
470 474 ### DEBUG LOGGING CONFIGURATION ####
471 475 #####################################
472 476 [loggers]
473 477 keys = root, sqlalchemy, beaker, celery, rhodecode, ssh_wrapper
474 478
475 479 [handlers]
476 480 keys = console, console_sql
477 481
478 482 [formatters]
479 483 keys = generic, color_formatter, color_formatter_sql
480 484
481 485 #############
482 486 ## LOGGERS ##
483 487 #############
484 488 [logger_root]
485 489 level = NOTSET
486 490 handlers = console
487 491
488 492 [logger_sqlalchemy]
489 493 level = INFO
490 494 handlers = console_sql
491 495 qualname = sqlalchemy.engine
492 496 propagate = 0
493 497
494 498 [logger_beaker]
495 499 level = DEBUG
496 500 handlers =
497 501 qualname = beaker.container
498 502 propagate = 1
499 503
500 504 [logger_rhodecode]
501 505 level = DEBUG
502 506 handlers =
503 507 qualname = rhodecode
504 508 propagate = 1
505 509
506 510 [logger_ssh_wrapper]
507 511 level = DEBUG
508 512 handlers =
509 513 qualname = ssh_wrapper
510 514 propagate = 1
511 515
512 516 [logger_celery]
513 517 level = DEBUG
514 518 handlers =
515 519 qualname = celery
516 520
517 521
518 522 ##############
519 523 ## HANDLERS ##
520 524 ##############
521 525
522 526 [handler_console]
523 527 class = StreamHandler
524 528 args = (sys.stderr, )
525 529 level = DEBUG
526 530 formatter = color_formatter
527 531
528 532 [handler_console_sql]
529 533 # "level = DEBUG" logs SQL queries and results.
530 534 # "level = INFO" logs SQL queries.
531 535 # "level = WARN" logs neither. (Recommended for production systems.)
532 536 class = StreamHandler
533 537 args = (sys.stderr, )
534 538 level = WARN
535 539 formatter = color_formatter_sql
536 540
537 541 ################
538 542 ## FORMATTERS ##
539 543 ################
540 544
541 545 [formatter_generic]
542 546 class = rhodecode.lib.logging_formatter.ExceptionAwareFormatter
543 547 format = %(asctime)s.%(msecs)03d [%(process)d] %(levelname)-5.5s [%(name)s] %(message)s | %(req_id)s
544 548 datefmt = %Y-%m-%d %H:%M:%S
545 549
546 550 [formatter_color_formatter]
547 551 class = rhodecode.lib.logging_formatter.ColorRequestTrackingFormatter
548 552 format = %(asctime)s.%(msecs)03d [%(process)d] %(levelname)-5.5s [%(name)s] %(message)s | %(req_id)s
549 553 datefmt = %Y-%m-%d %H:%M:%S
550 554
551 555 [formatter_color_formatter_sql]
552 556 class = rhodecode.lib.logging_formatter.ColorFormatterSql
553 557 format = %(asctime)s.%(msecs)03d [%(process)d] %(levelname)-5.5s [%(name)s] %(message)s
554 558 datefmt = %Y-%m-%d %H:%M:%S
555 559 """)
556 560
557 561 with tempfile.NamedTemporaryFile(prefix='rc_debug_logging_', suffix='.ini',
558 562 delete=False) as f:
559 563 log.info('Saved Temporary DEBUG config at %s', f.name)
560 564 f.write(ini_template)
561 565
562 566 logging.config.fileConfig(f.name)
563 567 log.debug('DEBUG MODE ON')
564 568 os.remove(f.name)
565 569
566 570
567 571 def _sanitize_appenlight_settings(settings):
568 572 _bool_setting(settings, 'appenlight', 'false')
569 573
570 574
571 575 def _sanitize_vcs_settings(settings):
572 576 """
573 577 Applies settings defaults and does type conversion for all VCS related
574 578 settings.
575 579 """
576 580 _string_setting(settings, 'vcs.svn.compatible_version', '')
577 581 _string_setting(settings, 'vcs.hooks.protocol', 'http')
578 582 _string_setting(settings, 'vcs.hooks.host', '127.0.0.1')
579 583 _string_setting(settings, 'vcs.scm_app_implementation', 'http')
580 584 _string_setting(settings, 'vcs.server', '')
581 585 _string_setting(settings, 'vcs.server.protocol', 'http')
582 586 _bool_setting(settings, 'startup.import_repos', 'false')
583 587 _bool_setting(settings, 'vcs.hooks.direct_calls', 'false')
584 588 _bool_setting(settings, 'vcs.server.enable', 'true')
585 589 _bool_setting(settings, 'vcs.start_server', 'false')
586 590 _list_setting(settings, 'vcs.backends', 'hg, git, svn')
587 591 _int_setting(settings, 'vcs.connection_timeout', 3600)
588 592
589 593 # Support legacy values of vcs.scm_app_implementation. Legacy
590 594 # configurations may use 'rhodecode.lib.middleware.utils.scm_app_http', or
591 595 # disabled since 4.13 'vcsserver.scm_app' which is now mapped to 'http'.
592 596 scm_app_impl = settings['vcs.scm_app_implementation']
593 597 if scm_app_impl in ['rhodecode.lib.middleware.utils.scm_app_http', 'vcsserver.scm_app']:
594 598 settings['vcs.scm_app_implementation'] = 'http'
595 599
596 600
597 601 def _sanitize_cache_settings(settings):
598 602 temp_store = tempfile.gettempdir()
599 603 default_cache_dir = os.path.join(temp_store, 'rc_cache')
600 604
601 605 # save default, cache dir, and use it for all backends later.
602 606 default_cache_dir = _string_setting(
603 607 settings,
604 608 'cache_dir',
605 609 default_cache_dir, lower=False, default_when_empty=True)
606 610
607 611 # ensure we have our dir created
608 612 if not os.path.isdir(default_cache_dir):
609 613 os.makedirs(default_cache_dir, mode=0o755)
610 614
611 615 # exception store cache
612 616 _string_setting(
613 617 settings,
614 618 'exception_tracker.store_path',
615 619 temp_store, lower=False, default_when_empty=True)
616 620 _bool_setting(
617 621 settings,
618 622 'exception_tracker.send_email',
619 623 'false')
620 624 _string_setting(
621 625 settings,
622 626 'exception_tracker.email_prefix',
623 627 '[RHODECODE ERROR]', lower=False, default_when_empty=True)
624 628
625 629 # cache_perms
626 630 _string_setting(
627 631 settings,
628 632 'rc_cache.cache_perms.backend',
629 633 'dogpile.cache.rc.file_namespace', lower=False)
630 634 _int_setting(
631 635 settings,
632 636 'rc_cache.cache_perms.expiration_time',
633 637 60)
634 638 _string_setting(
635 639 settings,
636 640 'rc_cache.cache_perms.arguments.filename',
637 641 os.path.join(default_cache_dir, 'rc_cache_1'), lower=False)
638 642
639 643 # cache_repo
640 644 _string_setting(
641 645 settings,
642 646 'rc_cache.cache_repo.backend',
643 647 'dogpile.cache.rc.file_namespace', lower=False)
644 648 _int_setting(
645 649 settings,
646 650 'rc_cache.cache_repo.expiration_time',
647 651 60)
648 652 _string_setting(
649 653 settings,
650 654 'rc_cache.cache_repo.arguments.filename',
651 655 os.path.join(default_cache_dir, 'rc_cache_2'), lower=False)
652 656
653 657 # cache_license
654 658 _string_setting(
655 659 settings,
656 660 'rc_cache.cache_license.backend',
657 661 'dogpile.cache.rc.file_namespace', lower=False)
658 662 _int_setting(
659 663 settings,
660 664 'rc_cache.cache_license.expiration_time',
661 665 5*60)
662 666 _string_setting(
663 667 settings,
664 668 'rc_cache.cache_license.arguments.filename',
665 669 os.path.join(default_cache_dir, 'rc_cache_3'), lower=False)
666 670
667 671 # cache_repo_longterm memory, 96H
668 672 _string_setting(
669 673 settings,
670 674 'rc_cache.cache_repo_longterm.backend',
671 675 'dogpile.cache.rc.memory_lru', lower=False)
672 676 _int_setting(
673 677 settings,
674 678 'rc_cache.cache_repo_longterm.expiration_time',
675 679 345600)
676 680 _int_setting(
677 681 settings,
678 682 'rc_cache.cache_repo_longterm.max_size',
679 683 10000)
680 684
681 685 # sql_cache_short
682 686 _string_setting(
683 687 settings,
684 688 'rc_cache.sql_cache_short.backend',
685 689 'dogpile.cache.rc.memory_lru', lower=False)
686 690 _int_setting(
687 691 settings,
688 692 'rc_cache.sql_cache_short.expiration_time',
689 693 30)
690 694 _int_setting(
691 695 settings,
692 696 'rc_cache.sql_cache_short.max_size',
693 697 10000)
694 698
695 699
696 700 def _int_setting(settings, name, default):
697 701 settings[name] = int(settings.get(name, default))
698 702 return settings[name]
699 703
700 704
701 705 def _bool_setting(settings, name, default):
702 706 input_val = settings.get(name, default)
703 707 if isinstance(input_val, unicode):
704 708 input_val = input_val.encode('utf8')
705 709 settings[name] = asbool(input_val)
706 710 return settings[name]
707 711
708 712
709 713 def _list_setting(settings, name, default):
710 714 raw_value = settings.get(name, default)
711 715
712 716 old_separator = ','
713 717 if old_separator in raw_value:
714 718 # If we get a comma separated list, pass it to our own function.
715 719 settings[name] = rhodecode_aslist(raw_value, sep=old_separator)
716 720 else:
717 721 # Otherwise we assume it uses pyramids space/newline separation.
718 722 settings[name] = aslist(raw_value)
719 723 return settings[name]
720 724
721 725
722 726 def _string_setting(settings, name, default, lower=True, default_when_empty=False):
723 727 value = settings.get(name, default)
724 728
725 729 if default_when_empty and not value:
726 730 # use default value when value is empty
727 731 value = default
728 732
729 733 if lower:
730 734 value = value.lower()
731 735 settings[name] = value
732 736 return settings[name]
733 737
734 738
735 739 def _substitute_values(mapping, substitutions):
736 740 result = {}
737 741
738 742 try:
739 743 for key, value in mapping.items():
740 744 # initialize without substitution first
741 745 result[key] = value
742 746
743 747 # Note: Cannot use regular replacements, since they would clash
744 748 # with the implementation of ConfigParser. Using "format" instead.
745 749 try:
746 750 result[key] = value.format(**substitutions)
747 751 except KeyError as e:
748 752 env_var = '{}'.format(e.args[0])
749 753
750 754 msg = 'Failed to substitute: `{key}={{{var}}}` with environment entry. ' \
751 755 'Make sure your environment has {var} set, or remove this ' \
752 756 'variable from config file'.format(key=key, var=env_var)
753 757
754 758 if env_var.startswith('ENV_'):
755 759 raise ValueError(msg)
756 760 else:
757 761 log.warning(msg)
758 762
759 763 except ValueError as e:
760 764 log.warning('Failed to substitute ENV variable: %s', e)
761 765 result = mapping
762 766
763 767 return result
@@ -1,295 +1,298 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2017-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import logging
22 22 import datetime
23 23
24 24 from rhodecode.lib.jsonalchemy import JsonRaw
25 25 from rhodecode.model import meta
26 26 from rhodecode.model.db import User, UserLog, Repository
27 27
28 28
29 29 log = logging.getLogger(__name__)
30 30
31 31 # action as key, and expected action_data as value
32 32 ACTIONS_V1 = {
33 33 'user.login.success': {'user_agent': ''},
34 34 'user.login.failure': {'user_agent': ''},
35 35 'user.logout': {'user_agent': ''},
36 36 'user.register': {},
37 37 'user.password.reset_request': {},
38 38 'user.push': {'user_agent': '', 'commit_ids': []},
39 39 'user.pull': {'user_agent': ''},
40 40
41 41 'user.create': {'data': {}},
42 42 'user.delete': {'old_data': {}},
43 43 'user.edit': {'old_data': {}},
44 44 'user.edit.permissions': {},
45 45 'user.edit.ip.add': {'ip': {}, 'user': {}},
46 46 'user.edit.ip.delete': {'ip': {}, 'user': {}},
47 47 'user.edit.token.add': {'token': {}, 'user': {}},
48 48 'user.edit.token.delete': {'token': {}, 'user': {}},
49 49 'user.edit.email.add': {'email': ''},
50 50 'user.edit.email.delete': {'email': ''},
51 51 'user.edit.ssh_key.add': {'token': {}, 'user': {}},
52 52 'user.edit.ssh_key.delete': {'token': {}, 'user': {}},
53 53 'user.edit.password_reset.enabled': {},
54 54 'user.edit.password_reset.disabled': {},
55 55
56 56 'user_group.create': {'data': {}},
57 57 'user_group.delete': {'old_data': {}},
58 58 'user_group.edit': {'old_data': {}},
59 59 'user_group.edit.permissions': {},
60 60 'user_group.edit.member.add': {'user': {}},
61 61 'user_group.edit.member.delete': {'user': {}},
62 62
63 63 'repo.create': {'data': {}},
64 64 'repo.fork': {'data': {}},
65 65 'repo.edit': {'old_data': {}},
66 66 'repo.edit.permissions': {},
67 67 'repo.edit.permissions.branch': {},
68 68 'repo.archive': {'old_data': {}},
69 69 'repo.delete': {'old_data': {}},
70 70
71 71 'repo.archive.download': {'user_agent': '', 'archive_name': '',
72 72 'archive_spec': '', 'archive_cached': ''},
73 73
74 74 'repo.permissions.branch_rule.create': {},
75 75 'repo.permissions.branch_rule.edit': {},
76 76 'repo.permissions.branch_rule.delete': {},
77 77
78 78 'repo.pull_request.create': '',
79 79 'repo.pull_request.edit': '',
80 80 'repo.pull_request.delete': '',
81 81 'repo.pull_request.close': '',
82 82 'repo.pull_request.merge': '',
83 83 'repo.pull_request.vote': '',
84 84 'repo.pull_request.comment.create': '',
85 85 'repo.pull_request.comment.edit': '',
86 86 'repo.pull_request.comment.delete': '',
87 87
88 88 'repo.pull_request.reviewer.add': '',
89 89 'repo.pull_request.reviewer.delete': '',
90 90
91 'repo.pull_request.observer.add': '',
92 'repo.pull_request.observer.delete': '',
93
91 94 'repo.commit.strip': {'commit_id': ''},
92 95 'repo.commit.comment.create': {'data': {}},
93 96 'repo.commit.comment.delete': {'data': {}},
94 97 'repo.commit.comment.edit': {'data': {}},
95 98 'repo.commit.vote': '',
96 99
97 100 'repo.artifact.add': '',
98 101 'repo.artifact.delete': '',
99 102
100 103 'repo_group.create': {'data': {}},
101 104 'repo_group.edit': {'old_data': {}},
102 105 'repo_group.edit.permissions': {},
103 106 'repo_group.delete': {'old_data': {}},
104 107 }
105 108
106 109 ACTIONS = ACTIONS_V1
107 110
108 111 SOURCE_WEB = 'source_web'
109 112 SOURCE_API = 'source_api'
110 113
111 114
112 115 class UserWrap(object):
113 116 """
114 117 Fake object used to imitate AuthUser
115 118 """
116 119
117 120 def __init__(self, user_id=None, username=None, ip_addr=None):
118 121 self.user_id = user_id
119 122 self.username = username
120 123 self.ip_addr = ip_addr
121 124
122 125
123 126 class RepoWrap(object):
124 127 """
125 128 Fake object used to imitate RepoObject that audit logger requires
126 129 """
127 130
128 131 def __init__(self, repo_id=None, repo_name=None):
129 132 self.repo_id = repo_id
130 133 self.repo_name = repo_name
131 134
132 135
133 136 def _store_log(action_name, action_data, user_id, username, user_data,
134 137 ip_address, repository_id, repository_name):
135 138 user_log = UserLog()
136 139 user_log.version = UserLog.VERSION_2
137 140
138 141 user_log.action = action_name
139 142 user_log.action_data = action_data or JsonRaw(u'{}')
140 143
141 144 user_log.user_ip = ip_address
142 145
143 146 user_log.user_id = user_id
144 147 user_log.username = username
145 148 user_log.user_data = user_data or JsonRaw(u'{}')
146 149
147 150 user_log.repository_id = repository_id
148 151 user_log.repository_name = repository_name
149 152
150 153 user_log.action_date = datetime.datetime.now()
151 154
152 155 return user_log
153 156
154 157
155 158 def store_web(*args, **kwargs):
156 159 action_data = {}
157 160 org_action_data = kwargs.pop('action_data', {})
158 161 action_data.update(org_action_data)
159 162 action_data['source'] = SOURCE_WEB
160 163 kwargs['action_data'] = action_data
161 164
162 165 return store(*args, **kwargs)
163 166
164 167
165 168 def store_api(*args, **kwargs):
166 169 action_data = {}
167 170 org_action_data = kwargs.pop('action_data', {})
168 171 action_data.update(org_action_data)
169 172 action_data['source'] = SOURCE_API
170 173 kwargs['action_data'] = action_data
171 174
172 175 return store(*args, **kwargs)
173 176
174 177
175 178 def store(action, user, action_data=None, user_data=None, ip_addr=None,
176 179 repo=None, sa_session=None, commit=False):
177 180 """
178 181 Audit logger for various actions made by users, typically this
179 182 results in a call such::
180 183
181 184 from rhodecode.lib import audit_logger
182 185
183 186 audit_logger.store(
184 187 'repo.edit', user=self._rhodecode_user)
185 188 audit_logger.store(
186 189 'repo.delete', action_data={'data': repo_data},
187 190 user=audit_logger.UserWrap(username='itried-login', ip_addr='8.8.8.8'))
188 191
189 192 # repo action
190 193 audit_logger.store(
191 194 'repo.delete',
192 195 user=audit_logger.UserWrap(username='itried-login', ip_addr='8.8.8.8'),
193 196 repo=audit_logger.RepoWrap(repo_name='some-repo'))
194 197
195 198 # repo action, when we know and have the repository object already
196 199 audit_logger.store(
197 200 'repo.delete', action_data={'source': audit_logger.SOURCE_WEB, },
198 201 user=self._rhodecode_user,
199 202 repo=repo_object)
200 203
201 204 # alternative wrapper to the above
202 205 audit_logger.store_web(
203 206 'repo.delete', action_data={},
204 207 user=self._rhodecode_user,
205 208 repo=repo_object)
206 209
207 210 # without an user ?
208 211 audit_logger.store(
209 212 'user.login.failure',
210 213 user=audit_logger.UserWrap(
211 214 username=self.request.params.get('username'),
212 215 ip_addr=self.request.remote_addr))
213 216
214 217 """
215 218 from rhodecode.lib.utils2 import safe_unicode
216 219 from rhodecode.lib.auth import AuthUser
217 220
218 221 action_spec = ACTIONS.get(action, None)
219 222 if action_spec is None:
220 223 raise ValueError('Action `{}` is not supported'.format(action))
221 224
222 225 if not sa_session:
223 226 sa_session = meta.Session()
224 227
225 228 try:
226 229 username = getattr(user, 'username', None)
227 230 if not username:
228 231 pass
229 232
230 233 user_id = getattr(user, 'user_id', None)
231 234 if not user_id:
232 235 # maybe we have username ? Try to figure user_id from username
233 236 if username:
234 237 user_id = getattr(
235 238 User.get_by_username(username), 'user_id', None)
236 239
237 240 ip_addr = ip_addr or getattr(user, 'ip_addr', None)
238 241 if not ip_addr:
239 242 pass
240 243
241 244 if not user_data:
242 245 # try to get this from the auth user
243 246 if isinstance(user, AuthUser):
244 247 user_data = {
245 248 'username': user.username,
246 249 'email': user.email,
247 250 }
248 251
249 252 repository_name = getattr(repo, 'repo_name', None)
250 253 repository_id = getattr(repo, 'repo_id', None)
251 254 if not repository_id:
252 255 # maybe we have repo_name ? Try to figure repo_id from repo_name
253 256 if repository_name:
254 257 repository_id = getattr(
255 258 Repository.get_by_repo_name(repository_name), 'repo_id', None)
256 259
257 260 action_name = safe_unicode(action)
258 261 ip_address = safe_unicode(ip_addr)
259 262
260 263 with sa_session.no_autoflush:
261 264 update_user_last_activity(sa_session, user_id)
262 265
263 266 user_log = _store_log(
264 267 action_name=action_name,
265 268 action_data=action_data or {},
266 269 user_id=user_id,
267 270 username=username,
268 271 user_data=user_data or {},
269 272 ip_address=ip_address,
270 273 repository_id=repository_id,
271 274 repository_name=repository_name
272 275 )
273 276
274 277 sa_session.add(user_log)
275 278
276 279 if commit:
277 280 sa_session.commit()
278 281
279 282 entry_id = user_log.entry_id or ''
280 283 log.info('AUDIT[%s]: Logging action: `%s` by user:id:%s[%s] ip:%s',
281 284 entry_id, action_name, user_id, username, ip_address)
282 285
283 286 except Exception:
284 287 log.exception('AUDIT: failed to store audit log')
285 288
286 289
287 290 def update_user_last_activity(sa_session, user_id):
288 291 _last_activity = datetime.datetime.now()
289 292 try:
290 293 sa_session.query(User).filter(User.user_id == user_id).update(
291 294 {"last_activity": _last_activity})
292 295 log.debug(
293 296 'updated user `%s` last activity to:%s', user_id, _last_activity)
294 297 except Exception:
295 298 log.exception("Failed last activity update")
@@ -1,2113 +1,2117 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 import regex
42 42 from collections import OrderedDict
43 43
44 44 import pygments
45 45 import itertools
46 46 import fnmatch
47 47 import bleach
48 48
49 49 from pyramid import compat
50 50 from datetime import datetime
51 51 from functools import partial
52 52 from pygments.formatters.html import HtmlFormatter
53 53 from pygments.lexers import (
54 54 get_lexer_by_name, get_lexer_for_filename, get_lexer_for_mimetype)
55 55
56 56 from pyramid.threadlocal import get_current_request
57 57 from tempita import looper
58 58 from webhelpers2.html import literal, HTML, escape
59 59 from webhelpers2.html._autolink import _auto_link_urls
60 60 from webhelpers2.html.tools import (
61 61 button_to, highlight, js_obfuscate, strip_links, strip_tags)
62 62
63 63 from webhelpers2.text import (
64 64 chop_at, collapse, convert_accented_entities,
65 65 convert_misc_entities, lchop, plural, rchop, remove_formatting,
66 66 replace_whitespace, urlify, truncate, wrap_paragraphs)
67 67 from webhelpers2.date import time_ago_in_words
68 68
69 69 from webhelpers2.html.tags import (
70 70 _input, NotGiven, _make_safe_id_component as safeid,
71 71 form as insecure_form,
72 72 auto_discovery_link, checkbox, end_form, file,
73 73 hidden, image, javascript_link, link_to, link_to_if, link_to_unless, ol,
74 74 select as raw_select, stylesheet_link, submit, text, password, textarea,
75 75 ul, radio, Options)
76 76
77 77 from webhelpers2.number import format_byte_size
78 78
79 79 from rhodecode.lib.action_parser import action_parser
80 80 from rhodecode.lib.pagination import Page, RepoPage, SqlPage
81 81 from rhodecode.lib.ext_json import json
82 82 from rhodecode.lib.utils import repo_name_slug, get_custom_lexer
83 83 from rhodecode.lib.utils2 import (
84 84 str2bool, safe_unicode, safe_str,
85 85 get_commit_safe, datetime_to_time, time_to_datetime, time_to_utcdatetime,
86 86 AttributeDict, safe_int, md5, md5_safe, get_host_info)
87 87 from rhodecode.lib.markup_renderer import MarkupRenderer, relative_links
88 88 from rhodecode.lib.vcs.exceptions import CommitDoesNotExistError
89 89 from rhodecode.lib.vcs.backends.base import BaseChangeset, EmptyCommit
90 90 from rhodecode.lib.vcs.conf.settings import ARCHIVE_SPECS
91 91 from rhodecode.lib.index.search_utils import get_matching_line_offsets
92 92 from rhodecode.config.conf import DATE_FORMAT, DATETIME_FORMAT
93 93 from rhodecode.model.changeset_status import ChangesetStatusModel
94 94 from rhodecode.model.db import Permission, User, Repository, UserApiKeys, FileStore
95 95 from rhodecode.model.repo_group import RepoGroupModel
96 96 from rhodecode.model.settings import IssueTrackerSettingsModel
97 97
98 98
99 99 log = logging.getLogger(__name__)
100 100
101 101
102 102 DEFAULT_USER = User.DEFAULT_USER
103 103 DEFAULT_USER_EMAIL = User.DEFAULT_USER_EMAIL
104 104
105 105
106 106 def asset(path, ver=None, **kwargs):
107 107 """
108 108 Helper to generate a static asset file path for rhodecode assets
109 109
110 110 eg. h.asset('images/image.png', ver='3923')
111 111
112 112 :param path: path of asset
113 113 :param ver: optional version query param to append as ?ver=
114 114 """
115 115 request = get_current_request()
116 116 query = {}
117 117 query.update(kwargs)
118 118 if ver:
119 119 query = {'ver': ver}
120 120 return request.static_path(
121 121 'rhodecode:public/{}'.format(path), _query=query)
122 122
123 123
124 124 default_html_escape_table = {
125 125 ord('&'): u'&amp;',
126 126 ord('<'): u'&lt;',
127 127 ord('>'): u'&gt;',
128 128 ord('"'): u'&quot;',
129 129 ord("'"): u'&#39;',
130 130 }
131 131
132 132
133 133 def html_escape(text, html_escape_table=default_html_escape_table):
134 134 """Produce entities within text."""
135 135 return text.translate(html_escape_table)
136 136
137 137
138 138 def chop_at_smart(s, sub, inclusive=False, suffix_if_chopped=None):
139 139 """
140 140 Truncate string ``s`` at the first occurrence of ``sub``.
141 141
142 142 If ``inclusive`` is true, truncate just after ``sub`` rather than at it.
143 143 """
144 144 suffix_if_chopped = suffix_if_chopped or ''
145 145 pos = s.find(sub)
146 146 if pos == -1:
147 147 return s
148 148
149 149 if inclusive:
150 150 pos += len(sub)
151 151
152 152 chopped = s[:pos]
153 153 left = s[pos:].strip()
154 154
155 155 if left and suffix_if_chopped:
156 156 chopped += suffix_if_chopped
157 157
158 158 return chopped
159 159
160 160
161 161 def shorter(text, size=20, prefix=False):
162 162 postfix = '...'
163 163 if len(text) > size:
164 164 if prefix:
165 165 # shorten in front
166 166 return postfix + text[-(size - len(postfix)):]
167 167 else:
168 168 return text[:size - len(postfix)] + postfix
169 169 return text
170 170
171 171
172 172 def reset(name, value=None, id=NotGiven, type="reset", **attrs):
173 173 """
174 174 Reset button
175 175 """
176 176 return _input(type, name, value, id, attrs)
177 177
178 178
179 179 def select(name, selected_values, options, id=NotGiven, **attrs):
180 180
181 181 if isinstance(options, (list, tuple)):
182 182 options_iter = options
183 183 # Handle old value,label lists ... where value also can be value,label lists
184 184 options = Options()
185 185 for opt in options_iter:
186 186 if isinstance(opt, tuple) and len(opt) == 2:
187 187 value, label = opt
188 188 elif isinstance(opt, basestring):
189 189 value = label = opt
190 190 else:
191 191 raise ValueError('invalid select option type %r' % type(opt))
192 192
193 193 if isinstance(value, (list, tuple)):
194 194 option_group = options.add_optgroup(label)
195 195 for opt2 in value:
196 196 if isinstance(opt2, tuple) and len(opt2) == 2:
197 197 group_value, group_label = opt2
198 198 elif isinstance(opt2, basestring):
199 199 group_value = group_label = opt2
200 200 else:
201 201 raise ValueError('invalid select option type %r' % type(opt2))
202 202
203 203 option_group.add_option(group_label, group_value)
204 204 else:
205 205 options.add_option(label, value)
206 206
207 207 return raw_select(name, selected_values, options, id=id, **attrs)
208 208
209 209
210 210 def branding(name, length=40):
211 211 return truncate(name, length, indicator="")
212 212
213 213
214 214 def FID(raw_id, path):
215 215 """
216 216 Creates a unique ID for filenode based on it's hash of path and commit
217 217 it's safe to use in urls
218 218
219 219 :param raw_id:
220 220 :param path:
221 221 """
222 222
223 223 return 'c-%s-%s' % (short_id(raw_id), md5_safe(path)[:12])
224 224
225 225
226 226 class _GetError(object):
227 227 """Get error from form_errors, and represent it as span wrapped error
228 228 message
229 229
230 230 :param field_name: field to fetch errors for
231 231 :param form_errors: form errors dict
232 232 """
233 233
234 234 def __call__(self, field_name, form_errors):
235 235 tmpl = """<span class="error_msg">%s</span>"""
236 236 if form_errors and field_name in form_errors:
237 237 return literal(tmpl % form_errors.get(field_name))
238 238
239 239
240 240 get_error = _GetError()
241 241
242 242
243 243 class _ToolTip(object):
244 244
245 245 def __call__(self, tooltip_title, trim_at=50):
246 246 """
247 247 Special function just to wrap our text into nice formatted
248 248 autowrapped text
249 249
250 250 :param tooltip_title:
251 251 """
252 252 tooltip_title = escape(tooltip_title)
253 253 tooltip_title = tooltip_title.replace('<', '&lt;').replace('>', '&gt;')
254 254 return tooltip_title
255 255
256 256
257 257 tooltip = _ToolTip()
258 258
259 259 files_icon = u'<i class="file-breadcrumb-copy tooltip icon-clipboard clipboard-action" data-clipboard-text="{}" title="Copy file path"></i>'
260 260
261 261
262 262 def files_breadcrumbs(repo_name, repo_type, commit_id, file_path, landing_ref_name=None, at_ref=None,
263 263 limit_items=False, linkify_last_item=False, hide_last_item=False,
264 264 copy_path_icon=True):
265 265 if isinstance(file_path, str):
266 266 file_path = safe_unicode(file_path)
267 267
268 268 if at_ref:
269 269 route_qry = {'at': at_ref}
270 270 default_landing_ref = at_ref or landing_ref_name or commit_id
271 271 else:
272 272 route_qry = None
273 273 default_landing_ref = commit_id
274 274
275 275 # first segment is a `HOME` link to repo files root location
276 276 root_name = literal(u'<i class="icon-home"></i>')
277 277
278 278 url_segments = [
279 279 link_to(
280 280 root_name,
281 281 repo_files_by_ref_url(
282 282 repo_name,
283 283 repo_type,
284 284 f_path=None, # None here is a special case for SVN repos,
285 285 # that won't prefix with a ref
286 286 ref_name=default_landing_ref,
287 287 commit_id=commit_id,
288 288 query=route_qry
289 289 )
290 290 )]
291 291
292 292 path_segments = file_path.split('/')
293 293 last_cnt = len(path_segments) - 1
294 294 for cnt, segment in enumerate(path_segments):
295 295 if not segment:
296 296 continue
297 297 segment_html = escape(segment)
298 298
299 299 last_item = cnt == last_cnt
300 300
301 301 if last_item and hide_last_item:
302 302 # iterate over and hide last element
303 303 continue
304 304
305 305 if last_item and linkify_last_item is False:
306 306 # plain version
307 307 url_segments.append(segment_html)
308 308 else:
309 309 url_segments.append(
310 310 link_to(
311 311 segment_html,
312 312 repo_files_by_ref_url(
313 313 repo_name,
314 314 repo_type,
315 315 f_path='/'.join(path_segments[:cnt + 1]),
316 316 ref_name=default_landing_ref,
317 317 commit_id=commit_id,
318 318 query=route_qry
319 319 ),
320 320 ))
321 321
322 322 limited_url_segments = url_segments[:1] + ['...'] + url_segments[-5:]
323 323 if limit_items and len(limited_url_segments) < len(url_segments):
324 324 url_segments = limited_url_segments
325 325
326 326 full_path = file_path
327 327 if copy_path_icon:
328 328 icon = files_icon.format(escape(full_path))
329 329 else:
330 330 icon = ''
331 331
332 332 if file_path == '':
333 333 return root_name
334 334 else:
335 335 return literal(' / '.join(url_segments) + icon)
336 336
337 337
338 338 def files_url_data(request):
339 339 matchdict = request.matchdict
340 340
341 341 if 'f_path' not in matchdict:
342 342 matchdict['f_path'] = ''
343 343
344 344 if 'commit_id' not in matchdict:
345 345 matchdict['commit_id'] = 'tip'
346 346
347 347 return json.dumps(matchdict)
348 348
349 349
350 350 def repo_files_by_ref_url(db_repo_name, db_repo_type, f_path, ref_name, commit_id, query=None, ):
351 351 _is_svn = is_svn(db_repo_type)
352 352 final_f_path = f_path
353 353
354 354 if _is_svn:
355 355 """
356 356 For SVN the ref_name cannot be used as a commit_id, it needs to be prefixed with
357 357 actually commit_id followed by the ref_name. This should be done only in case
358 358 This is a initial landing url, without additional paths.
359 359
360 360 like: /1000/tags/1.0.0/?at=tags/1.0.0
361 361 """
362 362
363 363 if ref_name and ref_name != 'tip':
364 364 # NOTE(marcink): for svn the ref_name is actually the stored path, so we prefix it
365 365 # for SVN we only do this magic prefix if it's root, .eg landing revision
366 366 # of files link. If we are in the tree we don't need this since we traverse the url
367 367 # that has everything stored
368 368 if f_path in ['', '/']:
369 369 final_f_path = '/'.join([ref_name, f_path])
370 370
371 371 # SVN always needs a commit_id explicitly, without a named REF
372 372 default_commit_id = commit_id
373 373 else:
374 374 """
375 375 For git and mercurial we construct a new URL using the names instead of commit_id
376 376 like: /master/some_path?at=master
377 377 """
378 378 # We currently do not support branches with slashes
379 379 if '/' in ref_name:
380 380 default_commit_id = commit_id
381 381 else:
382 382 default_commit_id = ref_name
383 383
384 384 # sometimes we pass f_path as None, to indicate explicit no prefix,
385 385 # we translate it to string to not have None
386 386 final_f_path = final_f_path or ''
387 387
388 388 files_url = route_path(
389 389 'repo_files',
390 390 repo_name=db_repo_name,
391 391 commit_id=default_commit_id,
392 392 f_path=final_f_path,
393 393 _query=query
394 394 )
395 395 return files_url
396 396
397 397
398 398 def code_highlight(code, lexer, formatter, use_hl_filter=False):
399 399 """
400 400 Lex ``code`` with ``lexer`` and format it with the formatter ``formatter``.
401 401
402 402 If ``outfile`` is given and a valid file object (an object
403 403 with a ``write`` method), the result will be written to it, otherwise
404 404 it is returned as a string.
405 405 """
406 406 if use_hl_filter:
407 407 # add HL filter
408 408 from rhodecode.lib.index import search_utils
409 409 lexer.add_filter(search_utils.ElasticSearchHLFilter())
410 410 return pygments.format(pygments.lex(code, lexer), formatter)
411 411
412 412
413 413 class CodeHtmlFormatter(HtmlFormatter):
414 414 """
415 415 My code Html Formatter for source codes
416 416 """
417 417
418 418 def wrap(self, source, outfile):
419 419 return self._wrap_div(self._wrap_pre(self._wrap_code(source)))
420 420
421 421 def _wrap_code(self, source):
422 422 for cnt, it in enumerate(source):
423 423 i, t = it
424 424 t = '<div id="L%s">%s</div>' % (cnt + 1, t)
425 425 yield i, t
426 426
427 427 def _wrap_tablelinenos(self, inner):
428 428 dummyoutfile = StringIO.StringIO()
429 429 lncount = 0
430 430 for t, line in inner:
431 431 if t:
432 432 lncount += 1
433 433 dummyoutfile.write(line)
434 434
435 435 fl = self.linenostart
436 436 mw = len(str(lncount + fl - 1))
437 437 sp = self.linenospecial
438 438 st = self.linenostep
439 439 la = self.lineanchors
440 440 aln = self.anchorlinenos
441 441 nocls = self.noclasses
442 442 if sp:
443 443 lines = []
444 444
445 445 for i in range(fl, fl + lncount):
446 446 if i % st == 0:
447 447 if i % sp == 0:
448 448 if aln:
449 449 lines.append('<a href="#%s%d" class="special">%*d</a>' %
450 450 (la, i, mw, i))
451 451 else:
452 452 lines.append('<span class="special">%*d</span>' % (mw, i))
453 453 else:
454 454 if aln:
455 455 lines.append('<a href="#%s%d">%*d</a>' % (la, i, mw, i))
456 456 else:
457 457 lines.append('%*d' % (mw, i))
458 458 else:
459 459 lines.append('')
460 460 ls = '\n'.join(lines)
461 461 else:
462 462 lines = []
463 463 for i in range(fl, fl + lncount):
464 464 if i % st == 0:
465 465 if aln:
466 466 lines.append('<a href="#%s%d">%*d</a>' % (la, i, mw, i))
467 467 else:
468 468 lines.append('%*d' % (mw, i))
469 469 else:
470 470 lines.append('')
471 471 ls = '\n'.join(lines)
472 472
473 473 # in case you wonder about the seemingly redundant <div> here: since the
474 474 # content in the other cell also is wrapped in a div, some browsers in
475 475 # some configurations seem to mess up the formatting...
476 476 if nocls:
477 477 yield 0, ('<table class="%stable">' % self.cssclass +
478 478 '<tr><td><div class="linenodiv" '
479 479 'style="background-color: #f0f0f0; padding-right: 10px">'
480 480 '<pre style="line-height: 125%">' +
481 481 ls + '</pre></div></td><td id="hlcode" class="code">')
482 482 else:
483 483 yield 0, ('<table class="%stable">' % self.cssclass +
484 484 '<tr><td class="linenos"><div class="linenodiv"><pre>' +
485 485 ls + '</pre></div></td><td id="hlcode" class="code">')
486 486 yield 0, dummyoutfile.getvalue()
487 487 yield 0, '</td></tr></table>'
488 488
489 489
490 490 class SearchContentCodeHtmlFormatter(CodeHtmlFormatter):
491 491 def __init__(self, **kw):
492 492 # only show these line numbers if set
493 493 self.only_lines = kw.pop('only_line_numbers', [])
494 494 self.query_terms = kw.pop('query_terms', [])
495 495 self.max_lines = kw.pop('max_lines', 5)
496 496 self.line_context = kw.pop('line_context', 3)
497 497 self.url = kw.pop('url', None)
498 498
499 499 super(CodeHtmlFormatter, self).__init__(**kw)
500 500
501 501 def _wrap_code(self, source):
502 502 for cnt, it in enumerate(source):
503 503 i, t = it
504 504 t = '<pre>%s</pre>' % t
505 505 yield i, t
506 506
507 507 def _wrap_tablelinenos(self, inner):
508 508 yield 0, '<table class="code-highlight %stable">' % self.cssclass
509 509
510 510 last_shown_line_number = 0
511 511 current_line_number = 1
512 512
513 513 for t, line in inner:
514 514 if not t:
515 515 yield t, line
516 516 continue
517 517
518 518 if current_line_number in self.only_lines:
519 519 if last_shown_line_number + 1 != current_line_number:
520 520 yield 0, '<tr>'
521 521 yield 0, '<td class="line">...</td>'
522 522 yield 0, '<td id="hlcode" class="code"></td>'
523 523 yield 0, '</tr>'
524 524
525 525 yield 0, '<tr>'
526 526 if self.url:
527 527 yield 0, '<td class="line"><a href="%s#L%i">%i</a></td>' % (
528 528 self.url, current_line_number, current_line_number)
529 529 else:
530 530 yield 0, '<td class="line"><a href="">%i</a></td>' % (
531 531 current_line_number)
532 532 yield 0, '<td id="hlcode" class="code">' + line + '</td>'
533 533 yield 0, '</tr>'
534 534
535 535 last_shown_line_number = current_line_number
536 536
537 537 current_line_number += 1
538 538
539 539 yield 0, '</table>'
540 540
541 541
542 542 def hsv_to_rgb(h, s, v):
543 543 """ Convert hsv color values to rgb """
544 544
545 545 if s == 0.0:
546 546 return v, v, v
547 547 i = int(h * 6.0) # XXX assume int() truncates!
548 548 f = (h * 6.0) - i
549 549 p = v * (1.0 - s)
550 550 q = v * (1.0 - s * f)
551 551 t = v * (1.0 - s * (1.0 - f))
552 552 i = i % 6
553 553 if i == 0:
554 554 return v, t, p
555 555 if i == 1:
556 556 return q, v, p
557 557 if i == 2:
558 558 return p, v, t
559 559 if i == 3:
560 560 return p, q, v
561 561 if i == 4:
562 562 return t, p, v
563 563 if i == 5:
564 564 return v, p, q
565 565
566 566
567 567 def unique_color_generator(n=10000, saturation=0.10, lightness=0.95):
568 568 """
569 569 Generator for getting n of evenly distributed colors using
570 570 hsv color and golden ratio. It always return same order of colors
571 571
572 572 :param n: number of colors to generate
573 573 :param saturation: saturation of returned colors
574 574 :param lightness: lightness of returned colors
575 575 :returns: RGB tuple
576 576 """
577 577
578 578 golden_ratio = 0.618033988749895
579 579 h = 0.22717784590367374
580 580
581 581 for _ in xrange(n):
582 582 h += golden_ratio
583 583 h %= 1
584 584 HSV_tuple = [h, saturation, lightness]
585 585 RGB_tuple = hsv_to_rgb(*HSV_tuple)
586 586 yield map(lambda x: str(int(x * 256)), RGB_tuple)
587 587
588 588
589 589 def color_hasher(n=10000, saturation=0.10, lightness=0.95):
590 590 """
591 591 Returns a function which when called with an argument returns a unique
592 592 color for that argument, eg.
593 593
594 594 :param n: number of colors to generate
595 595 :param saturation: saturation of returned colors
596 596 :param lightness: lightness of returned colors
597 597 :returns: css RGB string
598 598
599 599 >>> color_hash = color_hasher()
600 600 >>> color_hash('hello')
601 601 'rgb(34, 12, 59)'
602 602 >>> color_hash('hello')
603 603 'rgb(34, 12, 59)'
604 604 >>> color_hash('other')
605 605 'rgb(90, 224, 159)'
606 606 """
607 607
608 608 color_dict = {}
609 609 cgenerator = unique_color_generator(
610 610 saturation=saturation, lightness=lightness)
611 611
612 612 def get_color_string(thing):
613 613 if thing in color_dict:
614 614 col = color_dict[thing]
615 615 else:
616 616 col = color_dict[thing] = cgenerator.next()
617 617 return "rgb(%s)" % (', '.join(col))
618 618
619 619 return get_color_string
620 620
621 621
622 622 def get_lexer_safe(mimetype=None, filepath=None):
623 623 """
624 624 Tries to return a relevant pygments lexer using mimetype/filepath name,
625 625 defaulting to plain text if none could be found
626 626 """
627 627 lexer = None
628 628 try:
629 629 if mimetype:
630 630 lexer = get_lexer_for_mimetype(mimetype)
631 631 if not lexer:
632 632 lexer = get_lexer_for_filename(filepath)
633 633 except pygments.util.ClassNotFound:
634 634 pass
635 635
636 636 if not lexer:
637 637 lexer = get_lexer_by_name('text')
638 638
639 639 return lexer
640 640
641 641
642 642 def get_lexer_for_filenode(filenode):
643 643 lexer = get_custom_lexer(filenode.extension) or filenode.lexer
644 644 return lexer
645 645
646 646
647 647 def pygmentize(filenode, **kwargs):
648 648 """
649 649 pygmentize function using pygments
650 650
651 651 :param filenode:
652 652 """
653 653 lexer = get_lexer_for_filenode(filenode)
654 654 return literal(code_highlight(filenode.content, lexer,
655 655 CodeHtmlFormatter(**kwargs)))
656 656
657 657
658 658 def is_following_repo(repo_name, user_id):
659 659 from rhodecode.model.scm import ScmModel
660 660 return ScmModel().is_following_repo(repo_name, user_id)
661 661
662 662
663 663 class _Message(object):
664 664 """A message returned by ``Flash.pop_messages()``.
665 665
666 666 Converting the message to a string returns the message text. Instances
667 667 also have the following attributes:
668 668
669 669 * ``message``: the message text.
670 670 * ``category``: the category specified when the message was created.
671 671 """
672 672
673 673 def __init__(self, category, message, sub_data=None):
674 674 self.category = category
675 675 self.message = message
676 676 self.sub_data = sub_data or {}
677 677
678 678 def __str__(self):
679 679 return self.message
680 680
681 681 __unicode__ = __str__
682 682
683 683 def __html__(self):
684 684 return escape(safe_unicode(self.message))
685 685
686 686
687 687 class Flash(object):
688 688 # List of allowed categories. If None, allow any category.
689 689 categories = ["warning", "notice", "error", "success"]
690 690
691 691 # Default category if none is specified.
692 692 default_category = "notice"
693 693
694 694 def __init__(self, session_key="flash", categories=None,
695 695 default_category=None):
696 696 """
697 697 Instantiate a ``Flash`` object.
698 698
699 699 ``session_key`` is the key to save the messages under in the user's
700 700 session.
701 701
702 702 ``categories`` is an optional list which overrides the default list
703 703 of categories.
704 704
705 705 ``default_category`` overrides the default category used for messages
706 706 when none is specified.
707 707 """
708 708 self.session_key = session_key
709 709 if categories is not None:
710 710 self.categories = categories
711 711 if default_category is not None:
712 712 self.default_category = default_category
713 713 if self.categories and self.default_category not in self.categories:
714 714 raise ValueError(
715 715 "unrecognized default category %r" % (self.default_category,))
716 716
717 717 def pop_messages(self, session=None, request=None):
718 718 """
719 719 Return all accumulated messages and delete them from the session.
720 720
721 721 The return value is a list of ``Message`` objects.
722 722 """
723 723 messages = []
724 724
725 725 if not session:
726 726 if not request:
727 727 request = get_current_request()
728 728 session = request.session
729 729
730 730 # Pop the 'old' pylons flash messages. They are tuples of the form
731 731 # (category, message)
732 732 for cat, msg in session.pop(self.session_key, []):
733 733 messages.append(_Message(cat, msg))
734 734
735 735 # Pop the 'new' pyramid flash messages for each category as list
736 736 # of strings.
737 737 for cat in self.categories:
738 738 for msg in session.pop_flash(queue=cat):
739 739 sub_data = {}
740 740 if hasattr(msg, 'rsplit'):
741 741 flash_data = msg.rsplit('|DELIM|', 1)
742 742 org_message = flash_data[0]
743 743 if len(flash_data) > 1:
744 744 sub_data = json.loads(flash_data[1])
745 745 else:
746 746 org_message = msg
747 747
748 748 messages.append(_Message(cat, org_message, sub_data=sub_data))
749 749
750 750 # Map messages from the default queue to the 'notice' category.
751 751 for msg in session.pop_flash():
752 752 messages.append(_Message('notice', msg))
753 753
754 754 session.save()
755 755 return messages
756 756
757 757 def json_alerts(self, session=None, request=None):
758 758 payloads = []
759 759 messages = flash.pop_messages(session=session, request=request) or []
760 760 for message in messages:
761 761 payloads.append({
762 762 'message': {
763 763 'message': u'{}'.format(message.message),
764 764 'level': message.category,
765 765 'force': True,
766 766 'subdata': message.sub_data
767 767 }
768 768 })
769 769 return json.dumps(payloads)
770 770
771 771 def __call__(self, message, category=None, ignore_duplicate=True,
772 772 session=None, request=None):
773 773
774 774 if not session:
775 775 if not request:
776 776 request = get_current_request()
777 777 session = request.session
778 778
779 779 session.flash(
780 780 message, queue=category, allow_duplicate=not ignore_duplicate)
781 781
782 782
783 783 flash = Flash()
784 784
785 785 #==============================================================================
786 786 # SCM FILTERS available via h.
787 787 #==============================================================================
788 788 from rhodecode.lib.vcs.utils import author_name, author_email
789 789 from rhodecode.lib.utils2 import age, age_from_seconds
790 790 from rhodecode.model.db import User, ChangesetStatus
791 791
792 792
793 793 email = author_email
794 794
795 795
796 796 def capitalize(raw_text):
797 797 return raw_text.capitalize()
798 798
799 799
800 800 def short_id(long_id):
801 801 return long_id[:12]
802 802
803 803
804 804 def hide_credentials(url):
805 805 from rhodecode.lib.utils2 import credentials_filter
806 806 return credentials_filter(url)
807 807
808 808
809 809 import pytz
810 810 import tzlocal
811 811 local_timezone = tzlocal.get_localzone()
812 812
813 813
814 814 def get_timezone(datetime_iso, time_is_local=False):
815 815 tzinfo = '+00:00'
816 816
817 817 # detect if we have a timezone info, otherwise, add it
818 818 if time_is_local and isinstance(datetime_iso, datetime) and not datetime_iso.tzinfo:
819 819 force_timezone = os.environ.get('RC_TIMEZONE', '')
820 820 if force_timezone:
821 821 force_timezone = pytz.timezone(force_timezone)
822 822 timezone = force_timezone or local_timezone
823 823 offset = timezone.localize(datetime_iso).strftime('%z')
824 824 tzinfo = '{}:{}'.format(offset[:-2], offset[-2:])
825 825 return tzinfo
826 826
827 827
828 828 def age_component(datetime_iso, value=None, time_is_local=False, tooltip=True):
829 829 title = value or format_date(datetime_iso)
830 830 tzinfo = get_timezone(datetime_iso, time_is_local=time_is_local)
831 831
832 832 return literal(
833 833 '<time class="timeago {cls}" title="{tt_title}" datetime="{dt}{tzinfo}">{title}</time>'.format(
834 834 cls='tooltip' if tooltip else '',
835 835 tt_title=('{title}{tzinfo}'.format(title=title, tzinfo=tzinfo)) if tooltip else '',
836 836 title=title, dt=datetime_iso, tzinfo=tzinfo
837 837 ))
838 838
839 839
840 840 def _shorten_commit_id(commit_id, commit_len=None):
841 841 if commit_len is None:
842 842 request = get_current_request()
843 843 commit_len = request.call_context.visual.show_sha_length
844 844 return commit_id[:commit_len]
845 845
846 846
847 847 def show_id(commit, show_idx=None, commit_len=None):
848 848 """
849 849 Configurable function that shows ID
850 850 by default it's r123:fffeeefffeee
851 851
852 852 :param commit: commit instance
853 853 """
854 854 if show_idx is None:
855 855 request = get_current_request()
856 856 show_idx = request.call_context.visual.show_revision_number
857 857
858 858 raw_id = _shorten_commit_id(commit.raw_id, commit_len=commit_len)
859 859 if show_idx:
860 860 return 'r%s:%s' % (commit.idx, raw_id)
861 861 else:
862 862 return '%s' % (raw_id, )
863 863
864 864
865 865 def format_date(date):
866 866 """
867 867 use a standardized formatting for dates used in RhodeCode
868 868
869 869 :param date: date/datetime object
870 870 :return: formatted date
871 871 """
872 872
873 873 if date:
874 874 _fmt = "%a, %d %b %Y %H:%M:%S"
875 875 return safe_unicode(date.strftime(_fmt))
876 876
877 877 return u""
878 878
879 879
880 880 class _RepoChecker(object):
881 881
882 882 def __init__(self, backend_alias):
883 883 self._backend_alias = backend_alias
884 884
885 885 def __call__(self, repository):
886 886 if hasattr(repository, 'alias'):
887 887 _type = repository.alias
888 888 elif hasattr(repository, 'repo_type'):
889 889 _type = repository.repo_type
890 890 else:
891 891 _type = repository
892 892 return _type == self._backend_alias
893 893
894 894
895 895 is_git = _RepoChecker('git')
896 896 is_hg = _RepoChecker('hg')
897 897 is_svn = _RepoChecker('svn')
898 898
899 899
900 900 def get_repo_type_by_name(repo_name):
901 901 repo = Repository.get_by_repo_name(repo_name)
902 902 if repo:
903 903 return repo.repo_type
904 904
905 905
906 906 def is_svn_without_proxy(repository):
907 907 if is_svn(repository):
908 908 from rhodecode.model.settings import VcsSettingsModel
909 909 conf = VcsSettingsModel().get_ui_settings_as_config_obj()
910 910 return not str2bool(conf.get('vcs_svn_proxy', 'http_requests_enabled'))
911 911 return False
912 912
913 913
914 914 def discover_user(author):
915 915 """
916 916 Tries to discover RhodeCode User based on the author string. Author string
917 917 is typically `FirstName LastName <email@address.com>`
918 918 """
919 919
920 920 # if author is already an instance use it for extraction
921 921 if isinstance(author, User):
922 922 return author
923 923
924 924 # Valid email in the attribute passed, see if they're in the system
925 925 _email = author_email(author)
926 926 if _email != '':
927 927 user = User.get_by_email(_email, case_insensitive=True, cache=True)
928 928 if user is not None:
929 929 return user
930 930
931 931 # Maybe it's a username, we try to extract it and fetch by username ?
932 932 _author = author_name(author)
933 933 user = User.get_by_username(_author, case_insensitive=True, cache=True)
934 934 if user is not None:
935 935 return user
936 936
937 937 return None
938 938
939 939
940 940 def email_or_none(author):
941 941 # extract email from the commit string
942 942 _email = author_email(author)
943 943
944 944 # If we have an email, use it, otherwise
945 945 # see if it contains a username we can get an email from
946 946 if _email != '':
947 947 return _email
948 948 else:
949 949 user = User.get_by_username(
950 950 author_name(author), case_insensitive=True, cache=True)
951 951
952 952 if user is not None:
953 953 return user.email
954 954
955 955 # No valid email, not a valid user in the system, none!
956 956 return None
957 957
958 958
959 959 def link_to_user(author, length=0, **kwargs):
960 960 user = discover_user(author)
961 961 # user can be None, but if we have it already it means we can re-use it
962 962 # in the person() function, so we save 1 intensive-query
963 963 if user:
964 964 author = user
965 965
966 966 display_person = person(author, 'username_or_name_or_email')
967 967 if length:
968 968 display_person = shorter(display_person, length)
969 969
970 970 if user and user.username != user.DEFAULT_USER:
971 971 return link_to(
972 972 escape(display_person),
973 973 route_path('user_profile', username=user.username),
974 974 **kwargs)
975 975 else:
976 976 return escape(display_person)
977 977
978 978
979 979 def link_to_group(users_group_name, **kwargs):
980 980 return link_to(
981 981 escape(users_group_name),
982 982 route_path('user_group_profile', user_group_name=users_group_name),
983 983 **kwargs)
984 984
985 985
986 986 def person(author, show_attr="username_and_name"):
987 987 user = discover_user(author)
988 988 if user:
989 989 return getattr(user, show_attr)
990 990 else:
991 991 _author = author_name(author)
992 992 _email = email(author)
993 993 return _author or _email
994 994
995 995
996 996 def author_string(email):
997 997 if email:
998 998 user = User.get_by_email(email, case_insensitive=True, cache=True)
999 999 if user:
1000 1000 if user.first_name or user.last_name:
1001 1001 return '%s %s &lt;%s&gt;' % (
1002 1002 user.first_name, user.last_name, email)
1003 1003 else:
1004 1004 return email
1005 1005 else:
1006 1006 return email
1007 1007 else:
1008 1008 return None
1009 1009
1010 1010
1011 1011 def person_by_id(id_, show_attr="username_and_name"):
1012 1012 # attr to return from fetched user
1013 1013 person_getter = lambda usr: getattr(usr, show_attr)
1014 1014
1015 1015 #maybe it's an ID ?
1016 1016 if str(id_).isdigit() or isinstance(id_, int):
1017 1017 id_ = int(id_)
1018 1018 user = User.get(id_)
1019 1019 if user is not None:
1020 1020 return person_getter(user)
1021 1021 return id_
1022 1022
1023 1023
1024 1024 def gravatar_with_user(request, author, show_disabled=False, tooltip=False):
1025 1025 _render = request.get_partial_renderer('rhodecode:templates/base/base.mako')
1026 1026 return _render('gravatar_with_user', author, show_disabled=show_disabled, tooltip=tooltip)
1027 1027
1028 1028
1029 1029 tags_paterns = OrderedDict((
1030 1030 ('lang', (re.compile(r'\[(lang|language)\ \=\&gt;\ *([a-zA-Z\-\/\#\+\.]*)\]'),
1031 1031 '<div class="metatag" tag="lang">\\2</div>')),
1032 1032
1033 1033 ('see', (re.compile(r'\[see\ \=\&gt;\ *([a-zA-Z0-9\/\=\?\&amp;\ \:\/\.\-]*)\]'),
1034 1034 '<div class="metatag" tag="see">see: \\1 </div>')),
1035 1035
1036 1036 ('url', (re.compile(r'\[url\ \=\&gt;\ \[([a-zA-Z0-9\ \.\-\_]+)\]\((http://|https://|/)(.*?)\)\]'),
1037 1037 '<div class="metatag" tag="url"> <a href="\\2\\3">\\1</a> </div>')),
1038 1038
1039 1039 ('license', (re.compile(r'\[license\ \=\&gt;\ *([a-zA-Z0-9\/\=\?\&amp;\ \:\/\.\-]*)\]'),
1040 1040 '<div class="metatag" tag="license"><a href="http:\/\/www.opensource.org/licenses/\\1">\\1</a></div>')),
1041 1041
1042 1042 ('ref', (re.compile(r'\[(requires|recommends|conflicts|base)\ \=\&gt;\ *([a-zA-Z0-9\-\/]*)\]'),
1043 1043 '<div class="metatag" tag="ref \\1">\\1: <a href="/\\2">\\2</a></div>')),
1044 1044
1045 1045 ('state', (re.compile(r'\[(stable|featured|stale|dead|dev|deprecated)\]'),
1046 1046 '<div class="metatag" tag="state \\1">\\1</div>')),
1047 1047
1048 1048 # label in grey
1049 1049 ('label', (re.compile(r'\[([a-z]+)\]'),
1050 1050 '<div class="metatag" tag="label">\\1</div>')),
1051 1051
1052 1052 # generic catch all in grey
1053 1053 ('generic', (re.compile(r'\[([a-zA-Z0-9\.\-\_]+)\]'),
1054 1054 '<div class="metatag" tag="generic">\\1</div>')),
1055 1055 ))
1056 1056
1057 1057
1058 1058 def extract_metatags(value):
1059 1059 """
1060 1060 Extract supported meta-tags from given text value
1061 1061 """
1062 1062 tags = []
1063 1063 if not value:
1064 1064 return tags, ''
1065 1065
1066 1066 for key, val in tags_paterns.items():
1067 1067 pat, replace_html = val
1068 1068 tags.extend([(key, x.group()) for x in pat.finditer(value)])
1069 1069 value = pat.sub('', value)
1070 1070
1071 1071 return tags, value
1072 1072
1073 1073
1074 1074 def style_metatag(tag_type, value):
1075 1075 """
1076 1076 converts tags from value into html equivalent
1077 1077 """
1078 1078 if not value:
1079 1079 return ''
1080 1080
1081 1081 html_value = value
1082 1082 tag_data = tags_paterns.get(tag_type)
1083 1083 if tag_data:
1084 1084 pat, replace_html = tag_data
1085 1085 # convert to plain `unicode` instead of a markup tag to be used in
1086 1086 # regex expressions. safe_unicode doesn't work here
1087 1087 html_value = pat.sub(replace_html, unicode(value))
1088 1088
1089 1089 return html_value
1090 1090
1091 1091
1092 1092 def bool2icon(value, show_at_false=True):
1093 1093 """
1094 1094 Returns boolean value of a given value, represented as html element with
1095 1095 classes that will represent icons
1096 1096
1097 1097 :param value: given value to convert to html node
1098 1098 """
1099 1099
1100 1100 if value: # does bool conversion
1101 1101 return HTML.tag('i', class_="icon-true", title='True')
1102 1102 else: # not true as bool
1103 1103 if show_at_false:
1104 1104 return HTML.tag('i', class_="icon-false", title='False')
1105 1105 return HTML.tag('i')
1106 1106
1107
1108 def b64(inp):
1109 return base64.b64encode(inp)
1110
1107 1111 #==============================================================================
1108 1112 # PERMS
1109 1113 #==============================================================================
1110 1114 from rhodecode.lib.auth import (
1111 1115 HasPermissionAny, HasPermissionAll,
1112 1116 HasRepoPermissionAny, HasRepoPermissionAll, HasRepoGroupPermissionAll,
1113 1117 HasRepoGroupPermissionAny, HasRepoPermissionAnyApi, get_csrf_token,
1114 1118 csrf_token_key, AuthUser)
1115 1119
1116 1120
1117 1121 #==============================================================================
1118 1122 # GRAVATAR URL
1119 1123 #==============================================================================
1120 1124 class InitialsGravatar(object):
1121 1125 def __init__(self, email_address, first_name, last_name, size=30,
1122 1126 background=None, text_color='#fff'):
1123 1127 self.size = size
1124 1128 self.first_name = first_name
1125 1129 self.last_name = last_name
1126 1130 self.email_address = email_address
1127 1131 self.background = background or self.str2color(email_address)
1128 1132 self.text_color = text_color
1129 1133
1130 1134 def get_color_bank(self):
1131 1135 """
1132 1136 returns a predefined list of colors that gravatars can use.
1133 1137 Those are randomized distinct colors that guarantee readability and
1134 1138 uniqueness.
1135 1139
1136 1140 generated with: http://phrogz.net/css/distinct-colors.html
1137 1141 """
1138 1142 return [
1139 1143 '#bf3030', '#a67f53', '#00ff00', '#5989b3', '#392040', '#d90000',
1140 1144 '#402910', '#204020', '#79baf2', '#a700b3', '#bf6060', '#7f5320',
1141 1145 '#008000', '#003059', '#ee00ff', '#ff0000', '#8c4b00', '#007300',
1142 1146 '#005fb3', '#de73e6', '#ff4040', '#ffaa00', '#3df255', '#203140',
1143 1147 '#47004d', '#591616', '#664400', '#59b365', '#0d2133', '#83008c',
1144 1148 '#592d2d', '#bf9f60', '#73e682', '#1d3f73', '#73006b', '#402020',
1145 1149 '#b2862d', '#397341', '#597db3', '#e600d6', '#a60000', '#736039',
1146 1150 '#00b318', '#79aaf2', '#330d30', '#ff8080', '#403010', '#16591f',
1147 1151 '#002459', '#8c4688', '#e50000', '#ffbf40', '#00732e', '#102340',
1148 1152 '#bf60ac', '#8c4646', '#cc8800', '#00a642', '#1d3473', '#b32d98',
1149 1153 '#660e00', '#ffd580', '#80ffb2', '#7391e6', '#733967', '#d97b6c',
1150 1154 '#8c5e00', '#59b389', '#3967e6', '#590047', '#73281d', '#665200',
1151 1155 '#00e67a', '#2d50b3', '#8c2377', '#734139', '#b2982d', '#16593a',
1152 1156 '#001859', '#ff00aa', '#a65e53', '#ffcc00', '#0d3321', '#2d3959',
1153 1157 '#731d56', '#401610', '#4c3d00', '#468c6c', '#002ca6', '#d936a3',
1154 1158 '#d94c36', '#403920', '#36d9a3', '#0d1733', '#592d4a', '#993626',
1155 1159 '#cca300', '#00734d', '#46598c', '#8c005e', '#7f1100', '#8c7000',
1156 1160 '#00a66f', '#7382e6', '#b32d74', '#d9896c', '#ffe680', '#1d7362',
1157 1161 '#364cd9', '#73003d', '#d93a00', '#998a4d', '#59b3a1', '#5965b3',
1158 1162 '#e5007a', '#73341d', '#665f00', '#00b38f', '#0018b3', '#59163a',
1159 1163 '#b2502d', '#bfb960', '#00ffcc', '#23318c', '#a6537f', '#734939',
1160 1164 '#b2a700', '#104036', '#3d3df2', '#402031', '#e56739', '#736f39',
1161 1165 '#79f2ea', '#000059', '#401029', '#4c1400', '#ffee00', '#005953',
1162 1166 '#101040', '#990052', '#402820', '#403d10', '#00ffee', '#0000d9',
1163 1167 '#ff80c4', '#a66953', '#eeff00', '#00ccbe', '#8080ff', '#e673a1',
1164 1168 '#a62c00', '#474d00', '#1a3331', '#46468c', '#733950', '#662900',
1165 1169 '#858c23', '#238c85', '#0f0073', '#b20047', '#d9986c', '#becc00',
1166 1170 '#396f73', '#281d73', '#ff0066', '#ff6600', '#dee673', '#59adb3',
1167 1171 '#6559b3', '#590024', '#b2622d', '#98b32d', '#36ced9', '#332d59',
1168 1172 '#40001a', '#733f1d', '#526600', '#005359', '#242040', '#bf6079',
1169 1173 '#735039', '#cef23d', '#007780', '#5630bf', '#66001b', '#b24700',
1170 1174 '#acbf60', '#1d6273', '#25008c', '#731d34', '#a67453', '#50592d',
1171 1175 '#00ccff', '#6600ff', '#ff0044', '#4c1f00', '#8a994d', '#79daf2',
1172 1176 '#a173e6', '#d93662', '#402310', '#aaff00', '#2d98b3', '#8c40ff',
1173 1177 '#592d39', '#ff8c40', '#354020', '#103640', '#1a0040', '#331a20',
1174 1178 '#331400', '#334d00', '#1d5673', '#583973', '#7f0022', '#4c3626',
1175 1179 '#88cc00', '#36a3d9', '#3d0073', '#d9364c', '#33241a', '#698c23',
1176 1180 '#5995b3', '#300059', '#e57382', '#7f3300', '#366600', '#00aaff',
1177 1181 '#3a1659', '#733941', '#663600', '#74b32d', '#003c59', '#7f53a6',
1178 1182 '#73000f', '#ff8800', '#baf279', '#79caf2', '#291040', '#a6293a',
1179 1183 '#b2742d', '#587339', '#0077b3', '#632699', '#400009', '#d9a66c',
1180 1184 '#294010', '#2d4a59', '#aa00ff', '#4c131b', '#b25f00', '#5ce600',
1181 1185 '#267399', '#a336d9', '#990014', '#664e33', '#86bf60', '#0088ff',
1182 1186 '#7700b3', '#593a16', '#073300', '#1d4b73', '#ac60bf', '#e59539',
1183 1187 '#4f8c46', '#368dd9', '#5c0073'
1184 1188 ]
1185 1189
1186 1190 def rgb_to_hex_color(self, rgb_tuple):
1187 1191 """
1188 1192 Converts an rgb_tuple passed to an hex color.
1189 1193
1190 1194 :param rgb_tuple: tuple with 3 ints represents rgb color space
1191 1195 """
1192 1196 return '#' + ("".join(map(chr, rgb_tuple)).encode('hex'))
1193 1197
1194 1198 def email_to_int_list(self, email_str):
1195 1199 """
1196 1200 Get every byte of the hex digest value of email and turn it to integer.
1197 1201 It's going to be always between 0-255
1198 1202 """
1199 1203 digest = md5_safe(email_str.lower())
1200 1204 return [int(digest[i * 2:i * 2 + 2], 16) for i in range(16)]
1201 1205
1202 1206 def pick_color_bank_index(self, email_str, color_bank):
1203 1207 return self.email_to_int_list(email_str)[0] % len(color_bank)
1204 1208
1205 1209 def str2color(self, email_str):
1206 1210 """
1207 1211 Tries to map in a stable algorithm an email to color
1208 1212
1209 1213 :param email_str:
1210 1214 """
1211 1215 color_bank = self.get_color_bank()
1212 1216 # pick position (module it's length so we always find it in the
1213 1217 # bank even if it's smaller than 256 values
1214 1218 pos = self.pick_color_bank_index(email_str, color_bank)
1215 1219 return color_bank[pos]
1216 1220
1217 1221 def normalize_email(self, email_address):
1218 1222 import unicodedata
1219 1223 # default host used to fill in the fake/missing email
1220 1224 default_host = u'localhost'
1221 1225
1222 1226 if not email_address:
1223 1227 email_address = u'%s@%s' % (User.DEFAULT_USER, default_host)
1224 1228
1225 1229 email_address = safe_unicode(email_address)
1226 1230
1227 1231 if u'@' not in email_address:
1228 1232 email_address = u'%s@%s' % (email_address, default_host)
1229 1233
1230 1234 if email_address.endswith(u'@'):
1231 1235 email_address = u'%s%s' % (email_address, default_host)
1232 1236
1233 1237 email_address = unicodedata.normalize('NFKD', email_address)\
1234 1238 .encode('ascii', 'ignore')
1235 1239 return email_address
1236 1240
1237 1241 def get_initials(self):
1238 1242 """
1239 1243 Returns 2 letter initials calculated based on the input.
1240 1244 The algorithm picks first given email address, and takes first letter
1241 1245 of part before @, and then the first letter of server name. In case
1242 1246 the part before @ is in a format of `somestring.somestring2` it replaces
1243 1247 the server letter with first letter of somestring2
1244 1248
1245 1249 In case function was initialized with both first and lastname, this
1246 1250 overrides the extraction from email by first letter of the first and
1247 1251 last name. We add special logic to that functionality, In case Full name
1248 1252 is compound, like Guido Von Rossum, we use last part of the last name
1249 1253 (Von Rossum) picking `R`.
1250 1254
1251 1255 Function also normalizes the non-ascii characters to they ascii
1252 1256 representation, eg Ą => A
1253 1257 """
1254 1258 import unicodedata
1255 1259 # replace non-ascii to ascii
1256 1260 first_name = unicodedata.normalize(
1257 1261 'NFKD', safe_unicode(self.first_name)).encode('ascii', 'ignore')
1258 1262 last_name = unicodedata.normalize(
1259 1263 'NFKD', safe_unicode(self.last_name)).encode('ascii', 'ignore')
1260 1264
1261 1265 # do NFKD encoding, and also make sure email has proper format
1262 1266 email_address = self.normalize_email(self.email_address)
1263 1267
1264 1268 # first push the email initials
1265 1269 prefix, server = email_address.split('@', 1)
1266 1270
1267 1271 # check if prefix is maybe a 'first_name.last_name' syntax
1268 1272 _dot_split = prefix.rsplit('.', 1)
1269 1273 if len(_dot_split) == 2 and _dot_split[1]:
1270 1274 initials = [_dot_split[0][0], _dot_split[1][0]]
1271 1275 else:
1272 1276 initials = [prefix[0], server[0]]
1273 1277
1274 1278 # then try to replace either first_name or last_name
1275 1279 fn_letter = (first_name or " ")[0].strip()
1276 1280 ln_letter = (last_name.split(' ', 1)[-1] or " ")[0].strip()
1277 1281
1278 1282 if fn_letter:
1279 1283 initials[0] = fn_letter
1280 1284
1281 1285 if ln_letter:
1282 1286 initials[1] = ln_letter
1283 1287
1284 1288 return ''.join(initials).upper()
1285 1289
1286 1290 def get_img_data_by_type(self, font_family, img_type):
1287 1291 default_user = """
1288 1292 <svg xmlns="http://www.w3.org/2000/svg"
1289 1293 version="1.1" x="0px" y="0px" width="{size}" height="{size}"
1290 1294 viewBox="-15 -10 439.165 429.164"
1291 1295
1292 1296 xml:space="preserve"
1293 1297 style="background:{background};" >
1294 1298
1295 1299 <path d="M204.583,216.671c50.664,0,91.74-48.075,
1296 1300 91.74-107.378c0-82.237-41.074-107.377-91.74-107.377
1297 1301 c-50.668,0-91.74,25.14-91.74,107.377C112.844,
1298 1302 168.596,153.916,216.671,
1299 1303 204.583,216.671z" fill="{text_color}"/>
1300 1304 <path d="M407.164,374.717L360.88,
1301 1305 270.454c-2.117-4.771-5.836-8.728-10.465-11.138l-71.83-37.392
1302 1306 c-1.584-0.823-3.502-0.663-4.926,0.415c-20.316,
1303 1307 15.366-44.203,23.488-69.076,23.488c-24.877,
1304 1308 0-48.762-8.122-69.078-23.488
1305 1309 c-1.428-1.078-3.346-1.238-4.93-0.415L58.75,
1306 1310 259.316c-4.631,2.41-8.346,6.365-10.465,11.138L2.001,374.717
1307 1311 c-3.191,7.188-2.537,15.412,1.75,22.005c4.285,
1308 1312 6.592,11.537,10.526,19.4,10.526h362.861c7.863,0,15.117-3.936,
1309 1313 19.402-10.527 C409.699,390.129,
1310 1314 410.355,381.902,407.164,374.717z" fill="{text_color}"/>
1311 1315 </svg>""".format(
1312 1316 size=self.size,
1313 1317 background='#979797', # @grey4
1314 1318 text_color=self.text_color,
1315 1319 font_family=font_family)
1316 1320
1317 1321 return {
1318 1322 "default_user": default_user
1319 1323 }[img_type]
1320 1324
1321 1325 def get_img_data(self, svg_type=None):
1322 1326 """
1323 1327 generates the svg metadata for image
1324 1328 """
1325 1329 fonts = [
1326 1330 '-apple-system',
1327 1331 'BlinkMacSystemFont',
1328 1332 'Segoe UI',
1329 1333 'Roboto',
1330 1334 'Oxygen-Sans',
1331 1335 'Ubuntu',
1332 1336 'Cantarell',
1333 1337 'Helvetica Neue',
1334 1338 'sans-serif'
1335 1339 ]
1336 1340 font_family = ','.join(fonts)
1337 1341 if svg_type:
1338 1342 return self.get_img_data_by_type(font_family, svg_type)
1339 1343
1340 1344 initials = self.get_initials()
1341 1345 img_data = """
1342 1346 <svg xmlns="http://www.w3.org/2000/svg" pointer-events="none"
1343 1347 width="{size}" height="{size}"
1344 1348 style="width: 100%; height: 100%; background-color: {background}"
1345 1349 viewBox="0 0 {size} {size}">
1346 1350 <text text-anchor="middle" y="50%" x="50%" dy="0.35em"
1347 1351 pointer-events="auto" fill="{text_color}"
1348 1352 font-family="{font_family}"
1349 1353 style="font-weight: 400; font-size: {f_size}px;">{text}
1350 1354 </text>
1351 1355 </svg>""".format(
1352 1356 size=self.size,
1353 1357 f_size=self.size/2.05, # scale the text inside the box nicely
1354 1358 background=self.background,
1355 1359 text_color=self.text_color,
1356 1360 text=initials.upper(),
1357 1361 font_family=font_family)
1358 1362
1359 1363 return img_data
1360 1364
1361 1365 def generate_svg(self, svg_type=None):
1362 1366 img_data = self.get_img_data(svg_type)
1363 1367 return "data:image/svg+xml;base64,%s" % base64.b64encode(img_data)
1364 1368
1365 1369
1366 1370 def initials_gravatar(request, email_address, first_name, last_name, size=30, store_on_disk=False):
1367 1371
1368 1372 svg_type = None
1369 1373 if email_address == User.DEFAULT_USER_EMAIL:
1370 1374 svg_type = 'default_user'
1371 1375
1372 1376 klass = InitialsGravatar(email_address, first_name, last_name, size)
1373 1377
1374 1378 if store_on_disk:
1375 1379 from rhodecode.apps.file_store import utils as store_utils
1376 1380 from rhodecode.apps.file_store.exceptions import FileNotAllowedException, \
1377 1381 FileOverSizeException
1378 1382 from rhodecode.model.db import Session
1379 1383
1380 1384 image_key = md5_safe(email_address.lower()
1381 1385 + first_name.lower() + last_name.lower())
1382 1386
1383 1387 storage = store_utils.get_file_storage(request.registry.settings)
1384 1388 filename = '{}.svg'.format(image_key)
1385 1389 subdir = 'gravatars'
1386 1390 # since final name has a counter, we apply the 0
1387 1391 uid = storage.apply_counter(0, store_utils.uid_filename(filename, randomized=False))
1388 1392 store_uid = os.path.join(subdir, uid)
1389 1393
1390 1394 db_entry = FileStore.get_by_store_uid(store_uid)
1391 1395 if db_entry:
1392 1396 return request.route_path('download_file', fid=store_uid)
1393 1397
1394 1398 img_data = klass.get_img_data(svg_type=svg_type)
1395 1399 img_file = store_utils.bytes_to_file_obj(img_data)
1396 1400
1397 1401 try:
1398 1402 store_uid, metadata = storage.save_file(
1399 1403 img_file, filename, directory=subdir,
1400 1404 extensions=['.svg'], randomized_name=False)
1401 1405 except (FileNotAllowedException, FileOverSizeException):
1402 1406 raise
1403 1407
1404 1408 try:
1405 1409 entry = FileStore.create(
1406 1410 file_uid=store_uid, filename=metadata["filename"],
1407 1411 file_hash=metadata["sha256"], file_size=metadata["size"],
1408 1412 file_display_name=filename,
1409 1413 file_description=u'user gravatar `{}`'.format(safe_unicode(filename)),
1410 1414 hidden=True, check_acl=False, user_id=1
1411 1415 )
1412 1416 Session().add(entry)
1413 1417 Session().commit()
1414 1418 log.debug('Stored upload in DB as %s', entry)
1415 1419 except Exception:
1416 1420 raise
1417 1421
1418 1422 return request.route_path('download_file', fid=store_uid)
1419 1423
1420 1424 else:
1421 1425 return klass.generate_svg(svg_type=svg_type)
1422 1426
1423 1427
1424 1428 def gravatar_external(request, gravatar_url_tmpl, email_address, size=30):
1425 1429 return safe_str(gravatar_url_tmpl)\
1426 1430 .replace('{email}', email_address) \
1427 1431 .replace('{md5email}', md5_safe(email_address.lower())) \
1428 1432 .replace('{netloc}', request.host) \
1429 1433 .replace('{scheme}', request.scheme) \
1430 1434 .replace('{size}', safe_str(size))
1431 1435
1432 1436
1433 1437 def gravatar_url(email_address, size=30, request=None):
1434 1438 request = request or get_current_request()
1435 1439 _use_gravatar = request.call_context.visual.use_gravatar
1436 1440
1437 1441 email_address = email_address or User.DEFAULT_USER_EMAIL
1438 1442 if isinstance(email_address, unicode):
1439 1443 # hashlib crashes on unicode items
1440 1444 email_address = safe_str(email_address)
1441 1445
1442 1446 # empty email or default user
1443 1447 if not email_address or email_address == User.DEFAULT_USER_EMAIL:
1444 1448 return initials_gravatar(request, User.DEFAULT_USER_EMAIL, '', '', size=size)
1445 1449
1446 1450 if _use_gravatar:
1447 1451 gravatar_url_tmpl = request.call_context.visual.gravatar_url \
1448 1452 or User.DEFAULT_GRAVATAR_URL
1449 1453 return gravatar_external(request, gravatar_url_tmpl, email_address, size=size)
1450 1454
1451 1455 else:
1452 1456 return initials_gravatar(request, email_address, '', '', size=size)
1453 1457
1454 1458
1455 1459 def breadcrumb_repo_link(repo):
1456 1460 """
1457 1461 Makes a breadcrumbs path link to repo
1458 1462
1459 1463 ex::
1460 1464 group >> subgroup >> repo
1461 1465
1462 1466 :param repo: a Repository instance
1463 1467 """
1464 1468
1465 1469 path = [
1466 1470 link_to(group.name, route_path('repo_group_home', repo_group_name=group.group_name),
1467 1471 title='last change:{}'.format(format_date(group.last_commit_change)))
1468 1472 for group in repo.groups_with_parents
1469 1473 ] + [
1470 1474 link_to(repo.just_name, route_path('repo_summary', repo_name=repo.repo_name),
1471 1475 title='last change:{}'.format(format_date(repo.last_commit_change)))
1472 1476 ]
1473 1477
1474 1478 return literal(' &raquo; '.join(path))
1475 1479
1476 1480
1477 1481 def breadcrumb_repo_group_link(repo_group):
1478 1482 """
1479 1483 Makes a breadcrumbs path link to repo
1480 1484
1481 1485 ex::
1482 1486 group >> subgroup
1483 1487
1484 1488 :param repo_group: a Repository Group instance
1485 1489 """
1486 1490
1487 1491 path = [
1488 1492 link_to(group.name,
1489 1493 route_path('repo_group_home', repo_group_name=group.group_name),
1490 1494 title='last change:{}'.format(format_date(group.last_commit_change)))
1491 1495 for group in repo_group.parents
1492 1496 ] + [
1493 1497 link_to(repo_group.name,
1494 1498 route_path('repo_group_home', repo_group_name=repo_group.group_name),
1495 1499 title='last change:{}'.format(format_date(repo_group.last_commit_change)))
1496 1500 ]
1497 1501
1498 1502 return literal(' &raquo; '.join(path))
1499 1503
1500 1504
1501 1505 def format_byte_size_binary(file_size):
1502 1506 """
1503 1507 Formats file/folder sizes to standard.
1504 1508 """
1505 1509 if file_size is None:
1506 1510 file_size = 0
1507 1511
1508 1512 formatted_size = format_byte_size(file_size, binary=True)
1509 1513 return formatted_size
1510 1514
1511 1515
1512 1516 def urlify_text(text_, safe=True, **href_attrs):
1513 1517 """
1514 1518 Extract urls from text and make html links out of them
1515 1519 """
1516 1520
1517 1521 url_pat = re.compile(r'''(http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@#.&+]'''
1518 1522 '''|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+)''')
1519 1523
1520 1524 def url_func(match_obj):
1521 1525 url_full = match_obj.groups()[0]
1522 1526 a_options = dict(href_attrs)
1523 1527 a_options['href'] = url_full
1524 1528 a_text = url_full
1525 1529 return HTML.tag("a", a_text, **a_options)
1526 1530
1527 1531 _new_text = url_pat.sub(url_func, text_)
1528 1532
1529 1533 if safe:
1530 1534 return literal(_new_text)
1531 1535 return _new_text
1532 1536
1533 1537
1534 1538 def urlify_commits(text_, repo_name):
1535 1539 """
1536 1540 Extract commit ids from text and make link from them
1537 1541
1538 1542 :param text_:
1539 1543 :param repo_name: repo name to build the URL with
1540 1544 """
1541 1545
1542 1546 url_pat = re.compile(r'(^|\s)([0-9a-fA-F]{12,40})($|\s)')
1543 1547
1544 1548 def url_func(match_obj):
1545 1549 commit_id = match_obj.groups()[1]
1546 1550 pref = match_obj.groups()[0]
1547 1551 suf = match_obj.groups()[2]
1548 1552
1549 1553 tmpl = (
1550 1554 '%(pref)s<a class="tooltip-hovercard %(cls)s" href="%(url)s" data-hovercard-alt="%(hovercard_alt)s" data-hovercard-url="%(hovercard_url)s">'
1551 1555 '%(commit_id)s</a>%(suf)s'
1552 1556 )
1553 1557 return tmpl % {
1554 1558 'pref': pref,
1555 1559 'cls': 'revision-link',
1556 1560 'url': route_url(
1557 1561 'repo_commit', repo_name=repo_name, commit_id=commit_id),
1558 1562 'commit_id': commit_id,
1559 1563 'suf': suf,
1560 1564 'hovercard_alt': 'Commit: {}'.format(commit_id),
1561 1565 'hovercard_url': route_url(
1562 1566 'hovercard_repo_commit', repo_name=repo_name, commit_id=commit_id)
1563 1567 }
1564 1568
1565 1569 new_text = url_pat.sub(url_func, text_)
1566 1570
1567 1571 return new_text
1568 1572
1569 1573
1570 1574 def _process_url_func(match_obj, repo_name, uid, entry,
1571 1575 return_raw_data=False, link_format='html'):
1572 1576 pref = ''
1573 1577 if match_obj.group().startswith(' '):
1574 1578 pref = ' '
1575 1579
1576 1580 issue_id = ''.join(match_obj.groups())
1577 1581
1578 1582 if link_format == 'html':
1579 1583 tmpl = (
1580 1584 '%(pref)s<a class="tooltip %(cls)s" href="%(url)s" title="%(title)s">'
1581 1585 '%(issue-prefix)s%(id-repr)s'
1582 1586 '</a>')
1583 1587 elif link_format == 'html+hovercard':
1584 1588 tmpl = (
1585 1589 '%(pref)s<a class="tooltip-hovercard %(cls)s" href="%(url)s" data-hovercard-url="%(hovercard_url)s">'
1586 1590 '%(issue-prefix)s%(id-repr)s'
1587 1591 '</a>')
1588 1592 elif link_format in ['rst', 'rst+hovercard']:
1589 1593 tmpl = '`%(issue-prefix)s%(id-repr)s <%(url)s>`_'
1590 1594 elif link_format in ['markdown', 'markdown+hovercard']:
1591 1595 tmpl = '[%(pref)s%(issue-prefix)s%(id-repr)s](%(url)s)'
1592 1596 else:
1593 1597 raise ValueError('Bad link_format:{}'.format(link_format))
1594 1598
1595 1599 (repo_name_cleaned,
1596 1600 parent_group_name) = RepoGroupModel()._get_group_name_and_parent(repo_name)
1597 1601
1598 1602 # variables replacement
1599 1603 named_vars = {
1600 1604 'id': issue_id,
1601 1605 'repo': repo_name,
1602 1606 'repo_name': repo_name_cleaned,
1603 1607 'group_name': parent_group_name,
1604 1608 # set dummy keys so we always have them
1605 1609 'hostname': '',
1606 1610 'netloc': '',
1607 1611 'scheme': ''
1608 1612 }
1609 1613
1610 1614 request = get_current_request()
1611 1615 if request:
1612 1616 # exposes, hostname, netloc, scheme
1613 1617 host_data = get_host_info(request)
1614 1618 named_vars.update(host_data)
1615 1619
1616 1620 # named regex variables
1617 1621 named_vars.update(match_obj.groupdict())
1618 1622 _url = string.Template(entry['url']).safe_substitute(**named_vars)
1619 1623 desc = string.Template(escape(entry['desc'])).safe_substitute(**named_vars)
1620 1624 hovercard_url = string.Template(entry.get('hovercard_url', '')).safe_substitute(**named_vars)
1621 1625
1622 1626 def quote_cleaner(input_str):
1623 1627 """Remove quotes as it's HTML"""
1624 1628 return input_str.replace('"', '')
1625 1629
1626 1630 data = {
1627 1631 'pref': pref,
1628 1632 'cls': quote_cleaner('issue-tracker-link'),
1629 1633 'url': quote_cleaner(_url),
1630 1634 'id-repr': issue_id,
1631 1635 'issue-prefix': entry['pref'],
1632 1636 'serv': entry['url'],
1633 1637 'title': bleach.clean(desc, strip=True),
1634 1638 'hovercard_url': hovercard_url
1635 1639 }
1636 1640
1637 1641 if return_raw_data:
1638 1642 return {
1639 1643 'id': issue_id,
1640 1644 'url': _url
1641 1645 }
1642 1646 return tmpl % data
1643 1647
1644 1648
1645 1649 def get_active_pattern_entries(repo_name):
1646 1650 repo = None
1647 1651 if repo_name:
1648 1652 # Retrieving repo_name to avoid invalid repo_name to explode on
1649 1653 # IssueTrackerSettingsModel but still passing invalid name further down
1650 1654 repo = Repository.get_by_repo_name(repo_name, cache=True)
1651 1655
1652 1656 settings_model = IssueTrackerSettingsModel(repo=repo)
1653 1657 active_entries = settings_model.get_settings(cache=True)
1654 1658 return active_entries
1655 1659
1656 1660
1657 1661 pr_pattern_re = regex.compile(r'(?:(?:^!)|(?: !))(\d+)')
1658 1662
1659 1663 allowed_link_formats = [
1660 1664 'html', 'rst', 'markdown', 'html+hovercard', 'rst+hovercard', 'markdown+hovercard']
1661 1665
1662 1666
1663 1667 def process_patterns(text_string, repo_name, link_format='html', active_entries=None):
1664 1668
1665 1669 if link_format not in allowed_link_formats:
1666 1670 raise ValueError('Link format can be only one of:{} got {}'.format(
1667 1671 allowed_link_formats, link_format))
1668 1672
1669 1673 if active_entries is None:
1670 1674 log.debug('Fetch active issue tracker patterns for repo: %s', repo_name)
1671 1675 active_entries = get_active_pattern_entries(repo_name)
1672 1676
1673 1677 issues_data = []
1674 1678 errors = []
1675 1679 new_text = text_string
1676 1680
1677 1681 log.debug('Got %s entries to process', len(active_entries))
1678 1682 for uid, entry in active_entries.items():
1679 1683 log.debug('found issue tracker entry with uid %s', uid)
1680 1684
1681 1685 if not (entry['pat'] and entry['url']):
1682 1686 log.debug('skipping due to missing data')
1683 1687 continue
1684 1688
1685 1689 log.debug('issue tracker entry: uid: `%s` PAT:%s URL:%s PREFIX:%s',
1686 1690 uid, entry['pat'], entry['url'], entry['pref'])
1687 1691
1688 1692 if entry.get('pat_compiled'):
1689 1693 pattern = entry['pat_compiled']
1690 1694 else:
1691 1695 try:
1692 1696 pattern = regex.compile(r'%s' % entry['pat'])
1693 1697 except regex.error as e:
1694 1698 regex_err = ValueError('{}:{}'.format(entry['pat'], e))
1695 1699 log.exception('issue tracker pattern: `%s` failed to compile', regex_err)
1696 1700 errors.append(regex_err)
1697 1701 continue
1698 1702
1699 1703 data_func = partial(
1700 1704 _process_url_func, repo_name=repo_name, entry=entry, uid=uid,
1701 1705 return_raw_data=True)
1702 1706
1703 1707 for match_obj in pattern.finditer(text_string):
1704 1708 issues_data.append(data_func(match_obj))
1705 1709
1706 1710 url_func = partial(
1707 1711 _process_url_func, repo_name=repo_name, entry=entry, uid=uid,
1708 1712 link_format=link_format)
1709 1713
1710 1714 new_text = pattern.sub(url_func, new_text)
1711 1715 log.debug('processed prefix:uid `%s`', uid)
1712 1716
1713 1717 # finally use global replace, eg !123 -> pr-link, those will not catch
1714 1718 # if already similar pattern exists
1715 1719 server_url = '${scheme}://${netloc}'
1716 1720 pr_entry = {
1717 1721 'pref': '!',
1718 1722 'url': server_url + '/_admin/pull-requests/${id}',
1719 1723 'desc': 'Pull Request !${id}',
1720 1724 'hovercard_url': server_url + '/_hovercard/pull_request/${id}'
1721 1725 }
1722 1726 pr_url_func = partial(
1723 1727 _process_url_func, repo_name=repo_name, entry=pr_entry, uid=None,
1724 1728 link_format=link_format+'+hovercard')
1725 1729 new_text = pr_pattern_re.sub(pr_url_func, new_text)
1726 1730 log.debug('processed !pr pattern')
1727 1731
1728 1732 return new_text, issues_data, errors
1729 1733
1730 1734
1731 1735 def urlify_commit_message(commit_text, repository=None, active_pattern_entries=None,
1732 1736 issues_container=None, error_container=None):
1733 1737 """
1734 1738 Parses given text message and makes proper links.
1735 1739 issues are linked to given issue-server, and rest is a commit link
1736 1740 """
1737 1741
1738 1742 def escaper(_text):
1739 1743 return _text.replace('<', '&lt;').replace('>', '&gt;')
1740 1744
1741 1745 new_text = escaper(commit_text)
1742 1746
1743 1747 # extract http/https links and make them real urls
1744 1748 new_text = urlify_text(new_text, safe=False)
1745 1749
1746 1750 # urlify commits - extract commit ids and make link out of them, if we have
1747 1751 # the scope of repository present.
1748 1752 if repository:
1749 1753 new_text = urlify_commits(new_text, repository)
1750 1754
1751 1755 # process issue tracker patterns
1752 1756 new_text, issues, errors = process_patterns(
1753 1757 new_text, repository or '', active_entries=active_pattern_entries)
1754 1758
1755 1759 if issues_container is not None:
1756 1760 issues_container.extend(issues)
1757 1761
1758 1762 if error_container is not None:
1759 1763 error_container.extend(errors)
1760 1764
1761 1765 return literal(new_text)
1762 1766
1763 1767
1764 1768 def render_binary(repo_name, file_obj):
1765 1769 """
1766 1770 Choose how to render a binary file
1767 1771 """
1768 1772
1769 1773 # unicode
1770 1774 filename = file_obj.name
1771 1775
1772 1776 # images
1773 1777 for ext in ['*.png', '*.jpeg', '*.jpg', '*.ico', '*.gif']:
1774 1778 if fnmatch.fnmatch(filename, pat=ext):
1775 1779 src = route_path(
1776 1780 'repo_file_raw', repo_name=repo_name,
1777 1781 commit_id=file_obj.commit.raw_id,
1778 1782 f_path=file_obj.path)
1779 1783
1780 1784 return literal(
1781 1785 '<img class="rendered-binary" alt="rendered-image" src="{}">'.format(src))
1782 1786
1783 1787
1784 1788 def renderer_from_filename(filename, exclude=None):
1785 1789 """
1786 1790 choose a renderer based on filename, this works only for text based files
1787 1791 """
1788 1792
1789 1793 # ipython
1790 1794 for ext in ['*.ipynb']:
1791 1795 if fnmatch.fnmatch(filename, pat=ext):
1792 1796 return 'jupyter'
1793 1797
1794 1798 is_markup = MarkupRenderer.renderer_from_filename(filename, exclude=exclude)
1795 1799 if is_markup:
1796 1800 return is_markup
1797 1801 return None
1798 1802
1799 1803
1800 1804 def render(source, renderer='rst', mentions=False, relative_urls=None,
1801 1805 repo_name=None, active_pattern_entries=None, issues_container=None):
1802 1806
1803 1807 def maybe_convert_relative_links(html_source):
1804 1808 if relative_urls:
1805 1809 return relative_links(html_source, relative_urls)
1806 1810 return html_source
1807 1811
1808 1812 if renderer == 'plain':
1809 1813 return literal(
1810 1814 MarkupRenderer.plain(source, leading_newline=False))
1811 1815
1812 1816 elif renderer == 'rst':
1813 1817 if repo_name:
1814 1818 # process patterns on comments if we pass in repo name
1815 1819 source, issues, errors = process_patterns(
1816 1820 source, repo_name, link_format='rst',
1817 1821 active_entries=active_pattern_entries)
1818 1822 if issues_container is not None:
1819 1823 issues_container.extend(issues)
1820 1824
1821 1825 return literal(
1822 1826 '<div class="rst-block">%s</div>' %
1823 1827 maybe_convert_relative_links(
1824 1828 MarkupRenderer.rst(source, mentions=mentions)))
1825 1829
1826 1830 elif renderer == 'markdown':
1827 1831 if repo_name:
1828 1832 # process patterns on comments if we pass in repo name
1829 1833 source, issues, errors = process_patterns(
1830 1834 source, repo_name, link_format='markdown',
1831 1835 active_entries=active_pattern_entries)
1832 1836 if issues_container is not None:
1833 1837 issues_container.extend(issues)
1834 1838
1835 1839 return literal(
1836 1840 '<div class="markdown-block">%s</div>' %
1837 1841 maybe_convert_relative_links(
1838 1842 MarkupRenderer.markdown(source, flavored=True,
1839 1843 mentions=mentions)))
1840 1844
1841 1845 elif renderer == 'jupyter':
1842 1846 return literal(
1843 1847 '<div class="ipynb">%s</div>' %
1844 1848 maybe_convert_relative_links(
1845 1849 MarkupRenderer.jupyter(source)))
1846 1850
1847 1851 # None means just show the file-source
1848 1852 return None
1849 1853
1850 1854
1851 1855 def commit_status(repo, commit_id):
1852 1856 return ChangesetStatusModel().get_status(repo, commit_id)
1853 1857
1854 1858
1855 1859 def commit_status_lbl(commit_status):
1856 1860 return dict(ChangesetStatus.STATUSES).get(commit_status)
1857 1861
1858 1862
1859 1863 def commit_time(repo_name, commit_id):
1860 1864 repo = Repository.get_by_repo_name(repo_name)
1861 1865 commit = repo.get_commit(commit_id=commit_id)
1862 1866 return commit.date
1863 1867
1864 1868
1865 1869 def get_permission_name(key):
1866 1870 return dict(Permission.PERMS).get(key)
1867 1871
1868 1872
1869 1873 def journal_filter_help(request):
1870 1874 _ = request.translate
1871 1875 from rhodecode.lib.audit_logger import ACTIONS
1872 1876 actions = '\n'.join(textwrap.wrap(', '.join(sorted(ACTIONS.keys())), 80))
1873 1877
1874 1878 return _(
1875 1879 'Example filter terms:\n' +
1876 1880 ' repository:vcs\n' +
1877 1881 ' username:marcin\n' +
1878 1882 ' username:(NOT marcin)\n' +
1879 1883 ' action:*push*\n' +
1880 1884 ' ip:127.0.0.1\n' +
1881 1885 ' date:20120101\n' +
1882 1886 ' date:[20120101100000 TO 20120102]\n' +
1883 1887 '\n' +
1884 1888 'Actions: {actions}\n' +
1885 1889 '\n' +
1886 1890 'Generate wildcards using \'*\' character:\n' +
1887 1891 ' "repository:vcs*" - search everything starting with \'vcs\'\n' +
1888 1892 ' "repository:*vcs*" - search for repository containing \'vcs\'\n' +
1889 1893 '\n' +
1890 1894 'Optional AND / OR operators in queries\n' +
1891 1895 ' "repository:vcs OR repository:test"\n' +
1892 1896 ' "username:test AND repository:test*"\n'
1893 1897 ).format(actions=actions)
1894 1898
1895 1899
1896 1900 def not_mapped_error(repo_name):
1897 1901 from rhodecode.translation import _
1898 1902 flash(_('%s repository is not mapped to db perhaps'
1899 1903 ' it was created or renamed from the filesystem'
1900 1904 ' please run the application again'
1901 1905 ' in order to rescan repositories') % repo_name, category='error')
1902 1906
1903 1907
1904 1908 def ip_range(ip_addr):
1905 1909 from rhodecode.model.db import UserIpMap
1906 1910 s, e = UserIpMap._get_ip_range(ip_addr)
1907 1911 return '%s - %s' % (s, e)
1908 1912
1909 1913
1910 1914 def form(url, method='post', needs_csrf_token=True, **attrs):
1911 1915 """Wrapper around webhelpers.tags.form to prevent CSRF attacks."""
1912 1916 if method.lower() != 'get' and needs_csrf_token:
1913 1917 raise Exception(
1914 1918 'Forms to POST/PUT/DELETE endpoints should have (in general) a ' +
1915 1919 'CSRF token. If the endpoint does not require such token you can ' +
1916 1920 'explicitly set the parameter needs_csrf_token to false.')
1917 1921
1918 1922 return insecure_form(url, method=method, **attrs)
1919 1923
1920 1924
1921 1925 def secure_form(form_url, method="POST", multipart=False, **attrs):
1922 1926 """Start a form tag that points the action to an url. This
1923 1927 form tag will also include the hidden field containing
1924 1928 the auth token.
1925 1929
1926 1930 The url options should be given either as a string, or as a
1927 1931 ``url()`` function. The method for the form defaults to POST.
1928 1932
1929 1933 Options:
1930 1934
1931 1935 ``multipart``
1932 1936 If set to True, the enctype is set to "multipart/form-data".
1933 1937 ``method``
1934 1938 The method to use when submitting the form, usually either
1935 1939 "GET" or "POST". If "PUT", "DELETE", or another verb is used, a
1936 1940 hidden input with name _method is added to simulate the verb
1937 1941 over POST.
1938 1942
1939 1943 """
1940 1944
1941 1945 if 'request' in attrs:
1942 1946 session = attrs['request'].session
1943 1947 del attrs['request']
1944 1948 else:
1945 1949 raise ValueError(
1946 1950 'Calling this form requires request= to be passed as argument')
1947 1951
1948 1952 _form = insecure_form(form_url, method, multipart, **attrs)
1949 1953 token = literal(
1950 1954 '<input type="hidden" name="{}" value="{}">'.format(
1951 1955 csrf_token_key, get_csrf_token(session)))
1952 1956
1953 1957 return literal("%s\n%s" % (_form, token))
1954 1958
1955 1959
1956 1960 def dropdownmenu(name, selected, options, enable_filter=False, **attrs):
1957 1961 select_html = select(name, selected, options, **attrs)
1958 1962
1959 1963 select2 = """
1960 1964 <script>
1961 1965 $(document).ready(function() {
1962 1966 $('#%s').select2({
1963 1967 containerCssClass: 'drop-menu %s',
1964 1968 dropdownCssClass: 'drop-menu-dropdown',
1965 1969 dropdownAutoWidth: true%s
1966 1970 });
1967 1971 });
1968 1972 </script>
1969 1973 """
1970 1974
1971 1975 filter_option = """,
1972 1976 minimumResultsForSearch: -1
1973 1977 """
1974 1978 input_id = attrs.get('id') or name
1975 1979 extra_classes = ' '.join(attrs.pop('extra_classes', []))
1976 1980 filter_enabled = "" if enable_filter else filter_option
1977 1981 select_script = literal(select2 % (input_id, extra_classes, filter_enabled))
1978 1982
1979 1983 return literal(select_html+select_script)
1980 1984
1981 1985
1982 1986 def get_visual_attr(tmpl_context_var, attr_name):
1983 1987 """
1984 1988 A safe way to get a variable from visual variable of template context
1985 1989
1986 1990 :param tmpl_context_var: instance of tmpl_context, usually present as `c`
1987 1991 :param attr_name: name of the attribute we fetch from the c.visual
1988 1992 """
1989 1993 visual = getattr(tmpl_context_var, 'visual', None)
1990 1994 if not visual:
1991 1995 return
1992 1996 else:
1993 1997 return getattr(visual, attr_name, None)
1994 1998
1995 1999
1996 2000 def get_last_path_part(file_node):
1997 2001 if not file_node.path:
1998 2002 return u'/'
1999 2003
2000 2004 path = safe_unicode(file_node.path.split('/')[-1])
2001 2005 return u'../' + path
2002 2006
2003 2007
2004 2008 def route_url(*args, **kwargs):
2005 2009 """
2006 2010 Wrapper around pyramids `route_url` (fully qualified url) function.
2007 2011 """
2008 2012 req = get_current_request()
2009 2013 return req.route_url(*args, **kwargs)
2010 2014
2011 2015
2012 2016 def route_path(*args, **kwargs):
2013 2017 """
2014 2018 Wrapper around pyramids `route_path` function.
2015 2019 """
2016 2020 req = get_current_request()
2017 2021 return req.route_path(*args, **kwargs)
2018 2022
2019 2023
2020 2024 def route_path_or_none(*args, **kwargs):
2021 2025 try:
2022 2026 return route_path(*args, **kwargs)
2023 2027 except KeyError:
2024 2028 return None
2025 2029
2026 2030
2027 2031 def current_route_path(request, **kw):
2028 2032 new_args = request.GET.mixed()
2029 2033 new_args.update(kw)
2030 2034 return request.current_route_path(_query=new_args)
2031 2035
2032 2036
2033 2037 def curl_api_example(method, args):
2034 2038 args_json = json.dumps(OrderedDict([
2035 2039 ('id', 1),
2036 2040 ('auth_token', 'SECRET'),
2037 2041 ('method', method),
2038 2042 ('args', args)
2039 2043 ]))
2040 2044
2041 2045 return "curl {api_url} -X POST -H 'content-type:text/plain' --data-binary '{args_json}'".format(
2042 2046 api_url=route_url('apiv2'),
2043 2047 args_json=args_json
2044 2048 )
2045 2049
2046 2050
2047 2051 def api_call_example(method, args):
2048 2052 """
2049 2053 Generates an API call example via CURL
2050 2054 """
2051 2055 curl_call = curl_api_example(method, args)
2052 2056
2053 2057 return literal(
2054 2058 curl_call +
2055 2059 "<br/><br/>SECRET can be found in <a href=\"{token_url}\">auth-tokens</a> page, "
2056 2060 "and needs to be of `api calls` role."
2057 2061 .format(token_url=route_url('my_account_auth_tokens')))
2058 2062
2059 2063
2060 2064 def notification_description(notification, request):
2061 2065 """
2062 2066 Generate notification human readable description based on notification type
2063 2067 """
2064 2068 from rhodecode.model.notification import NotificationModel
2065 2069 return NotificationModel().make_description(
2066 2070 notification, translate=request.translate)
2067 2071
2068 2072
2069 2073 def go_import_header(request, db_repo=None):
2070 2074 """
2071 2075 Creates a header for go-import functionality in Go Lang
2072 2076 """
2073 2077
2074 2078 if not db_repo:
2075 2079 return
2076 2080 if 'go-get' not in request.GET:
2077 2081 return
2078 2082
2079 2083 clone_url = db_repo.clone_url()
2080 2084 prefix = re.split(r'^https?:\/\/', clone_url)[-1]
2081 2085 # we have a repo and go-get flag,
2082 2086 return literal('<meta name="go-import" content="{} {} {}">'.format(
2083 2087 prefix, db_repo.repo_type, clone_url))
2084 2088
2085 2089
2086 2090 def reviewer_as_json(*args, **kwargs):
2087 2091 from rhodecode.apps.repository.utils import reviewer_as_json as _reviewer_as_json
2088 2092 return _reviewer_as_json(*args, **kwargs)
2089 2093
2090 2094
2091 2095 def get_repo_view_type(request):
2092 2096 route_name = request.matched_route.name
2093 2097 route_to_view_type = {
2094 2098 'repo_changelog': 'commits',
2095 2099 'repo_commits': 'commits',
2096 2100 'repo_files': 'files',
2097 2101 'repo_summary': 'summary',
2098 2102 'repo_commit': 'commit'
2099 2103 }
2100 2104
2101 2105 return route_to_view_type.get(route_name)
2102 2106
2103 2107
2104 2108 def is_active(menu_entry, selected):
2105 2109 """
2106 2110 Returns active class for selecting menus in templates
2107 2111 <li class=${h.is_active('settings', current_active)}></li>
2108 2112 """
2109 2113 if not isinstance(menu_entry, list):
2110 2114 menu_entry = [menu_entry]
2111 2115
2112 2116 if selected in menu_entry:
2113 2117 return "active"
@@ -1,397 +1,396 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 itertools
23 23 import logging
24 24 import collections
25 25
26 26 from rhodecode.model import BaseModel
27 27 from rhodecode.model.db import (
28 ChangesetStatus, ChangesetComment, PullRequest, Session)
28 ChangesetStatus, ChangesetComment, PullRequest, PullRequestReviewers, Session)
29 29 from rhodecode.lib.exceptions import StatusChangeOnClosedPullRequestError
30 30 from rhodecode.lib.markup_renderer import (
31 31 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
32 32
33 33 log = logging.getLogger(__name__)
34 34
35 35
36 36 class ChangesetStatusModel(BaseModel):
37 37
38 38 cls = ChangesetStatus
39 39
40 40 def __get_changeset_status(self, changeset_status):
41 41 return self._get_instance(ChangesetStatus, changeset_status)
42 42
43 43 def __get_pull_request(self, pull_request):
44 44 return self._get_instance(PullRequest, pull_request)
45 45
46 46 def _get_status_query(self, repo, revision, pull_request,
47 47 with_revisions=False):
48 48 repo = self._get_repo(repo)
49 49
50 50 q = ChangesetStatus.query()\
51 51 .filter(ChangesetStatus.repo == repo)
52 52 if not with_revisions:
53 53 q = q.filter(ChangesetStatus.version == 0)
54 54
55 55 if revision:
56 56 q = q.filter(ChangesetStatus.revision == revision)
57 57 elif pull_request:
58 58 pull_request = self.__get_pull_request(pull_request)
59 59 # TODO: johbo: Think about the impact of this join, there must
60 60 # be a reason why ChangesetStatus and ChanagesetComment is linked
61 61 # to the pull request. Might be that we want to do the same for
62 62 # the pull_request_version_id.
63 63 q = q.join(ChangesetComment).filter(
64 64 ChangesetStatus.pull_request == pull_request,
65 65 ChangesetComment.pull_request_version_id == None)
66 66 else:
67 67 raise Exception('Please specify revision or pull_request')
68 68 q = q.order_by(ChangesetStatus.version.asc())
69 69 return q
70 70
71 71 def calculate_group_vote(self, group_id, group_statuses_by_reviewers,
72 72 trim_votes=True):
73 73 """
74 74 Calculate status based on given group members, and voting rule
75 75
76 76
77 77 group1 - 4 members, 3 required for approval
78 78 user1 - approved
79 79 user2 - reject
80 80 user3 - approved
81 81 user4 - rejected
82 82
83 83 final_state: rejected, reasons not at least 3 votes
84 84
85 85
86 86 group1 - 4 members, 2 required for approval
87 87 user1 - approved
88 88 user2 - reject
89 89 user3 - approved
90 90 user4 - rejected
91 91
92 92 final_state: approved, reasons got at least 2 approvals
93 93
94 94 group1 - 4 members, ALL required for approval
95 95 user1 - approved
96 96 user2 - reject
97 97 user3 - approved
98 98 user4 - rejected
99 99
100 100 final_state: rejected, reasons not all approvals
101 101
102 102
103 103 group1 - 4 members, ALL required for approval
104 104 user1 - approved
105 105 user2 - approved
106 106 user3 - approved
107 107 user4 - approved
108 108
109 109 final_state: approved, reason all approvals received
110 110
111 111 group1 - 4 members, 5 required for approval
112 112 (approval should be shorted to number of actual members)
113 113
114 114 user1 - approved
115 115 user2 - approved
116 116 user3 - approved
117 117 user4 - approved
118 118
119 119 final_state: approved, reason all approvals received
120 120
121 121 """
122 122 group_vote_data = {}
123 123 got_rule = False
124 124 members = collections.OrderedDict()
125 125 for review_obj, user, reasons, mandatory, statuses \
126 126 in group_statuses_by_reviewers:
127 127
128 128 if not got_rule:
129 129 group_vote_data = review_obj.rule_user_group_data()
130 130 got_rule = bool(group_vote_data)
131 131
132 132 members[user.user_id] = statuses
133 133
134 134 if not group_vote_data:
135 135 return []
136 136
137 137 required_votes = group_vote_data['vote_rule']
138 138 if required_votes == -1:
139 139 # -1 means all required, so we replace it with how many people
140 140 # are in the members
141 141 required_votes = len(members)
142 142
143 143 if trim_votes and required_votes > len(members):
144 144 # we require more votes than we have members in the group
145 145 # in this case we trim the required votes to the number of members
146 146 required_votes = len(members)
147 147
148 148 approvals = sum([
149 149 1 for statuses in members.values()
150 150 if statuses and
151 151 statuses[0][1].status == ChangesetStatus.STATUS_APPROVED])
152 152
153 153 calculated_votes = []
154 154 # we have all votes from users, now check if we have enough votes
155 155 # to fill other
156 156 fill_in = ChangesetStatus.STATUS_UNDER_REVIEW
157 157 if approvals >= required_votes:
158 158 fill_in = ChangesetStatus.STATUS_APPROVED
159 159
160 160 for member, statuses in members.items():
161 161 if statuses:
162 162 ver, latest = statuses[0]
163 163 if fill_in == ChangesetStatus.STATUS_APPROVED:
164 164 calculated_votes.append(fill_in)
165 165 else:
166 166 calculated_votes.append(latest.status)
167 167 else:
168 168 calculated_votes.append(fill_in)
169 169
170 170 return calculated_votes
171 171
172 172 def calculate_status(self, statuses_by_reviewers):
173 173 """
174 174 Given the approval statuses from reviewers, calculates final approval
175 175 status. There can only be 3 results, all approved, all rejected. If
176 176 there is no consensus the PR is under review.
177 177
178 178 :param statuses_by_reviewers:
179 179 """
180 180
181 181 def group_rule(element):
182 182 review_obj = element[0]
183 183 rule_data = review_obj.rule_user_group_data()
184 184 if rule_data and rule_data['id']:
185 185 return rule_data['id']
186 186
187 187 voting_groups = itertools.groupby(
188 188 sorted(statuses_by_reviewers, key=group_rule), group_rule)
189 189
190 190 voting_by_groups = [(x, list(y)) for x, y in voting_groups]
191 191
192 192 reviewers_number = len(statuses_by_reviewers)
193 193 votes = collections.defaultdict(int)
194 194 for group, group_statuses_by_reviewers in voting_by_groups:
195 195 if group:
196 196 # calculate how the "group" voted
197 197 for vote_status in self.calculate_group_vote(
198 198 group, group_statuses_by_reviewers):
199 199 votes[vote_status] += 1
200 200 else:
201 201
202 202 for review_obj, user, reasons, mandatory, statuses \
203 203 in group_statuses_by_reviewers:
204 204 # individual vote
205 205 if statuses:
206 206 ver, latest = statuses[0]
207 207 votes[latest.status] += 1
208 208
209 209 approved_votes_count = votes[ChangesetStatus.STATUS_APPROVED]
210 210 rejected_votes_count = votes[ChangesetStatus.STATUS_REJECTED]
211 211
212 212 # TODO(marcink): with group voting, how does rejected work,
213 213 # do we ever get rejected state ?
214 214
215 215 if approved_votes_count and (approved_votes_count == reviewers_number):
216 216 return ChangesetStatus.STATUS_APPROVED
217 217
218 218 if rejected_votes_count and (rejected_votes_count == reviewers_number):
219 219 return ChangesetStatus.STATUS_REJECTED
220 220
221 221 return ChangesetStatus.STATUS_UNDER_REVIEW
222 222
223 223 def get_statuses(self, repo, revision=None, pull_request=None,
224 224 with_revisions=False):
225 225 q = self._get_status_query(repo, revision, pull_request,
226 226 with_revisions)
227 227 return q.all()
228 228
229 229 def get_status(self, repo, revision=None, pull_request=None, as_str=True):
230 230 """
231 231 Returns latest status of changeset for given revision or for given
232 232 pull request. Statuses are versioned inside a table itself and
233 233 version == 0 is always the current one
234 234
235 235 :param repo:
236 236 :param revision: 40char hash or None
237 237 :param pull_request: pull_request reference
238 238 :param as_str: return status as string not object
239 239 """
240 240 q = self._get_status_query(repo, revision, pull_request)
241 241
242 242 # need to use first here since there can be multiple statuses
243 243 # returned from pull_request
244 244 status = q.first()
245 245 if as_str:
246 246 status = status.status if status else status
247 247 st = status or ChangesetStatus.DEFAULT
248 248 return str(st)
249 249 return status
250 250
251 251 def _render_auto_status_message(
252 252 self, status, commit_id=None, pull_request=None):
253 253 """
254 254 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
255 255 so it's always looking the same disregarding on which default
256 256 renderer system is using.
257 257
258 258 :param status: status text to change into
259 259 :param commit_id: the commit_id we change the status for
260 260 :param pull_request: the pull request we change the status for
261 261 """
262 262
263 263 new_status = ChangesetStatus.get_status_lbl(status)
264 264
265 265 params = {
266 266 'new_status_label': new_status,
267 267 'pull_request': pull_request,
268 268 'commit_id': commit_id,
269 269 }
270 270 renderer = RstTemplateRenderer()
271 271 return renderer.render('auto_status_change.mako', **params)
272 272
273 273 def set_status(self, repo, status, user, comment=None, revision=None,
274 274 pull_request=None, dont_allow_on_closed_pull_request=False):
275 275 """
276 276 Creates new status for changeset or updates the old ones bumping their
277 277 version, leaving the current status at
278 278
279 279 :param repo:
280 280 :param revision:
281 281 :param status:
282 282 :param user:
283 283 :param comment:
284 284 :param dont_allow_on_closed_pull_request: don't allow a status change
285 285 if last status was for pull request and it's closed. We shouldn't
286 286 mess around this manually
287 287 """
288 288 repo = self._get_repo(repo)
289 289
290 290 q = ChangesetStatus.query()
291 291
292 292 if revision:
293 293 q = q.filter(ChangesetStatus.repo == repo)
294 294 q = q.filter(ChangesetStatus.revision == revision)
295 295 elif pull_request:
296 296 pull_request = self.__get_pull_request(pull_request)
297 297 q = q.filter(ChangesetStatus.repo == pull_request.source_repo)
298 298 q = q.filter(ChangesetStatus.revision.in_(pull_request.revisions))
299 299 cur_statuses = q.all()
300 300
301 301 # if statuses exists and last is associated with a closed pull request
302 302 # we need to check if we can allow this status change
303 303 if (dont_allow_on_closed_pull_request and cur_statuses
304 304 and getattr(cur_statuses[0].pull_request, 'status', '')
305 305 == PullRequest.STATUS_CLOSED):
306 306 raise StatusChangeOnClosedPullRequestError(
307 307 'Changing status on closed pull request is not allowed'
308 308 )
309 309
310 310 # update all current statuses with older version
311 311 if cur_statuses:
312 312 for st in cur_statuses:
313 313 st.version += 1
314 314 Session().add(st)
315 315 Session().flush()
316 316
317 317 def _create_status(user, repo, status, comment, revision, pull_request):
318 318 new_status = ChangesetStatus()
319 319 new_status.author = self._get_user(user)
320 320 new_status.repo = self._get_repo(repo)
321 321 new_status.status = status
322 322 new_status.comment = comment
323 323 new_status.revision = revision
324 324 new_status.pull_request = pull_request
325 325 return new_status
326 326
327 327 if not comment:
328 328 from rhodecode.model.comment import CommentsModel
329 329 comment = CommentsModel().create(
330 330 text=self._render_auto_status_message(
331 331 status, commit_id=revision, pull_request=pull_request),
332 332 repo=repo,
333 333 user=user,
334 334 pull_request=pull_request,
335 335 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER
336 336 )
337 337
338 338 if revision:
339 339 new_status = _create_status(
340 340 user=user, repo=repo, status=status, comment=comment,
341 341 revision=revision, pull_request=pull_request)
342 342 Session().add(new_status)
343 343 return new_status
344 344 elif pull_request:
345 345 # pull request can have more than one revision associated to it
346 346 # we need to create new version for each one
347 347 new_statuses = []
348 348 repo = pull_request.source_repo
349 349 for rev in pull_request.revisions:
350 350 new_status = _create_status(
351 351 user=user, repo=repo, status=status, comment=comment,
352 352 revision=rev, pull_request=pull_request)
353 353 new_statuses.append(new_status)
354 354 Session().add(new_status)
355 355 return new_statuses
356 356
357 357 def aggregate_votes_by_user(self, commit_statuses, reviewers_data):
358 358
359 359 commit_statuses_map = collections.defaultdict(list)
360 360 for st in commit_statuses:
361 361 commit_statuses_map[st.author.username] += [st]
362 362
363 363 reviewers = []
364 364
365 365 def version(commit_status):
366 366 return commit_status.version
367 367
368 368 for obj in reviewers_data:
369 369 if not obj.user:
370 370 continue
371 371 statuses = commit_statuses_map.get(obj.user.username, None)
372 372 if statuses:
373 373 status_groups = itertools.groupby(
374 374 sorted(statuses, key=version), version)
375 375 statuses = [(x, list(y)[0]) for x, y in status_groups]
376 376
377 377 reviewers.append((obj, obj.user, obj.reasons, obj.mandatory, statuses))
378 378
379 379 return reviewers
380 380
381 381 def reviewers_statuses(self, pull_request):
382 382 _commit_statuses = self.get_statuses(
383 383 pull_request.source_repo,
384 384 pull_request=pull_request,
385 385 with_revisions=True)
386 reviewers = pull_request.get_pull_request_reviewers(
387 role=PullRequestReviewers.ROLE_REVIEWER)
388 return self.aggregate_votes_by_user(_commit_statuses, reviewers)
386 389
387 return self.aggregate_votes_by_user(_commit_statuses, pull_request.reviewers)
388
389 def calculated_review_status(self, pull_request, reviewers_statuses=None):
390 def calculated_review_status(self, pull_request):
390 391 """
391 392 calculate pull request status based on reviewers, it should be a list
392 393 of two element lists.
393
394 :param reviewers_statuses:
395 394 """
396 reviewers = reviewers_statuses or self.reviewers_statuses(pull_request)
395 reviewers = self.reviewers_statuses(pull_request)
397 396 return self.calculate_status(reviewers)
@@ -1,863 +1,862 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 94 [comment_groups[_co.pull_request_version_id].append(_co) for _co in comments]
95 95
96 96 def yield_comments(pos):
97 97 for co in comment_groups[pos]:
98 98 yield co
99 99
100 100 comment_versions = collections.defaultdict(
101 101 lambda: collections.defaultdict(list))
102 102 prev_prvid = -1
103 103 # fake last entry with None, to aggregate on "latest" version which
104 104 # doesn't have an pull_request_version_id
105 105 for ver in versions + [AttributeDict({'pull_request_version_id': None})]:
106 106 prvid = ver.pull_request_version_id
107 107 if prev_prvid == -1:
108 108 prev_prvid = prvid
109 109
110 110 for co in yield_comments(prvid):
111 111 comment_versions[prvid]['at'].append(co)
112 112
113 113 # save until
114 114 current = comment_versions[prvid]['at']
115 115 prev_until = comment_versions[prev_prvid]['until']
116 116 cur_until = prev_until + current
117 117 comment_versions[prvid]['until'].extend(cur_until)
118 118
119 119 # save outdated
120 120 if inline:
121 121 outdated = [x for x in cur_until
122 122 if x.outdated_at_version(show_version)]
123 123 else:
124 124 outdated = [x for x in cur_until
125 125 if x.older_than_version(show_version)]
126 126 display = [x for x in cur_until if x not in outdated]
127 127
128 128 comment_versions[prvid]['outdated'] = outdated
129 129 comment_versions[prvid]['display'] = display
130 130
131 131 prev_prvid = prvid
132 132
133 133 return comment_versions
134 134
135 135 def get_repository_comments(self, repo, comment_type=None, user=None, commit_id=None):
136 136 qry = Session().query(ChangesetComment) \
137 137 .filter(ChangesetComment.repo == repo)
138 138
139 139 if comment_type and comment_type in ChangesetComment.COMMENT_TYPES:
140 140 qry = qry.filter(ChangesetComment.comment_type == comment_type)
141 141
142 142 if user:
143 143 user = self._get_user(user)
144 144 if user:
145 145 qry = qry.filter(ChangesetComment.user_id == user.user_id)
146 146
147 147 if commit_id:
148 148 qry = qry.filter(ChangesetComment.revision == commit_id)
149 149
150 150 qry = qry.order_by(ChangesetComment.created_on)
151 151 return qry.all()
152 152
153 153 def get_repository_unresolved_todos(self, repo):
154 154 todos = Session().query(ChangesetComment) \
155 155 .filter(ChangesetComment.repo == repo) \
156 156 .filter(ChangesetComment.resolved_by == None) \
157 157 .filter(ChangesetComment.comment_type
158 158 == ChangesetComment.COMMENT_TYPE_TODO)
159 159 todos = todos.all()
160 160
161 161 return todos
162 162
163 163 def get_pull_request_unresolved_todos(self, pull_request, show_outdated=True):
164 164
165 165 todos = Session().query(ChangesetComment) \
166 166 .filter(ChangesetComment.pull_request == pull_request) \
167 167 .filter(ChangesetComment.resolved_by == None) \
168 168 .filter(ChangesetComment.comment_type
169 169 == ChangesetComment.COMMENT_TYPE_TODO)
170 170
171 171 if not show_outdated:
172 172 todos = todos.filter(
173 173 coalesce(ChangesetComment.display_state, '') !=
174 174 ChangesetComment.COMMENT_OUTDATED)
175 175
176 176 todos = todos.all()
177 177
178 178 return todos
179 179
180 180 def get_pull_request_resolved_todos(self, pull_request, show_outdated=True):
181 181
182 182 todos = Session().query(ChangesetComment) \
183 183 .filter(ChangesetComment.pull_request == pull_request) \
184 184 .filter(ChangesetComment.resolved_by != None) \
185 185 .filter(ChangesetComment.comment_type
186 186 == ChangesetComment.COMMENT_TYPE_TODO)
187 187
188 188 if not show_outdated:
189 189 todos = todos.filter(
190 190 coalesce(ChangesetComment.display_state, '') !=
191 191 ChangesetComment.COMMENT_OUTDATED)
192 192
193 193 todos = todos.all()
194 194
195 195 return todos
196 196
197 197 def get_commit_unresolved_todos(self, commit_id, show_outdated=True):
198 198
199 199 todos = Session().query(ChangesetComment) \
200 200 .filter(ChangesetComment.revision == commit_id) \
201 201 .filter(ChangesetComment.resolved_by == None) \
202 202 .filter(ChangesetComment.comment_type
203 203 == ChangesetComment.COMMENT_TYPE_TODO)
204 204
205 205 if not show_outdated:
206 206 todos = todos.filter(
207 207 coalesce(ChangesetComment.display_state, '') !=
208 208 ChangesetComment.COMMENT_OUTDATED)
209 209
210 210 todos = todos.all()
211 211
212 212 return todos
213 213
214 214 def get_commit_resolved_todos(self, commit_id, show_outdated=True):
215 215
216 216 todos = Session().query(ChangesetComment) \
217 217 .filter(ChangesetComment.revision == commit_id) \
218 218 .filter(ChangesetComment.resolved_by != None) \
219 219 .filter(ChangesetComment.comment_type
220 220 == ChangesetComment.COMMENT_TYPE_TODO)
221 221
222 222 if not show_outdated:
223 223 todos = todos.filter(
224 224 coalesce(ChangesetComment.display_state, '') !=
225 225 ChangesetComment.COMMENT_OUTDATED)
226 226
227 227 todos = todos.all()
228 228
229 229 return todos
230 230
231 231 def get_commit_inline_comments(self, commit_id):
232 232 inline_comments = Session().query(ChangesetComment) \
233 233 .filter(ChangesetComment.line_no != None) \
234 234 .filter(ChangesetComment.f_path != None) \
235 235 .filter(ChangesetComment.revision == commit_id)
236 236 inline_comments = inline_comments.all()
237 237 return inline_comments
238 238
239 239 def _log_audit_action(self, action, action_data, auth_user, comment):
240 240 audit_logger.store(
241 241 action=action,
242 242 action_data=action_data,
243 243 user=auth_user,
244 244 repo=comment.repo)
245 245
246 246 def create(self, text, repo, user, commit_id=None, pull_request=None,
247 247 f_path=None, line_no=None, status_change=None,
248 248 status_change_type=None, comment_type=None,
249 249 resolves_comment_id=None, closing_pr=False, send_email=True,
250 250 renderer=None, auth_user=None, extra_recipients=None):
251 251 """
252 252 Creates new comment for commit or pull request.
253 253 IF status_change is not none this comment is associated with a
254 254 status change of commit or commit associated with pull request
255 255
256 256 :param text:
257 257 :param repo:
258 258 :param user:
259 259 :param commit_id:
260 260 :param pull_request:
261 261 :param f_path:
262 262 :param line_no:
263 263 :param status_change: Label for status change
264 264 :param comment_type: Type of comment
265 265 :param resolves_comment_id: id of comment which this one will resolve
266 266 :param status_change_type: type of status change
267 267 :param closing_pr:
268 268 :param send_email:
269 269 :param renderer: pick renderer for this comment
270 270 :param auth_user: current authenticated user calling this method
271 271 :param extra_recipients: list of extra users to be added to recipients
272 272 """
273 273
274 274 if not text:
275 275 log.warning('Missing text for comment, skipping...')
276 276 return
277 277 request = get_current_request()
278 278 _ = request.translate
279 279
280 280 if not renderer:
281 281 renderer = self._get_renderer(request=request)
282 282
283 283 repo = self._get_repo(repo)
284 284 user = self._get_user(user)
285 285 auth_user = auth_user or user
286 286
287 287 schema = comment_schema.CommentSchema()
288 288 validated_kwargs = schema.deserialize(dict(
289 289 comment_body=text,
290 290 comment_type=comment_type,
291 291 comment_file=f_path,
292 292 comment_line=line_no,
293 293 renderer_type=renderer,
294 294 status_change=status_change_type,
295 295 resolves_comment_id=resolves_comment_id,
296 296 repo=repo.repo_id,
297 297 user=user.user_id,
298 298 ))
299 299
300 300 comment = ChangesetComment()
301 301 comment.renderer = validated_kwargs['renderer_type']
302 302 comment.text = validated_kwargs['comment_body']
303 303 comment.f_path = validated_kwargs['comment_file']
304 304 comment.line_no = validated_kwargs['comment_line']
305 305 comment.comment_type = validated_kwargs['comment_type']
306 306
307 307 comment.repo = repo
308 308 comment.author = user
309 309 resolved_comment = self.__get_commit_comment(
310 310 validated_kwargs['resolves_comment_id'])
311 311 # check if the comment actually belongs to this PR
312 312 if resolved_comment and resolved_comment.pull_request and \
313 313 resolved_comment.pull_request != pull_request:
314 314 log.warning('Comment tried to resolved unrelated todo comment: %s',
315 315 resolved_comment)
316 316 # comment not bound to this pull request, forbid
317 317 resolved_comment = None
318 318
319 319 elif resolved_comment and resolved_comment.repo and \
320 320 resolved_comment.repo != repo:
321 321 log.warning('Comment tried to resolved unrelated todo comment: %s',
322 322 resolved_comment)
323 323 # comment not bound to this repo, forbid
324 324 resolved_comment = None
325 325
326 326 comment.resolved_comment = resolved_comment
327 327
328 328 pull_request_id = pull_request
329 329
330 330 commit_obj = None
331 331 pull_request_obj = None
332 332
333 333 if commit_id:
334 334 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
335 335 # do a lookup, so we don't pass something bad here
336 336 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
337 337 comment.revision = commit_obj.raw_id
338 338
339 339 elif pull_request_id:
340 340 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
341 341 pull_request_obj = self.__get_pull_request(pull_request_id)
342 342 comment.pull_request = pull_request_obj
343 343 else:
344 344 raise Exception('Please specify commit or pull_request_id')
345 345
346 346 Session().add(comment)
347 347 Session().flush()
348 348 kwargs = {
349 349 'user': user,
350 350 'renderer_type': renderer,
351 351 'repo_name': repo.repo_name,
352 352 'status_change': status_change,
353 353 'status_change_type': status_change_type,
354 354 'comment_body': text,
355 355 'comment_file': f_path,
356 356 'comment_line': line_no,
357 357 'comment_type': comment_type or 'note',
358 358 'comment_id': comment.comment_id
359 359 }
360 360
361 361 if commit_obj:
362 362 recipients = ChangesetComment.get_users(
363 363 revision=commit_obj.raw_id)
364 364 # add commit author if it's in RhodeCode system
365 365 cs_author = User.get_from_cs_author(commit_obj.author)
366 366 if not cs_author:
367 367 # use repo owner if we cannot extract the author correctly
368 368 cs_author = repo.user
369 369 recipients += [cs_author]
370 370
371 371 commit_comment_url = self.get_url(comment, request=request)
372 372 commit_comment_reply_url = self.get_url(
373 373 comment, request=request,
374 374 anchor='comment-{}/?/ReplyToComment'.format(comment.comment_id))
375 375
376 376 target_repo_url = h.link_to(
377 377 repo.repo_name,
378 378 h.route_url('repo_summary', repo_name=repo.repo_name))
379 379
380 380 commit_url = h.route_url('repo_commit', repo_name=repo.repo_name,
381 381 commit_id=commit_id)
382 382
383 383 # commit specifics
384 384 kwargs.update({
385 385 'commit': commit_obj,
386 386 'commit_message': commit_obj.message,
387 387 'commit_target_repo_url': target_repo_url,
388 388 'commit_comment_url': commit_comment_url,
389 389 'commit_comment_reply_url': commit_comment_reply_url,
390 390 'commit_url': commit_url,
391 391 'thread_ids': [commit_url, commit_comment_url],
392 392 })
393 393
394 394 elif pull_request_obj:
395 395 # get the current participants of this pull request
396 396 recipients = ChangesetComment.get_users(
397 397 pull_request_id=pull_request_obj.pull_request_id)
398 398 # add pull request author
399 399 recipients += [pull_request_obj.author]
400 400
401 401 # add the reviewers to notification
402 402 recipients += [x.user for x in pull_request_obj.reviewers]
403 403
404 404 pr_target_repo = pull_request_obj.target_repo
405 405 pr_source_repo = pull_request_obj.source_repo
406 406
407 407 pr_comment_url = self.get_url(comment, request=request)
408 408 pr_comment_reply_url = self.get_url(
409 409 comment, request=request,
410 410 anchor='comment-{}/?/ReplyToComment'.format(comment.comment_id))
411 411
412 412 pr_url = h.route_url(
413 413 'pullrequest_show',
414 414 repo_name=pr_target_repo.repo_name,
415 415 pull_request_id=pull_request_obj.pull_request_id, )
416 416
417 417 # set some variables for email notification
418 418 pr_target_repo_url = h.route_url(
419 419 'repo_summary', repo_name=pr_target_repo.repo_name)
420 420
421 421 pr_source_repo_url = h.route_url(
422 422 'repo_summary', repo_name=pr_source_repo.repo_name)
423 423
424 424 # pull request specifics
425 425 kwargs.update({
426 426 'pull_request': pull_request_obj,
427 427 'pr_id': pull_request_obj.pull_request_id,
428 428 'pull_request_url': pr_url,
429 429 'pull_request_target_repo': pr_target_repo,
430 430 'pull_request_target_repo_url': pr_target_repo_url,
431 431 'pull_request_source_repo': pr_source_repo,
432 432 'pull_request_source_repo_url': pr_source_repo_url,
433 433 'pr_comment_url': pr_comment_url,
434 434 'pr_comment_reply_url': pr_comment_reply_url,
435 435 'pr_closing': closing_pr,
436 436 'thread_ids': [pr_url, pr_comment_url],
437 437 })
438 438
439 if send_email:
439 440 recipients += [self._get_user(u) for u in (extra_recipients or [])]
440
441 if send_email:
442 441 # pre-generate the subject for notification itself
443 442 (subject, _e, body_plaintext) = EmailNotificationModel().render_email(
444 443 notification_type, **kwargs)
445 444
446 445 mention_recipients = set(
447 446 self._extract_mentions(text)).difference(recipients)
448 447
449 448 # create notification objects, and emails
450 449 NotificationModel().create(
451 450 created_by=user,
452 451 notification_subject=subject,
453 452 notification_body=body_plaintext,
454 453 notification_type=notification_type,
455 454 recipients=recipients,
456 455 mention_recipients=mention_recipients,
457 456 email_kwargs=kwargs,
458 457 )
459 458
460 459 Session().flush()
461 460 if comment.pull_request:
462 461 action = 'repo.pull_request.comment.create'
463 462 else:
464 463 action = 'repo.commit.comment.create'
465 464
466 465 comment_id = comment.comment_id
467 466 comment_data = comment.get_api_data()
468 467
469 468 self._log_audit_action(
470 469 action, {'data': comment_data}, auth_user, comment)
471 470
472 471 channel = None
473 472 if commit_obj:
474 473 repo_name = repo.repo_name
475 474 channel = u'/repo${}$/commit/{}'.format(
476 475 repo_name,
477 476 commit_obj.raw_id
478 477 )
479 478 elif pull_request_obj:
480 479 repo_name = pr_target_repo.repo_name
481 480 channel = u'/repo${}$/pr/{}'.format(
482 481 repo_name,
483 482 pull_request_obj.pull_request_id
484 483 )
485 484
486 485 if channel:
487 486 username = user.username
488 487 message = '<strong>{}</strong> {} #{}, {}'
489 488 message = message.format(
490 489 username,
491 490 _('posted a new comment'),
492 491 comment_id,
493 492 _('Refresh the page to see new comments.'))
494 493
495 494 message_obj = {
496 495 'message': message,
497 496 'level': 'success',
498 497 'topic': '/notifications'
499 498 }
500 499
501 500 channelstream.post_message(
502 501 channel, message_obj, user.username,
503 502 registry=get_current_registry())
504 503
505 504 message_obj = {
506 505 'message': None,
507 506 'user': username,
508 507 'comment_id': comment_id,
509 508 'topic': '/comment'
510 509 }
511 510 channelstream.post_message(
512 511 channel, message_obj, user.username,
513 512 registry=get_current_registry())
514 513
515 514 return comment
516 515
517 516 def edit(self, comment_id, text, auth_user, version):
518 517 """
519 518 Change existing comment for commit or pull request.
520 519
521 520 :param comment_id:
522 521 :param text:
523 522 :param auth_user: current authenticated user calling this method
524 523 :param version: last comment version
525 524 """
526 525 if not text:
527 526 log.warning('Missing text for comment, skipping...')
528 527 return
529 528
530 529 comment = ChangesetComment.get(comment_id)
531 530 old_comment_text = comment.text
532 531 comment.text = text
533 532 comment.modified_at = datetime.datetime.now()
534 533 version = safe_int(version)
535 534
536 535 # NOTE(marcink): this returns initial comment + edits, so v2 from ui
537 536 # would return 3 here
538 537 comment_version = ChangesetCommentHistory.get_version(comment_id)
539 538
540 539 if isinstance(version, (int, long)) and (comment_version - version) != 1:
541 540 log.warning(
542 541 'Version mismatch comment_version {} submitted {}, skipping'.format(
543 542 comment_version-1, # -1 since note above
544 543 version
545 544 )
546 545 )
547 546 raise CommentVersionMismatch()
548 547
549 548 comment_history = ChangesetCommentHistory()
550 549 comment_history.comment_id = comment_id
551 550 comment_history.version = comment_version
552 551 comment_history.created_by_user_id = auth_user.user_id
553 552 comment_history.text = old_comment_text
554 553 # TODO add email notification
555 554 Session().add(comment_history)
556 555 Session().add(comment)
557 556 Session().flush()
558 557
559 558 if comment.pull_request:
560 559 action = 'repo.pull_request.comment.edit'
561 560 else:
562 561 action = 'repo.commit.comment.edit'
563 562
564 563 comment_data = comment.get_api_data()
565 564 comment_data['old_comment_text'] = old_comment_text
566 565 self._log_audit_action(
567 566 action, {'data': comment_data}, auth_user, comment)
568 567
569 568 return comment_history
570 569
571 570 def delete(self, comment, auth_user):
572 571 """
573 572 Deletes given comment
574 573 """
575 574 comment = self.__get_commit_comment(comment)
576 575 old_data = comment.get_api_data()
577 576 Session().delete(comment)
578 577
579 578 if comment.pull_request:
580 579 action = 'repo.pull_request.comment.delete'
581 580 else:
582 581 action = 'repo.commit.comment.delete'
583 582
584 583 self._log_audit_action(
585 584 action, {'old_data': old_data}, auth_user, comment)
586 585
587 586 return comment
588 587
589 588 def get_all_comments(self, repo_id, revision=None, pull_request=None):
590 589 q = ChangesetComment.query()\
591 590 .filter(ChangesetComment.repo_id == repo_id)
592 591 if revision:
593 592 q = q.filter(ChangesetComment.revision == revision)
594 593 elif pull_request:
595 594 pull_request = self.__get_pull_request(pull_request)
596 595 q = q.filter(ChangesetComment.pull_request == pull_request)
597 596 else:
598 597 raise Exception('Please specify commit or pull_request')
599 598 q = q.order_by(ChangesetComment.created_on)
600 599 return q.all()
601 600
602 601 def get_url(self, comment, request=None, permalink=False, anchor=None):
603 602 if not request:
604 603 request = get_current_request()
605 604
606 605 comment = self.__get_commit_comment(comment)
607 606 if anchor is None:
608 607 anchor = 'comment-{}'.format(comment.comment_id)
609 608
610 609 if comment.pull_request:
611 610 pull_request = comment.pull_request
612 611 if permalink:
613 612 return request.route_url(
614 613 'pull_requests_global',
615 614 pull_request_id=pull_request.pull_request_id,
616 615 _anchor=anchor)
617 616 else:
618 617 return request.route_url(
619 618 'pullrequest_show',
620 619 repo_name=safe_str(pull_request.target_repo.repo_name),
621 620 pull_request_id=pull_request.pull_request_id,
622 621 _anchor=anchor)
623 622
624 623 else:
625 624 repo = comment.repo
626 625 commit_id = comment.revision
627 626
628 627 if permalink:
629 628 return request.route_url(
630 629 'repo_commit', repo_name=safe_str(repo.repo_id),
631 630 commit_id=commit_id,
632 631 _anchor=anchor)
633 632
634 633 else:
635 634 return request.route_url(
636 635 'repo_commit', repo_name=safe_str(repo.repo_name),
637 636 commit_id=commit_id,
638 637 _anchor=anchor)
639 638
640 639 def get_comments(self, repo_id, revision=None, pull_request=None):
641 640 """
642 641 Gets main comments based on revision or pull_request_id
643 642
644 643 :param repo_id:
645 644 :param revision:
646 645 :param pull_request:
647 646 """
648 647
649 648 q = ChangesetComment.query()\
650 649 .filter(ChangesetComment.repo_id == repo_id)\
651 650 .filter(ChangesetComment.line_no == None)\
652 651 .filter(ChangesetComment.f_path == None)
653 652 if revision:
654 653 q = q.filter(ChangesetComment.revision == revision)
655 654 elif pull_request:
656 655 pull_request = self.__get_pull_request(pull_request)
657 656 q = q.filter(ChangesetComment.pull_request == pull_request)
658 657 else:
659 658 raise Exception('Please specify commit or pull_request')
660 659 q = q.order_by(ChangesetComment.created_on)
661 660 return q.all()
662 661
663 662 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
664 663 q = self._get_inline_comments_query(repo_id, revision, pull_request)
665 664 return self._group_comments_by_path_and_line_number(q)
666 665
667 666 def get_inline_comments_as_list(self, inline_comments, skip_outdated=True,
668 667 version=None):
669 668 inline_comms = []
670 669 for fname, per_line_comments in inline_comments.iteritems():
671 670 for lno, comments in per_line_comments.iteritems():
672 671 for comm in comments:
673 672 if not comm.outdated_at_version(version) and skip_outdated:
674 673 inline_comms.append(comm)
675 674
676 675 return inline_comms
677 676
678 677 def get_outdated_comments(self, repo_id, pull_request):
679 678 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
680 679 # of a pull request.
681 680 q = self._all_inline_comments_of_pull_request(pull_request)
682 681 q = q.filter(
683 682 ChangesetComment.display_state ==
684 683 ChangesetComment.COMMENT_OUTDATED
685 684 ).order_by(ChangesetComment.comment_id.asc())
686 685
687 686 return self._group_comments_by_path_and_line_number(q)
688 687
689 688 def _get_inline_comments_query(self, repo_id, revision, pull_request):
690 689 # TODO: johbo: Split this into two methods: One for PR and one for
691 690 # commit.
692 691 if revision:
693 692 q = Session().query(ChangesetComment).filter(
694 693 ChangesetComment.repo_id == repo_id,
695 694 ChangesetComment.line_no != null(),
696 695 ChangesetComment.f_path != null(),
697 696 ChangesetComment.revision == revision)
698 697
699 698 elif pull_request:
700 699 pull_request = self.__get_pull_request(pull_request)
701 700 if not CommentsModel.use_outdated_comments(pull_request):
702 701 q = self._visible_inline_comments_of_pull_request(pull_request)
703 702 else:
704 703 q = self._all_inline_comments_of_pull_request(pull_request)
705 704
706 705 else:
707 706 raise Exception('Please specify commit or pull_request_id')
708 707 q = q.order_by(ChangesetComment.comment_id.asc())
709 708 return q
710 709
711 710 def _group_comments_by_path_and_line_number(self, q):
712 711 comments = q.all()
713 712 paths = collections.defaultdict(lambda: collections.defaultdict(list))
714 713 for co in comments:
715 714 paths[co.f_path][co.line_no].append(co)
716 715 return paths
717 716
718 717 @classmethod
719 718 def needed_extra_diff_context(cls):
720 719 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
721 720
722 721 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
723 722 if not CommentsModel.use_outdated_comments(pull_request):
724 723 return
725 724
726 725 comments = self._visible_inline_comments_of_pull_request(pull_request)
727 726 comments_to_outdate = comments.all()
728 727
729 728 for comment in comments_to_outdate:
730 729 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
731 730
732 731 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
733 732 diff_line = _parse_comment_line_number(comment.line_no)
734 733
735 734 try:
736 735 old_context = old_diff_proc.get_context_of_line(
737 736 path=comment.f_path, diff_line=diff_line)
738 737 new_context = new_diff_proc.get_context_of_line(
739 738 path=comment.f_path, diff_line=diff_line)
740 739 except (diffs.LineNotInDiffException,
741 740 diffs.FileNotInDiffException):
742 741 comment.display_state = ChangesetComment.COMMENT_OUTDATED
743 742 return
744 743
745 744 if old_context == new_context:
746 745 return
747 746
748 747 if self._should_relocate_diff_line(diff_line):
749 748 new_diff_lines = new_diff_proc.find_context(
750 749 path=comment.f_path, context=old_context,
751 750 offset=self.DIFF_CONTEXT_BEFORE)
752 751 if not new_diff_lines:
753 752 comment.display_state = ChangesetComment.COMMENT_OUTDATED
754 753 else:
755 754 new_diff_line = self._choose_closest_diff_line(
756 755 diff_line, new_diff_lines)
757 756 comment.line_no = _diff_to_comment_line_number(new_diff_line)
758 757 else:
759 758 comment.display_state = ChangesetComment.COMMENT_OUTDATED
760 759
761 760 def _should_relocate_diff_line(self, diff_line):
762 761 """
763 762 Checks if relocation shall be tried for the given `diff_line`.
764 763
765 764 If a comment points into the first lines, then we can have a situation
766 765 that after an update another line has been added on top. In this case
767 766 we would find the context still and move the comment around. This
768 767 would be wrong.
769 768 """
770 769 should_relocate = (
771 770 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
772 771 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
773 772 return should_relocate
774 773
775 774 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
776 775 candidate = new_diff_lines[0]
777 776 best_delta = _diff_line_delta(diff_line, candidate)
778 777 for new_diff_line in new_diff_lines[1:]:
779 778 delta = _diff_line_delta(diff_line, new_diff_line)
780 779 if delta < best_delta:
781 780 candidate = new_diff_line
782 781 best_delta = delta
783 782 return candidate
784 783
785 784 def _visible_inline_comments_of_pull_request(self, pull_request):
786 785 comments = self._all_inline_comments_of_pull_request(pull_request)
787 786 comments = comments.filter(
788 787 coalesce(ChangesetComment.display_state, '') !=
789 788 ChangesetComment.COMMENT_OUTDATED)
790 789 return comments
791 790
792 791 def _all_inline_comments_of_pull_request(self, pull_request):
793 792 comments = Session().query(ChangesetComment)\
794 793 .filter(ChangesetComment.line_no != None)\
795 794 .filter(ChangesetComment.f_path != None)\
796 795 .filter(ChangesetComment.pull_request == pull_request)
797 796 return comments
798 797
799 798 def _all_general_comments_of_pull_request(self, pull_request):
800 799 comments = Session().query(ChangesetComment)\
801 800 .filter(ChangesetComment.line_no == None)\
802 801 .filter(ChangesetComment.f_path == None)\
803 802 .filter(ChangesetComment.pull_request == pull_request)
804 803
805 804 return comments
806 805
807 806 @staticmethod
808 807 def use_outdated_comments(pull_request):
809 808 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
810 809 settings = settings_model.get_general_settings()
811 810 return settings.get('rhodecode_use_outdated_comments', False)
812 811
813 812 def trigger_commit_comment_hook(self, repo, user, action, data=None):
814 813 repo = self._get_repo(repo)
815 814 target_scm = repo.scm_instance()
816 815 if action == 'create':
817 816 trigger_hook = hooks_utils.trigger_comment_commit_hooks
818 817 elif action == 'edit':
819 818 trigger_hook = hooks_utils.trigger_comment_commit_edit_hooks
820 819 else:
821 820 return
822 821
823 822 log.debug('Handling repo %s trigger_commit_comment_hook with action %s: %s',
824 823 repo, action, trigger_hook)
825 824 trigger_hook(
826 825 username=user.username,
827 826 repo_name=repo.repo_name,
828 827 repo_type=target_scm.alias,
829 828 repo=repo,
830 829 data=data)
831 830
832 831
833 832 def _parse_comment_line_number(line_no):
834 833 """
835 834 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
836 835 """
837 836 old_line = None
838 837 new_line = None
839 838 if line_no.startswith('o'):
840 839 old_line = int(line_no[1:])
841 840 elif line_no.startswith('n'):
842 841 new_line = int(line_no[1:])
843 842 else:
844 843 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
845 844 return diffs.DiffLineNumber(old_line, new_line)
846 845
847 846
848 847 def _diff_to_comment_line_number(diff_line):
849 848 if diff_line.new is not None:
850 849 return u'n{}'.format(diff_line.new)
851 850 elif diff_line.old is not None:
852 851 return u'o{}'.format(diff_line.old)
853 852 return u''
854 853
855 854
856 855 def _diff_line_delta(a, b):
857 856 if None not in (a.new, b.new):
858 857 return abs(a.new - b.new)
859 858 elif None not in (a.old, b.old):
860 859 return abs(a.old - b.old)
861 860 else:
862 861 raise ValueError(
863 862 "Cannot compute delta between {} and {}".format(a, b))
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
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