##// END OF EJS Templates
events: add an event for pull request comments with review status
dan -
r443:c2778156 default
parent child Browse files
Show More
@@ -1,849 +1,853 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2012-2016 RhodeCode GmbH
3 # Copyright (C) 2012-2016 RhodeCode GmbH
4 #
4 #
5 # This program is free software: you can redistribute it and/or modify
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
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
7 # (only), as published by the Free Software Foundation.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU Affero General Public License
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/>.
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
16 #
17 # This program is dual-licensed. If you wish to learn more about the
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
20
21 """
21 """
22 pull requests controller for rhodecode for initializing pull requests
22 pull requests controller for rhodecode for initializing pull requests
23 """
23 """
24
24
25 import formencode
25 import formencode
26 import logging
26 import logging
27
27
28 from webob.exc import HTTPNotFound, HTTPForbidden, HTTPBadRequest
28 from webob.exc import HTTPNotFound, HTTPForbidden, HTTPBadRequest
29 from pylons import request, tmpl_context as c, url
29 from pylons import request, tmpl_context as c, url
30 from pylons.controllers.util import redirect
30 from pylons.controllers.util import redirect
31 from pylons.i18n.translation import _
31 from pylons.i18n.translation import _
32 from sqlalchemy.sql import func
32 from sqlalchemy.sql import func
33 from sqlalchemy.sql.expression import or_
33 from sqlalchemy.sql.expression import or_
34
34
35 from rhodecode import events
35 from rhodecode.lib import auth, diffs, helpers as h
36 from rhodecode.lib import auth, diffs, helpers as h
36 from rhodecode.lib.ext_json import json
37 from rhodecode.lib.ext_json import json
37 from rhodecode.lib.base import (
38 from rhodecode.lib.base import (
38 BaseRepoController, render, vcs_operation_context)
39 BaseRepoController, render, vcs_operation_context)
39 from rhodecode.lib.auth import (
40 from rhodecode.lib.auth import (
40 LoginRequired, HasRepoPermissionAnyDecorator, NotAnonymous,
41 LoginRequired, HasRepoPermissionAnyDecorator, NotAnonymous,
41 HasAcceptedRepoType, XHRRequired)
42 HasAcceptedRepoType, XHRRequired)
42 from rhodecode.lib.utils import jsonify
43 from rhodecode.lib.utils import jsonify
43 from rhodecode.lib.utils2 import safe_int, safe_str, str2bool, safe_unicode
44 from rhodecode.lib.utils2 import safe_int, safe_str, str2bool, safe_unicode
44 from rhodecode.lib.vcs.backends.base import EmptyCommit
45 from rhodecode.lib.vcs.backends.base import EmptyCommit
45 from rhodecode.lib.vcs.exceptions import (
46 from rhodecode.lib.vcs.exceptions import (
46 EmptyRepositoryError, CommitDoesNotExistError, RepositoryRequirementError)
47 EmptyRepositoryError, CommitDoesNotExistError, RepositoryRequirementError)
47 from rhodecode.lib.diffs import LimitedDiffContainer
48 from rhodecode.lib.diffs import LimitedDiffContainer
48 from rhodecode.model.changeset_status import ChangesetStatusModel
49 from rhodecode.model.changeset_status import ChangesetStatusModel
49 from rhodecode.model.comment import ChangesetCommentsModel
50 from rhodecode.model.comment import ChangesetCommentsModel
50 from rhodecode.model.db import PullRequest, ChangesetStatus, ChangesetComment, \
51 from rhodecode.model.db import PullRequest, ChangesetStatus, ChangesetComment, \
51 Repository
52 Repository
52 from rhodecode.model.forms import PullRequestForm
53 from rhodecode.model.forms import PullRequestForm
53 from rhodecode.model.meta import Session
54 from rhodecode.model.meta import Session
54 from rhodecode.model.pull_request import PullRequestModel
55 from rhodecode.model.pull_request import PullRequestModel
55
56
56 log = logging.getLogger(__name__)
57 log = logging.getLogger(__name__)
57
58
58
59
59 class PullrequestsController(BaseRepoController):
60 class PullrequestsController(BaseRepoController):
60 def __before__(self):
61 def __before__(self):
61 super(PullrequestsController, self).__before__()
62 super(PullrequestsController, self).__before__()
62
63
63 def _load_compare_data(self, pull_request, enable_comments=True):
64 def _load_compare_data(self, pull_request, enable_comments=True):
64 """
65 """
65 Load context data needed for generating compare diff
66 Load context data needed for generating compare diff
66
67
67 :param pull_request: object related to the request
68 :param pull_request: object related to the request
68 :param enable_comments: flag to determine if comments are included
69 :param enable_comments: flag to determine if comments are included
69 """
70 """
70 source_repo = pull_request.source_repo
71 source_repo = pull_request.source_repo
71 source_ref_id = pull_request.source_ref_parts.commit_id
72 source_ref_id = pull_request.source_ref_parts.commit_id
72
73
73 target_repo = pull_request.target_repo
74 target_repo = pull_request.target_repo
74 target_ref_id = pull_request.target_ref_parts.commit_id
75 target_ref_id = pull_request.target_ref_parts.commit_id
75
76
76 # despite opening commits for bookmarks/branches/tags, we always
77 # despite opening commits for bookmarks/branches/tags, we always
77 # convert this to rev to prevent changes after bookmark or branch change
78 # convert this to rev to prevent changes after bookmark or branch change
78 c.source_ref_type = 'rev'
79 c.source_ref_type = 'rev'
79 c.source_ref = source_ref_id
80 c.source_ref = source_ref_id
80
81
81 c.target_ref_type = 'rev'
82 c.target_ref_type = 'rev'
82 c.target_ref = target_ref_id
83 c.target_ref = target_ref_id
83
84
84 c.source_repo = source_repo
85 c.source_repo = source_repo
85 c.target_repo = target_repo
86 c.target_repo = target_repo
86
87
87 c.fulldiff = bool(request.GET.get('fulldiff'))
88 c.fulldiff = bool(request.GET.get('fulldiff'))
88
89
89 # diff_limit is the old behavior, will cut off the whole diff
90 # diff_limit is the old behavior, will cut off the whole diff
90 # if the limit is applied otherwise will just hide the
91 # if the limit is applied otherwise will just hide the
91 # big files from the front-end
92 # big files from the front-end
92 diff_limit = self.cut_off_limit_diff
93 diff_limit = self.cut_off_limit_diff
93 file_limit = self.cut_off_limit_file
94 file_limit = self.cut_off_limit_file
94
95
95 pre_load = ["author", "branch", "date", "message"]
96 pre_load = ["author", "branch", "date", "message"]
96
97
97 c.commit_ranges = []
98 c.commit_ranges = []
98 source_commit = EmptyCommit()
99 source_commit = EmptyCommit()
99 target_commit = EmptyCommit()
100 target_commit = EmptyCommit()
100 c.missing_requirements = False
101 c.missing_requirements = False
101 try:
102 try:
102 c.commit_ranges = [
103 c.commit_ranges = [
103 source_repo.get_commit(commit_id=rev, pre_load=pre_load)
104 source_repo.get_commit(commit_id=rev, pre_load=pre_load)
104 for rev in pull_request.revisions]
105 for rev in pull_request.revisions]
105
106
106 c.statuses = source_repo.statuses(
107 c.statuses = source_repo.statuses(
107 [x.raw_id for x in c.commit_ranges])
108 [x.raw_id for x in c.commit_ranges])
108
109
109 target_commit = source_repo.get_commit(
110 target_commit = source_repo.get_commit(
110 commit_id=safe_str(target_ref_id))
111 commit_id=safe_str(target_ref_id))
111 source_commit = source_repo.get_commit(
112 source_commit = source_repo.get_commit(
112 commit_id=safe_str(source_ref_id))
113 commit_id=safe_str(source_ref_id))
113 except RepositoryRequirementError:
114 except RepositoryRequirementError:
114 c.missing_requirements = True
115 c.missing_requirements = True
115
116
116 c.missing_commits = False
117 c.missing_commits = False
117 if (c.missing_requirements or
118 if (c.missing_requirements or
118 isinstance(source_commit, EmptyCommit) or
119 isinstance(source_commit, EmptyCommit) or
119 source_commit == target_commit):
120 source_commit == target_commit):
120 _parsed = []
121 _parsed = []
121 c.missing_commits = True
122 c.missing_commits = True
122 else:
123 else:
123 vcs_diff = PullRequestModel().get_diff(pull_request)
124 vcs_diff = PullRequestModel().get_diff(pull_request)
124 diff_processor = diffs.DiffProcessor(
125 diff_processor = diffs.DiffProcessor(
125 vcs_diff, format='gitdiff', diff_limit=diff_limit,
126 vcs_diff, format='gitdiff', diff_limit=diff_limit,
126 file_limit=file_limit, show_full_diff=c.fulldiff)
127 file_limit=file_limit, show_full_diff=c.fulldiff)
127 _parsed = diff_processor.prepare()
128 _parsed = diff_processor.prepare()
128
129
129 c.limited_diff = isinstance(_parsed, LimitedDiffContainer)
130 c.limited_diff = isinstance(_parsed, LimitedDiffContainer)
130
131
131 c.files = []
132 c.files = []
132 c.changes = {}
133 c.changes = {}
133 c.lines_added = 0
134 c.lines_added = 0
134 c.lines_deleted = 0
135 c.lines_deleted = 0
135 c.included_files = []
136 c.included_files = []
136 c.deleted_files = []
137 c.deleted_files = []
137
138
138 for f in _parsed:
139 for f in _parsed:
139 st = f['stats']
140 st = f['stats']
140 c.lines_added += st['added']
141 c.lines_added += st['added']
141 c.lines_deleted += st['deleted']
142 c.lines_deleted += st['deleted']
142
143
143 fid = h.FID('', f['filename'])
144 fid = h.FID('', f['filename'])
144 c.files.append([fid, f['operation'], f['filename'], f['stats']])
145 c.files.append([fid, f['operation'], f['filename'], f['stats']])
145 c.included_files.append(f['filename'])
146 c.included_files.append(f['filename'])
146 html_diff = diff_processor.as_html(enable_comments=enable_comments,
147 html_diff = diff_processor.as_html(enable_comments=enable_comments,
147 parsed_lines=[f])
148 parsed_lines=[f])
148 c.changes[fid] = [f['operation'], f['filename'], html_diff, f]
149 c.changes[fid] = [f['operation'], f['filename'], html_diff, f]
149
150
150 def _extract_ordering(self, request):
151 def _extract_ordering(self, request):
151 column_index = safe_int(request.GET.get('order[0][column]'))
152 column_index = safe_int(request.GET.get('order[0][column]'))
152 order_dir = request.GET.get('order[0][dir]', 'desc')
153 order_dir = request.GET.get('order[0][dir]', 'desc')
153 order_by = request.GET.get(
154 order_by = request.GET.get(
154 'columns[%s][data][sort]' % column_index, 'name_raw')
155 'columns[%s][data][sort]' % column_index, 'name_raw')
155 return order_by, order_dir
156 return order_by, order_dir
156
157
157 @LoginRequired()
158 @LoginRequired()
158 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
159 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
159 'repository.admin')
160 'repository.admin')
160 @HasAcceptedRepoType('git', 'hg')
161 @HasAcceptedRepoType('git', 'hg')
161 def show_all(self, repo_name):
162 def show_all(self, repo_name):
162 # filter types
163 # filter types
163 c.active = 'open'
164 c.active = 'open'
164 c.source = str2bool(request.GET.get('source'))
165 c.source = str2bool(request.GET.get('source'))
165 c.closed = str2bool(request.GET.get('closed'))
166 c.closed = str2bool(request.GET.get('closed'))
166 c.my = str2bool(request.GET.get('my'))
167 c.my = str2bool(request.GET.get('my'))
167 c.awaiting_review = str2bool(request.GET.get('awaiting_review'))
168 c.awaiting_review = str2bool(request.GET.get('awaiting_review'))
168 c.awaiting_my_review = str2bool(request.GET.get('awaiting_my_review'))
169 c.awaiting_my_review = str2bool(request.GET.get('awaiting_my_review'))
169 c.repo_name = repo_name
170 c.repo_name = repo_name
170
171
171 opened_by = None
172 opened_by = None
172 if c.my:
173 if c.my:
173 c.active = 'my'
174 c.active = 'my'
174 opened_by = [c.rhodecode_user.user_id]
175 opened_by = [c.rhodecode_user.user_id]
175
176
176 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
177 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
177 if c.closed:
178 if c.closed:
178 c.active = 'closed'
179 c.active = 'closed'
179 statuses = [PullRequest.STATUS_CLOSED]
180 statuses = [PullRequest.STATUS_CLOSED]
180
181
181 if c.awaiting_review and not c.source:
182 if c.awaiting_review and not c.source:
182 c.active = 'awaiting'
183 c.active = 'awaiting'
183 if c.source and not c.awaiting_review:
184 if c.source and not c.awaiting_review:
184 c.active = 'source'
185 c.active = 'source'
185 if c.awaiting_my_review:
186 if c.awaiting_my_review:
186 c.active = 'awaiting_my'
187 c.active = 'awaiting_my'
187
188
188 data = self._get_pull_requests_list(
189 data = self._get_pull_requests_list(
189 repo_name=repo_name, opened_by=opened_by, statuses=statuses)
190 repo_name=repo_name, opened_by=opened_by, statuses=statuses)
190 if not request.is_xhr:
191 if not request.is_xhr:
191 c.data = json.dumps(data['data'])
192 c.data = json.dumps(data['data'])
192 c.records_total = data['recordsTotal']
193 c.records_total = data['recordsTotal']
193 return render('/pullrequests/pullrequests.html')
194 return render('/pullrequests/pullrequests.html')
194 else:
195 else:
195 return json.dumps(data)
196 return json.dumps(data)
196
197
197 def _get_pull_requests_list(self, repo_name, opened_by, statuses):
198 def _get_pull_requests_list(self, repo_name, opened_by, statuses):
198 # pagination
199 # pagination
199 start = safe_int(request.GET.get('start'), 0)
200 start = safe_int(request.GET.get('start'), 0)
200 length = safe_int(request.GET.get('length'), c.visual.dashboard_items)
201 length = safe_int(request.GET.get('length'), c.visual.dashboard_items)
201 order_by, order_dir = self._extract_ordering(request)
202 order_by, order_dir = self._extract_ordering(request)
202
203
203 if c.awaiting_review:
204 if c.awaiting_review:
204 pull_requests = PullRequestModel().get_awaiting_review(
205 pull_requests = PullRequestModel().get_awaiting_review(
205 repo_name, source=c.source, opened_by=opened_by,
206 repo_name, source=c.source, opened_by=opened_by,
206 statuses=statuses, offset=start, length=length,
207 statuses=statuses, offset=start, length=length,
207 order_by=order_by, order_dir=order_dir)
208 order_by=order_by, order_dir=order_dir)
208 pull_requests_total_count = PullRequestModel(
209 pull_requests_total_count = PullRequestModel(
209 ).count_awaiting_review(
210 ).count_awaiting_review(
210 repo_name, source=c.source, statuses=statuses,
211 repo_name, source=c.source, statuses=statuses,
211 opened_by=opened_by)
212 opened_by=opened_by)
212 elif c.awaiting_my_review:
213 elif c.awaiting_my_review:
213 pull_requests = PullRequestModel().get_awaiting_my_review(
214 pull_requests = PullRequestModel().get_awaiting_my_review(
214 repo_name, source=c.source, opened_by=opened_by,
215 repo_name, source=c.source, opened_by=opened_by,
215 user_id=c.rhodecode_user.user_id, statuses=statuses,
216 user_id=c.rhodecode_user.user_id, statuses=statuses,
216 offset=start, length=length, order_by=order_by,
217 offset=start, length=length, order_by=order_by,
217 order_dir=order_dir)
218 order_dir=order_dir)
218 pull_requests_total_count = PullRequestModel(
219 pull_requests_total_count = PullRequestModel(
219 ).count_awaiting_my_review(
220 ).count_awaiting_my_review(
220 repo_name, source=c.source, user_id=c.rhodecode_user.user_id,
221 repo_name, source=c.source, user_id=c.rhodecode_user.user_id,
221 statuses=statuses, opened_by=opened_by)
222 statuses=statuses, opened_by=opened_by)
222 else:
223 else:
223 pull_requests = PullRequestModel().get_all(
224 pull_requests = PullRequestModel().get_all(
224 repo_name, source=c.source, opened_by=opened_by,
225 repo_name, source=c.source, opened_by=opened_by,
225 statuses=statuses, offset=start, length=length,
226 statuses=statuses, offset=start, length=length,
226 order_by=order_by, order_dir=order_dir)
227 order_by=order_by, order_dir=order_dir)
227 pull_requests_total_count = PullRequestModel().count_all(
228 pull_requests_total_count = PullRequestModel().count_all(
228 repo_name, source=c.source, statuses=statuses,
229 repo_name, source=c.source, statuses=statuses,
229 opened_by=opened_by)
230 opened_by=opened_by)
230
231
231 from rhodecode.lib.utils import PartialRenderer
232 from rhodecode.lib.utils import PartialRenderer
232 _render = PartialRenderer('data_table/_dt_elements.html')
233 _render = PartialRenderer('data_table/_dt_elements.html')
233 data = []
234 data = []
234 for pr in pull_requests:
235 for pr in pull_requests:
235 comments = ChangesetCommentsModel().get_all_comments(
236 comments = ChangesetCommentsModel().get_all_comments(
236 c.rhodecode_db_repo.repo_id, pull_request=pr)
237 c.rhodecode_db_repo.repo_id, pull_request=pr)
237
238
238 data.append({
239 data.append({
239 'name': _render('pullrequest_name',
240 'name': _render('pullrequest_name',
240 pr.pull_request_id, pr.target_repo.repo_name),
241 pr.pull_request_id, pr.target_repo.repo_name),
241 'name_raw': pr.pull_request_id,
242 'name_raw': pr.pull_request_id,
242 'status': _render('pullrequest_status',
243 'status': _render('pullrequest_status',
243 pr.calculated_review_status()),
244 pr.calculated_review_status()),
244 'title': _render(
245 'title': _render(
245 'pullrequest_title', pr.title, pr.description),
246 'pullrequest_title', pr.title, pr.description),
246 'description': h.escape(pr.description),
247 'description': h.escape(pr.description),
247 'updated_on': _render('pullrequest_updated_on',
248 'updated_on': _render('pullrequest_updated_on',
248 h.datetime_to_time(pr.updated_on)),
249 h.datetime_to_time(pr.updated_on)),
249 'updated_on_raw': h.datetime_to_time(pr.updated_on),
250 'updated_on_raw': h.datetime_to_time(pr.updated_on),
250 'created_on': _render('pullrequest_updated_on',
251 'created_on': _render('pullrequest_updated_on',
251 h.datetime_to_time(pr.created_on)),
252 h.datetime_to_time(pr.created_on)),
252 'created_on_raw': h.datetime_to_time(pr.created_on),
253 'created_on_raw': h.datetime_to_time(pr.created_on),
253 'author': _render('pullrequest_author',
254 'author': _render('pullrequest_author',
254 pr.author.full_contact, ),
255 pr.author.full_contact, ),
255 'author_raw': pr.author.full_name,
256 'author_raw': pr.author.full_name,
256 'comments': _render('pullrequest_comments', len(comments)),
257 'comments': _render('pullrequest_comments', len(comments)),
257 'comments_raw': len(comments),
258 'comments_raw': len(comments),
258 'closed': pr.is_closed(),
259 'closed': pr.is_closed(),
259 })
260 })
260 # json used to render the grid
261 # json used to render the grid
261 data = ({
262 data = ({
262 'data': data,
263 'data': data,
263 'recordsTotal': pull_requests_total_count,
264 'recordsTotal': pull_requests_total_count,
264 'recordsFiltered': pull_requests_total_count,
265 'recordsFiltered': pull_requests_total_count,
265 })
266 })
266 return data
267 return data
267
268
268 @LoginRequired()
269 @LoginRequired()
269 @NotAnonymous()
270 @NotAnonymous()
270 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
271 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
271 'repository.admin')
272 'repository.admin')
272 @HasAcceptedRepoType('git', 'hg')
273 @HasAcceptedRepoType('git', 'hg')
273 def index(self):
274 def index(self):
274 source_repo = c.rhodecode_db_repo
275 source_repo = c.rhodecode_db_repo
275
276
276 try:
277 try:
277 source_repo.scm_instance().get_commit()
278 source_repo.scm_instance().get_commit()
278 except EmptyRepositoryError:
279 except EmptyRepositoryError:
279 h.flash(h.literal(_('There are no commits yet')),
280 h.flash(h.literal(_('There are no commits yet')),
280 category='warning')
281 category='warning')
281 redirect(url('summary_home', repo_name=source_repo.repo_name))
282 redirect(url('summary_home', repo_name=source_repo.repo_name))
282
283
283 commit_id = request.GET.get('commit')
284 commit_id = request.GET.get('commit')
284 branch_ref = request.GET.get('branch')
285 branch_ref = request.GET.get('branch')
285 bookmark_ref = request.GET.get('bookmark')
286 bookmark_ref = request.GET.get('bookmark')
286
287
287 try:
288 try:
288 source_repo_data = PullRequestModel().generate_repo_data(
289 source_repo_data = PullRequestModel().generate_repo_data(
289 source_repo, commit_id=commit_id,
290 source_repo, commit_id=commit_id,
290 branch=branch_ref, bookmark=bookmark_ref)
291 branch=branch_ref, bookmark=bookmark_ref)
291 except CommitDoesNotExistError as e:
292 except CommitDoesNotExistError as e:
292 log.exception(e)
293 log.exception(e)
293 h.flash(_('Commit does not exist'), 'error')
294 h.flash(_('Commit does not exist'), 'error')
294 redirect(url('pullrequest_home', repo_name=source_repo.repo_name))
295 redirect(url('pullrequest_home', repo_name=source_repo.repo_name))
295
296
296 default_target_repo = source_repo
297 default_target_repo = source_repo
297 if (source_repo.parent and
298 if (source_repo.parent and
298 not source_repo.parent.scm_instance().is_empty()):
299 not source_repo.parent.scm_instance().is_empty()):
299 # change default if we have a parent repo
300 # change default if we have a parent repo
300 default_target_repo = source_repo.parent
301 default_target_repo = source_repo.parent
301
302
302 target_repo_data = PullRequestModel().generate_repo_data(
303 target_repo_data = PullRequestModel().generate_repo_data(
303 default_target_repo)
304 default_target_repo)
304
305
305 selected_source_ref = source_repo_data['refs']['selected_ref']
306 selected_source_ref = source_repo_data['refs']['selected_ref']
306
307
307 title_source_ref = selected_source_ref.split(':', 2)[1]
308 title_source_ref = selected_source_ref.split(':', 2)[1]
308 c.default_title = PullRequestModel().generate_pullrequest_title(
309 c.default_title = PullRequestModel().generate_pullrequest_title(
309 source=source_repo.repo_name,
310 source=source_repo.repo_name,
310 source_ref=title_source_ref,
311 source_ref=title_source_ref,
311 target=default_target_repo.repo_name
312 target=default_target_repo.repo_name
312 )
313 )
313
314
314 c.default_repo_data = {
315 c.default_repo_data = {
315 'source_repo_name': source_repo.repo_name,
316 'source_repo_name': source_repo.repo_name,
316 'source_refs_json': json.dumps(source_repo_data),
317 'source_refs_json': json.dumps(source_repo_data),
317 'target_repo_name': default_target_repo.repo_name,
318 'target_repo_name': default_target_repo.repo_name,
318 'target_refs_json': json.dumps(target_repo_data),
319 'target_refs_json': json.dumps(target_repo_data),
319 }
320 }
320 c.default_source_ref = selected_source_ref
321 c.default_source_ref = selected_source_ref
321
322
322 return render('/pullrequests/pullrequest.html')
323 return render('/pullrequests/pullrequest.html')
323
324
324 @LoginRequired()
325 @LoginRequired()
325 @NotAnonymous()
326 @NotAnonymous()
326 @XHRRequired()
327 @XHRRequired()
327 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
328 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
328 'repository.admin')
329 'repository.admin')
329 @jsonify
330 @jsonify
330 def get_repo_refs(self, repo_name, target_repo_name):
331 def get_repo_refs(self, repo_name, target_repo_name):
331 repo = Repository.get_by_repo_name(target_repo_name)
332 repo = Repository.get_by_repo_name(target_repo_name)
332 if not repo:
333 if not repo:
333 raise HTTPNotFound
334 raise HTTPNotFound
334 return PullRequestModel().generate_repo_data(repo)
335 return PullRequestModel().generate_repo_data(repo)
335
336
336 @LoginRequired()
337 @LoginRequired()
337 @NotAnonymous()
338 @NotAnonymous()
338 @XHRRequired()
339 @XHRRequired()
339 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
340 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
340 'repository.admin')
341 'repository.admin')
341 @jsonify
342 @jsonify
342 def get_repo_destinations(self, repo_name):
343 def get_repo_destinations(self, repo_name):
343 repo = Repository.get_by_repo_name(repo_name)
344 repo = Repository.get_by_repo_name(repo_name)
344 if not repo:
345 if not repo:
345 raise HTTPNotFound
346 raise HTTPNotFound
346 filter_query = request.GET.get('query')
347 filter_query = request.GET.get('query')
347
348
348 query = Repository.query() \
349 query = Repository.query() \
349 .order_by(func.length(Repository.repo_name)) \
350 .order_by(func.length(Repository.repo_name)) \
350 .filter(or_(
351 .filter(or_(
351 Repository.repo_name == repo.repo_name,
352 Repository.repo_name == repo.repo_name,
352 Repository.fork_id == repo.repo_id))
353 Repository.fork_id == repo.repo_id))
353
354
354 if filter_query:
355 if filter_query:
355 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
356 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
356 query = query.filter(
357 query = query.filter(
357 Repository.repo_name.ilike(ilike_expression))
358 Repository.repo_name.ilike(ilike_expression))
358
359
359 add_parent = False
360 add_parent = False
360 if repo.parent:
361 if repo.parent:
361 if filter_query in repo.parent.repo_name:
362 if filter_query in repo.parent.repo_name:
362 if not repo.parent.scm_instance().is_empty():
363 if not repo.parent.scm_instance().is_empty():
363 add_parent = True
364 add_parent = True
364
365
365 limit = 20 - 1 if add_parent else 20
366 limit = 20 - 1 if add_parent else 20
366 all_repos = query.limit(limit).all()
367 all_repos = query.limit(limit).all()
367 if add_parent:
368 if add_parent:
368 all_repos += [repo.parent]
369 all_repos += [repo.parent]
369
370
370 repos = []
371 repos = []
371 for obj in self.scm_model.get_repos(all_repos):
372 for obj in self.scm_model.get_repos(all_repos):
372 repos.append({
373 repos.append({
373 'id': obj['name'],
374 'id': obj['name'],
374 'text': obj['name'],
375 'text': obj['name'],
375 'type': 'repo',
376 'type': 'repo',
376 'obj': obj['dbrepo']
377 'obj': obj['dbrepo']
377 })
378 })
378
379
379 data = {
380 data = {
380 'more': False,
381 'more': False,
381 'results': [{
382 'results': [{
382 'text': _('Repositories'),
383 'text': _('Repositories'),
383 'children': repos
384 'children': repos
384 }] if repos else []
385 }] if repos else []
385 }
386 }
386 return data
387 return data
387
388
388 @LoginRequired()
389 @LoginRequired()
389 @NotAnonymous()
390 @NotAnonymous()
390 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
391 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
391 'repository.admin')
392 'repository.admin')
392 @HasAcceptedRepoType('git', 'hg')
393 @HasAcceptedRepoType('git', 'hg')
393 @auth.CSRFRequired()
394 @auth.CSRFRequired()
394 def create(self, repo_name):
395 def create(self, repo_name):
395 repo = Repository.get_by_repo_name(repo_name)
396 repo = Repository.get_by_repo_name(repo_name)
396 if not repo:
397 if not repo:
397 raise HTTPNotFound
398 raise HTTPNotFound
398
399
399 try:
400 try:
400 _form = PullRequestForm(repo.repo_id)().to_python(request.POST)
401 _form = PullRequestForm(repo.repo_id)().to_python(request.POST)
401 except formencode.Invalid as errors:
402 except formencode.Invalid as errors:
402 if errors.error_dict.get('revisions'):
403 if errors.error_dict.get('revisions'):
403 msg = 'Revisions: %s' % errors.error_dict['revisions']
404 msg = 'Revisions: %s' % errors.error_dict['revisions']
404 elif errors.error_dict.get('pullrequest_title'):
405 elif errors.error_dict.get('pullrequest_title'):
405 msg = _('Pull request requires a title with min. 3 chars')
406 msg = _('Pull request requires a title with min. 3 chars')
406 else:
407 else:
407 msg = _('Error creating pull request: {}').format(errors)
408 msg = _('Error creating pull request: {}').format(errors)
408 log.exception(msg)
409 log.exception(msg)
409 h.flash(msg, 'error')
410 h.flash(msg, 'error')
410
411
411 # would rather just go back to form ...
412 # would rather just go back to form ...
412 return redirect(url('pullrequest_home', repo_name=repo_name))
413 return redirect(url('pullrequest_home', repo_name=repo_name))
413
414
414 source_repo = _form['source_repo']
415 source_repo = _form['source_repo']
415 source_ref = _form['source_ref']
416 source_ref = _form['source_ref']
416 target_repo = _form['target_repo']
417 target_repo = _form['target_repo']
417 target_ref = _form['target_ref']
418 target_ref = _form['target_ref']
418 commit_ids = _form['revisions'][::-1]
419 commit_ids = _form['revisions'][::-1]
419 reviewers = _form['review_members']
420 reviewers = _form['review_members']
420
421
421 # find the ancestor for this pr
422 # find the ancestor for this pr
422 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
423 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
423 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
424 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
424
425
425 source_scm = source_db_repo.scm_instance()
426 source_scm = source_db_repo.scm_instance()
426 target_scm = target_db_repo.scm_instance()
427 target_scm = target_db_repo.scm_instance()
427
428
428 source_commit = source_scm.get_commit(source_ref.split(':')[-1])
429 source_commit = source_scm.get_commit(source_ref.split(':')[-1])
429 target_commit = target_scm.get_commit(target_ref.split(':')[-1])
430 target_commit = target_scm.get_commit(target_ref.split(':')[-1])
430
431
431 ancestor = source_scm.get_common_ancestor(
432 ancestor = source_scm.get_common_ancestor(
432 source_commit.raw_id, target_commit.raw_id, target_scm)
433 source_commit.raw_id, target_commit.raw_id, target_scm)
433
434
434 target_ref_type, target_ref_name, __ = _form['target_ref'].split(':')
435 target_ref_type, target_ref_name, __ = _form['target_ref'].split(':')
435 target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
436 target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
436
437
437 pullrequest_title = _form['pullrequest_title']
438 pullrequest_title = _form['pullrequest_title']
438 title_source_ref = source_ref.split(':', 2)[1]
439 title_source_ref = source_ref.split(':', 2)[1]
439 if not pullrequest_title:
440 if not pullrequest_title:
440 pullrequest_title = PullRequestModel().generate_pullrequest_title(
441 pullrequest_title = PullRequestModel().generate_pullrequest_title(
441 source=source_repo,
442 source=source_repo,
442 source_ref=title_source_ref,
443 source_ref=title_source_ref,
443 target=target_repo
444 target=target_repo
444 )
445 )
445
446
446 description = _form['pullrequest_desc']
447 description = _form['pullrequest_desc']
447 try:
448 try:
448 pull_request = PullRequestModel().create(
449 pull_request = PullRequestModel().create(
449 c.rhodecode_user.user_id, source_repo, source_ref, target_repo,
450 c.rhodecode_user.user_id, source_repo, source_ref, target_repo,
450 target_ref, commit_ids, reviewers, pullrequest_title,
451 target_ref, commit_ids, reviewers, pullrequest_title,
451 description
452 description
452 )
453 )
453 Session().commit()
454 Session().commit()
454 h.flash(_('Successfully opened new pull request'),
455 h.flash(_('Successfully opened new pull request'),
455 category='success')
456 category='success')
456 except Exception as e:
457 except Exception as e:
457 msg = _('Error occurred during sending pull request')
458 msg = _('Error occurred during sending pull request')
458 log.exception(msg)
459 log.exception(msg)
459 h.flash(msg, category='error')
460 h.flash(msg, category='error')
460 return redirect(url('pullrequest_home', repo_name=repo_name))
461 return redirect(url('pullrequest_home', repo_name=repo_name))
461
462
462 return redirect(url('pullrequest_show', repo_name=target_repo,
463 return redirect(url('pullrequest_show', repo_name=target_repo,
463 pull_request_id=pull_request.pull_request_id))
464 pull_request_id=pull_request.pull_request_id))
464
465
465 @LoginRequired()
466 @LoginRequired()
466 @NotAnonymous()
467 @NotAnonymous()
467 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
468 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
468 'repository.admin')
469 'repository.admin')
469 @auth.CSRFRequired()
470 @auth.CSRFRequired()
470 @jsonify
471 @jsonify
471 def update(self, repo_name, pull_request_id):
472 def update(self, repo_name, pull_request_id):
472 pull_request_id = safe_int(pull_request_id)
473 pull_request_id = safe_int(pull_request_id)
473 pull_request = PullRequest.get_or_404(pull_request_id)
474 pull_request = PullRequest.get_or_404(pull_request_id)
474 # only owner or admin can update it
475 # only owner or admin can update it
475 allowed_to_update = PullRequestModel().check_user_update(
476 allowed_to_update = PullRequestModel().check_user_update(
476 pull_request, c.rhodecode_user)
477 pull_request, c.rhodecode_user)
477 if allowed_to_update:
478 if allowed_to_update:
478 if 'reviewers_ids' in request.POST:
479 if 'reviewers_ids' in request.POST:
479 self._update_reviewers(pull_request_id)
480 self._update_reviewers(pull_request_id)
480 elif str2bool(request.POST.get('update_commits', 'false')):
481 elif str2bool(request.POST.get('update_commits', 'false')):
481 self._update_commits(pull_request)
482 self._update_commits(pull_request)
482 elif str2bool(request.POST.get('close_pull_request', 'false')):
483 elif str2bool(request.POST.get('close_pull_request', 'false')):
483 self._reject_close(pull_request)
484 self._reject_close(pull_request)
484 elif str2bool(request.POST.get('edit_pull_request', 'false')):
485 elif str2bool(request.POST.get('edit_pull_request', 'false')):
485 self._edit_pull_request(pull_request)
486 self._edit_pull_request(pull_request)
486 else:
487 else:
487 raise HTTPBadRequest()
488 raise HTTPBadRequest()
488 return True
489 return True
489 raise HTTPForbidden()
490 raise HTTPForbidden()
490
491
491 def _edit_pull_request(self, pull_request):
492 def _edit_pull_request(self, pull_request):
492 try:
493 try:
493 PullRequestModel().edit(
494 PullRequestModel().edit(
494 pull_request, request.POST.get('title'),
495 pull_request, request.POST.get('title'),
495 request.POST.get('description'))
496 request.POST.get('description'))
496 except ValueError:
497 except ValueError:
497 msg = _(u'Cannot update closed pull requests.')
498 msg = _(u'Cannot update closed pull requests.')
498 h.flash(msg, category='error')
499 h.flash(msg, category='error')
499 return
500 return
500 else:
501 else:
501 Session().commit()
502 Session().commit()
502
503
503 msg = _(u'Pull request title & description updated.')
504 msg = _(u'Pull request title & description updated.')
504 h.flash(msg, category='success')
505 h.flash(msg, category='success')
505 return
506 return
506
507
507 def _update_commits(self, pull_request):
508 def _update_commits(self, pull_request):
508 try:
509 try:
509 if PullRequestModel().has_valid_update_type(pull_request):
510 if PullRequestModel().has_valid_update_type(pull_request):
510 updated_version, changes = PullRequestModel().update_commits(
511 updated_version, changes = PullRequestModel().update_commits(
511 pull_request)
512 pull_request)
512 if updated_version:
513 if updated_version:
513 msg = _(
514 msg = _(
514 u'Pull request updated to "{source_commit_id}" with '
515 u'Pull request updated to "{source_commit_id}" with '
515 u'{count_added} added, {count_removed} removed '
516 u'{count_added} added, {count_removed} removed '
516 u'commits.'
517 u'commits.'
517 ).format(
518 ).format(
518 source_commit_id=pull_request.source_ref_parts.commit_id,
519 source_commit_id=pull_request.source_ref_parts.commit_id,
519 count_added=len(changes.added),
520 count_added=len(changes.added),
520 count_removed=len(changes.removed))
521 count_removed=len(changes.removed))
521 h.flash(msg, category='success')
522 h.flash(msg, category='success')
522 else:
523 else:
523 h.flash(_("Nothing changed in pull request."),
524 h.flash(_("Nothing changed in pull request."),
524 category='warning')
525 category='warning')
525 else:
526 else:
526 msg = _(
527 msg = _(
527 u"Skipping update of pull request due to reference "
528 u"Skipping update of pull request due to reference "
528 u"type: {reference_type}"
529 u"type: {reference_type}"
529 ).format(reference_type=pull_request.source_ref_parts.type)
530 ).format(reference_type=pull_request.source_ref_parts.type)
530 h.flash(msg, category='warning')
531 h.flash(msg, category='warning')
531 except CommitDoesNotExistError:
532 except CommitDoesNotExistError:
532 h.flash(
533 h.flash(
533 _(u'Update failed due to missing commits.'), category='error')
534 _(u'Update failed due to missing commits.'), category='error')
534
535
535 @auth.CSRFRequired()
536 @auth.CSRFRequired()
536 @LoginRequired()
537 @LoginRequired()
537 @NotAnonymous()
538 @NotAnonymous()
538 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
539 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
539 'repository.admin')
540 'repository.admin')
540 def merge(self, repo_name, pull_request_id):
541 def merge(self, repo_name, pull_request_id):
541 """
542 """
542 POST /{repo_name}/pull-request/{pull_request_id}
543 POST /{repo_name}/pull-request/{pull_request_id}
543
544
544 Merge will perform a server-side merge of the specified
545 Merge will perform a server-side merge of the specified
545 pull request, if the pull request is approved and mergeable.
546 pull request, if the pull request is approved and mergeable.
546 After succesfull merging, the pull request is automatically
547 After succesfull merging, the pull request is automatically
547 closed, with a relevant comment.
548 closed, with a relevant comment.
548 """
549 """
549 pull_request_id = safe_int(pull_request_id)
550 pull_request_id = safe_int(pull_request_id)
550 pull_request = PullRequest.get_or_404(pull_request_id)
551 pull_request = PullRequest.get_or_404(pull_request_id)
551 user = c.rhodecode_user
552 user = c.rhodecode_user
552
553
553 if self._meets_merge_pre_conditions(pull_request, user):
554 if self._meets_merge_pre_conditions(pull_request, user):
554 log.debug("Pre-conditions checked, trying to merge.")
555 log.debug("Pre-conditions checked, trying to merge.")
555 extras = vcs_operation_context(
556 extras = vcs_operation_context(
556 request.environ, repo_name=pull_request.target_repo.repo_name,
557 request.environ, repo_name=pull_request.target_repo.repo_name,
557 username=user.username, action='push',
558 username=user.username, action='push',
558 scm=pull_request.target_repo.repo_type)
559 scm=pull_request.target_repo.repo_type)
559 self._merge_pull_request(pull_request, user, extras)
560 self._merge_pull_request(pull_request, user, extras)
560
561
561 return redirect(url(
562 return redirect(url(
562 'pullrequest_show',
563 'pullrequest_show',
563 repo_name=pull_request.target_repo.repo_name,
564 repo_name=pull_request.target_repo.repo_name,
564 pull_request_id=pull_request.pull_request_id))
565 pull_request_id=pull_request.pull_request_id))
565
566
566 def _meets_merge_pre_conditions(self, pull_request, user):
567 def _meets_merge_pre_conditions(self, pull_request, user):
567 if not PullRequestModel().check_user_merge(pull_request, user):
568 if not PullRequestModel().check_user_merge(pull_request, user):
568 raise HTTPForbidden()
569 raise HTTPForbidden()
569
570
570 merge_status, msg = PullRequestModel().merge_status(pull_request)
571 merge_status, msg = PullRequestModel().merge_status(pull_request)
571 if not merge_status:
572 if not merge_status:
572 log.debug("Cannot merge, not mergeable.")
573 log.debug("Cannot merge, not mergeable.")
573 h.flash(msg, category='error')
574 h.flash(msg, category='error')
574 return False
575 return False
575
576
576 if (pull_request.calculated_review_status()
577 if (pull_request.calculated_review_status()
577 is not ChangesetStatus.STATUS_APPROVED):
578 is not ChangesetStatus.STATUS_APPROVED):
578 log.debug("Cannot merge, approval is pending.")
579 log.debug("Cannot merge, approval is pending.")
579 msg = _('Pull request reviewer approval is pending.')
580 msg = _('Pull request reviewer approval is pending.')
580 h.flash(msg, category='error')
581 h.flash(msg, category='error')
581 return False
582 return False
582 return True
583 return True
583
584
584 def _merge_pull_request(self, pull_request, user, extras):
585 def _merge_pull_request(self, pull_request, user, extras):
585 merge_resp = PullRequestModel().merge(
586 merge_resp = PullRequestModel().merge(
586 pull_request, user, extras=extras)
587 pull_request, user, extras=extras)
587
588
588 if merge_resp.executed:
589 if merge_resp.executed:
589 log.debug("The merge was successful, closing the pull request.")
590 log.debug("The merge was successful, closing the pull request.")
590 PullRequestModel().close_pull_request(
591 PullRequestModel().close_pull_request(
591 pull_request.pull_request_id, user)
592 pull_request.pull_request_id, user)
592 Session().commit()
593 Session().commit()
593 msg = _('Pull request was successfully merged and closed.')
594 msg = _('Pull request was successfully merged and closed.')
594 h.flash(msg, category='success')
595 h.flash(msg, category='success')
595 else:
596 else:
596 log.debug(
597 log.debug(
597 "The merge was not successful. Merge response: %s",
598 "The merge was not successful. Merge response: %s",
598 merge_resp)
599 merge_resp)
599 msg = PullRequestModel().merge_status_message(
600 msg = PullRequestModel().merge_status_message(
600 merge_resp.failure_reason)
601 merge_resp.failure_reason)
601 h.flash(msg, category='error')
602 h.flash(msg, category='error')
602
603
603 def _update_reviewers(self, pull_request_id):
604 def _update_reviewers(self, pull_request_id):
604 reviewers_ids = map(int, filter(
605 reviewers_ids = map(int, filter(
605 lambda v: v not in [None, ''],
606 lambda v: v not in [None, ''],
606 request.POST.get('reviewers_ids', '').split(',')))
607 request.POST.get('reviewers_ids', '').split(',')))
607 PullRequestModel().update_reviewers(pull_request_id, reviewers_ids)
608 PullRequestModel().update_reviewers(pull_request_id, reviewers_ids)
608 Session().commit()
609 Session().commit()
609
610
610 def _reject_close(self, pull_request):
611 def _reject_close(self, pull_request):
611 if pull_request.is_closed():
612 if pull_request.is_closed():
612 raise HTTPForbidden()
613 raise HTTPForbidden()
613
614
614 PullRequestModel().close_pull_request_with_comment(
615 PullRequestModel().close_pull_request_with_comment(
615 pull_request, c.rhodecode_user, c.rhodecode_db_repo)
616 pull_request, c.rhodecode_user, c.rhodecode_db_repo)
616 Session().commit()
617 Session().commit()
617
618
618 @LoginRequired()
619 @LoginRequired()
619 @NotAnonymous()
620 @NotAnonymous()
620 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
621 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
621 'repository.admin')
622 'repository.admin')
622 @auth.CSRFRequired()
623 @auth.CSRFRequired()
623 @jsonify
624 @jsonify
624 def delete(self, repo_name, pull_request_id):
625 def delete(self, repo_name, pull_request_id):
625 pull_request_id = safe_int(pull_request_id)
626 pull_request_id = safe_int(pull_request_id)
626 pull_request = PullRequest.get_or_404(pull_request_id)
627 pull_request = PullRequest.get_or_404(pull_request_id)
627 # only owner can delete it !
628 # only owner can delete it !
628 if pull_request.author.user_id == c.rhodecode_user.user_id:
629 if pull_request.author.user_id == c.rhodecode_user.user_id:
629 PullRequestModel().delete(pull_request)
630 PullRequestModel().delete(pull_request)
630 Session().commit()
631 Session().commit()
631 h.flash(_('Successfully deleted pull request'),
632 h.flash(_('Successfully deleted pull request'),
632 category='success')
633 category='success')
633 return redirect(url('my_account_pullrequests'))
634 return redirect(url('my_account_pullrequests'))
634 raise HTTPForbidden()
635 raise HTTPForbidden()
635
636
636 @LoginRequired()
637 @LoginRequired()
637 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
638 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
638 'repository.admin')
639 'repository.admin')
639 def show(self, repo_name, pull_request_id):
640 def show(self, repo_name, pull_request_id):
640 pull_request_id = safe_int(pull_request_id)
641 pull_request_id = safe_int(pull_request_id)
641 c.pull_request = PullRequest.get_or_404(pull_request_id)
642 c.pull_request = PullRequest.get_or_404(pull_request_id)
642
643
643 c.template_context['pull_request_data']['pull_request_id'] = \
644 c.template_context['pull_request_data']['pull_request_id'] = \
644 pull_request_id
645 pull_request_id
645
646
646 # pull_requests repo_name we opened it against
647 # pull_requests repo_name we opened it against
647 # ie. target_repo must match
648 # ie. target_repo must match
648 if repo_name != c.pull_request.target_repo.repo_name:
649 if repo_name != c.pull_request.target_repo.repo_name:
649 raise HTTPNotFound
650 raise HTTPNotFound
650
651
651 c.allowed_to_change_status = PullRequestModel(). \
652 c.allowed_to_change_status = PullRequestModel(). \
652 check_user_change_status(c.pull_request, c.rhodecode_user)
653 check_user_change_status(c.pull_request, c.rhodecode_user)
653 c.allowed_to_update = PullRequestModel().check_user_update(
654 c.allowed_to_update = PullRequestModel().check_user_update(
654 c.pull_request, c.rhodecode_user) and not c.pull_request.is_closed()
655 c.pull_request, c.rhodecode_user) and not c.pull_request.is_closed()
655 c.allowed_to_merge = PullRequestModel().check_user_merge(
656 c.allowed_to_merge = PullRequestModel().check_user_merge(
656 c.pull_request, c.rhodecode_user) and not c.pull_request.is_closed()
657 c.pull_request, c.rhodecode_user) and not c.pull_request.is_closed()
657
658
658 cc_model = ChangesetCommentsModel()
659 cc_model = ChangesetCommentsModel()
659
660
660 c.pull_request_reviewers = c.pull_request.reviewers_statuses()
661 c.pull_request_reviewers = c.pull_request.reviewers_statuses()
661
662
662 c.pull_request_review_status = c.pull_request.calculated_review_status()
663 c.pull_request_review_status = c.pull_request.calculated_review_status()
663 c.pr_merge_status, c.pr_merge_msg = PullRequestModel().merge_status(
664 c.pr_merge_status, c.pr_merge_msg = PullRequestModel().merge_status(
664 c.pull_request)
665 c.pull_request)
665 c.approval_msg = None
666 c.approval_msg = None
666 if c.pull_request_review_status != ChangesetStatus.STATUS_APPROVED:
667 if c.pull_request_review_status != ChangesetStatus.STATUS_APPROVED:
667 c.approval_msg = _('Reviewer approval is pending.')
668 c.approval_msg = _('Reviewer approval is pending.')
668 c.pr_merge_status = False
669 c.pr_merge_status = False
669 # load compare data into template context
670 # load compare data into template context
670 enable_comments = not c.pull_request.is_closed()
671 enable_comments = not c.pull_request.is_closed()
671 self._load_compare_data(c.pull_request, enable_comments=enable_comments)
672 self._load_compare_data(c.pull_request, enable_comments=enable_comments)
672
673
673 # this is a hack to properly display links, when creating PR, the
674 # this is a hack to properly display links, when creating PR, the
674 # compare view and others uses different notation, and
675 # compare view and others uses different notation, and
675 # compare_commits.html renders links based on the target_repo.
676 # compare_commits.html renders links based on the target_repo.
676 # We need to swap that here to generate it properly on the html side
677 # We need to swap that here to generate it properly on the html side
677 c.target_repo = c.source_repo
678 c.target_repo = c.source_repo
678
679
679 # inline comments
680 # inline comments
680 c.inline_cnt = 0
681 c.inline_cnt = 0
681 c.inline_comments = cc_model.get_inline_comments(
682 c.inline_comments = cc_model.get_inline_comments(
682 c.rhodecode_db_repo.repo_id,
683 c.rhodecode_db_repo.repo_id,
683 pull_request=pull_request_id).items()
684 pull_request=pull_request_id).items()
684 # count inline comments
685 # count inline comments
685 for __, lines in c.inline_comments:
686 for __, lines in c.inline_comments:
686 for comments in lines.values():
687 for comments in lines.values():
687 c.inline_cnt += len(comments)
688 c.inline_cnt += len(comments)
688
689
689 # outdated comments
690 # outdated comments
690 c.outdated_cnt = 0
691 c.outdated_cnt = 0
691 if ChangesetCommentsModel.use_outdated_comments(c.pull_request):
692 if ChangesetCommentsModel.use_outdated_comments(c.pull_request):
692 c.outdated_comments = cc_model.get_outdated_comments(
693 c.outdated_comments = cc_model.get_outdated_comments(
693 c.rhodecode_db_repo.repo_id,
694 c.rhodecode_db_repo.repo_id,
694 pull_request=c.pull_request)
695 pull_request=c.pull_request)
695 # Count outdated comments and check for deleted files
696 # Count outdated comments and check for deleted files
696 for file_name, lines in c.outdated_comments.iteritems():
697 for file_name, lines in c.outdated_comments.iteritems():
697 for comments in lines.values():
698 for comments in lines.values():
698 c.outdated_cnt += len(comments)
699 c.outdated_cnt += len(comments)
699 if file_name not in c.included_files:
700 if file_name not in c.included_files:
700 c.deleted_files.append(file_name)
701 c.deleted_files.append(file_name)
701 else:
702 else:
702 c.outdated_comments = {}
703 c.outdated_comments = {}
703
704
704 # comments
705 # comments
705 c.comments = cc_model.get_comments(c.rhodecode_db_repo.repo_id,
706 c.comments = cc_model.get_comments(c.rhodecode_db_repo.repo_id,
706 pull_request=pull_request_id)
707 pull_request=pull_request_id)
707
708
708 if c.allowed_to_update:
709 if c.allowed_to_update:
709 force_close = ('forced_closed', _('Close Pull Request'))
710 force_close = ('forced_closed', _('Close Pull Request'))
710 statuses = ChangesetStatus.STATUSES + [force_close]
711 statuses = ChangesetStatus.STATUSES + [force_close]
711 else:
712 else:
712 statuses = ChangesetStatus.STATUSES
713 statuses = ChangesetStatus.STATUSES
713 c.commit_statuses = statuses
714 c.commit_statuses = statuses
714
715
715 c.ancestor = None # TODO: add ancestor here
716 c.ancestor = None # TODO: add ancestor here
716
717
717 return render('/pullrequests/pullrequest_show.html')
718 return render('/pullrequests/pullrequest_show.html')
718
719
719 @LoginRequired()
720 @LoginRequired()
720 @NotAnonymous()
721 @NotAnonymous()
721 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
722 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
722 'repository.admin')
723 'repository.admin')
723 @auth.CSRFRequired()
724 @auth.CSRFRequired()
724 @jsonify
725 @jsonify
725 def comment(self, repo_name, pull_request_id):
726 def comment(self, repo_name, pull_request_id):
726 pull_request_id = safe_int(pull_request_id)
727 pull_request_id = safe_int(pull_request_id)
727 pull_request = PullRequest.get_or_404(pull_request_id)
728 pull_request = PullRequest.get_or_404(pull_request_id)
728 if pull_request.is_closed():
729 if pull_request.is_closed():
729 raise HTTPForbidden()
730 raise HTTPForbidden()
730
731
731 # TODO: johbo: Re-think this bit, "approved_closed" does not exist
732 # TODO: johbo: Re-think this bit, "approved_closed" does not exist
732 # as a changeset status, still we want to send it in one value.
733 # as a changeset status, still we want to send it in one value.
733 status = request.POST.get('changeset_status', None)
734 status = request.POST.get('changeset_status', None)
734 text = request.POST.get('text')
735 text = request.POST.get('text')
735 if status and '_closed' in status:
736 if status and '_closed' in status:
736 close_pr = True
737 close_pr = True
737 status = status.replace('_closed', '')
738 status = status.replace('_closed', '')
738 else:
739 else:
739 close_pr = False
740 close_pr = False
740
741
741 forced = (status == 'forced')
742 forced = (status == 'forced')
742 if forced:
743 if forced:
743 status = 'rejected'
744 status = 'rejected'
744
745
745 allowed_to_change_status = PullRequestModel().check_user_change_status(
746 allowed_to_change_status = PullRequestModel().check_user_change_status(
746 pull_request, c.rhodecode_user)
747 pull_request, c.rhodecode_user)
747
748
748 if status and allowed_to_change_status:
749 if status and allowed_to_change_status:
749 message = (_('Status change %(transition_icon)s %(status)s')
750 message = (_('Status change %(transition_icon)s %(status)s')
750 % {'transition_icon': '>',
751 % {'transition_icon': '>',
751 'status': ChangesetStatus.get_status_lbl(status)})
752 'status': ChangesetStatus.get_status_lbl(status)})
752 if close_pr:
753 if close_pr:
753 message = _('Closing with') + ' ' + message
754 message = _('Closing with') + ' ' + message
754 text = text or message
755 text = text or message
755 comm = ChangesetCommentsModel().create(
756 comm = ChangesetCommentsModel().create(
756 text=text,
757 text=text,
757 repo=c.rhodecode_db_repo.repo_id,
758 repo=c.rhodecode_db_repo.repo_id,
758 user=c.rhodecode_user.user_id,
759 user=c.rhodecode_user.user_id,
759 pull_request=pull_request_id,
760 pull_request=pull_request_id,
760 f_path=request.POST.get('f_path'),
761 f_path=request.POST.get('f_path'),
761 line_no=request.POST.get('line'),
762 line_no=request.POST.get('line'),
762 status_change=(ChangesetStatus.get_status_lbl(status)
763 status_change=(ChangesetStatus.get_status_lbl(status)
763 if status and allowed_to_change_status else None),
764 if status and allowed_to_change_status else None),
764 closing_pr=close_pr
765 closing_pr=close_pr
765 )
766 )
766
767
768
769
767 if allowed_to_change_status:
770 if allowed_to_change_status:
768 old_calculated_status = pull_request.calculated_review_status()
771 old_calculated_status = pull_request.calculated_review_status()
769 # get status if set !
772 # get status if set !
770 if status:
773 if status:
771 ChangesetStatusModel().set_status(
774 ChangesetStatusModel().set_status(
772 c.rhodecode_db_repo.repo_id,
775 c.rhodecode_db_repo.repo_id,
773 status,
776 status,
774 c.rhodecode_user.user_id,
777 c.rhodecode_user.user_id,
775 comm,
778 comm,
776 pull_request=pull_request_id
779 pull_request=pull_request_id
777 )
780 )
778
781
779 Session().flush()
782 Session().flush()
783 events.trigger(events.PullRequestCommentEvent(pull_request, comm))
780 # we now calculate the status of pull request, and based on that
784 # we now calculate the status of pull request, and based on that
781 # calculation we set the commits status
785 # calculation we set the commits status
782 calculated_status = pull_request.calculated_review_status()
786 calculated_status = pull_request.calculated_review_status()
783 if old_calculated_status != calculated_status:
787 if old_calculated_status != calculated_status:
784 PullRequestModel()._trigger_pull_request_hook(
788 PullRequestModel()._trigger_pull_request_hook(
785 pull_request, c.rhodecode_user, 'review_status_change')
789 pull_request, c.rhodecode_user, 'review_status_change')
786
790
787 calculated_status_lbl = ChangesetStatus.get_status_lbl(
791 calculated_status_lbl = ChangesetStatus.get_status_lbl(
788 calculated_status)
792 calculated_status)
789
793
790 if close_pr:
794 if close_pr:
791 status_completed = (
795 status_completed = (
792 calculated_status in [ChangesetStatus.STATUS_APPROVED,
796 calculated_status in [ChangesetStatus.STATUS_APPROVED,
793 ChangesetStatus.STATUS_REJECTED])
797 ChangesetStatus.STATUS_REJECTED])
794 if forced or status_completed:
798 if forced or status_completed:
795 PullRequestModel().close_pull_request(
799 PullRequestModel().close_pull_request(
796 pull_request_id, c.rhodecode_user)
800 pull_request_id, c.rhodecode_user)
797 else:
801 else:
798 h.flash(_('Closing pull request on other statuses than '
802 h.flash(_('Closing pull request on other statuses than '
799 'rejected or approved is forbidden. '
803 'rejected or approved is forbidden. '
800 'Calculated status from all reviewers '
804 'Calculated status from all reviewers '
801 'is currently: %s') % calculated_status_lbl,
805 'is currently: %s') % calculated_status_lbl,
802 category='warning')
806 category='warning')
803
807
804 Session().commit()
808 Session().commit()
805
809
806 if not request.is_xhr:
810 if not request.is_xhr:
807 return redirect(h.url('pullrequest_show', repo_name=repo_name,
811 return redirect(h.url('pullrequest_show', repo_name=repo_name,
808 pull_request_id=pull_request_id))
812 pull_request_id=pull_request_id))
809
813
810 data = {
814 data = {
811 'target_id': h.safeid(h.safe_unicode(request.POST.get('f_path'))),
815 'target_id': h.safeid(h.safe_unicode(request.POST.get('f_path'))),
812 }
816 }
813 if comm:
817 if comm:
814 c.co = comm
818 c.co = comm
815 data.update(comm.get_dict())
819 data.update(comm.get_dict())
816 data.update({'rendered_text':
820 data.update({'rendered_text':
817 render('changeset/changeset_comment_block.html')})
821 render('changeset/changeset_comment_block.html')})
818
822
819 return data
823 return data
820
824
821 @LoginRequired()
825 @LoginRequired()
822 @NotAnonymous()
826 @NotAnonymous()
823 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
827 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
824 'repository.admin')
828 'repository.admin')
825 @auth.CSRFRequired()
829 @auth.CSRFRequired()
826 @jsonify
830 @jsonify
827 def delete_comment(self, repo_name, comment_id):
831 def delete_comment(self, repo_name, comment_id):
828 return self._delete_comment(comment_id)
832 return self._delete_comment(comment_id)
829
833
830 def _delete_comment(self, comment_id):
834 def _delete_comment(self, comment_id):
831 comment_id = safe_int(comment_id)
835 comment_id = safe_int(comment_id)
832 co = ChangesetComment.get_or_404(comment_id)
836 co = ChangesetComment.get_or_404(comment_id)
833 if co.pull_request.is_closed():
837 if co.pull_request.is_closed():
834 # don't allow deleting comments on closed pull request
838 # don't allow deleting comments on closed pull request
835 raise HTTPForbidden()
839 raise HTTPForbidden()
836
840
837 is_owner = co.author.user_id == c.rhodecode_user.user_id
841 is_owner = co.author.user_id == c.rhodecode_user.user_id
838 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(c.repo_name)
842 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(c.repo_name)
839 if h.HasPermissionAny('hg.admin')() or is_repo_admin or is_owner:
843 if h.HasPermissionAny('hg.admin')() or is_repo_admin or is_owner:
840 old_calculated_status = co.pull_request.calculated_review_status()
844 old_calculated_status = co.pull_request.calculated_review_status()
841 ChangesetCommentsModel().delete(comment=co)
845 ChangesetCommentsModel().delete(comment=co)
842 Session().commit()
846 Session().commit()
843 calculated_status = co.pull_request.calculated_review_status()
847 calculated_status = co.pull_request.calculated_review_status()
844 if old_calculated_status != calculated_status:
848 if old_calculated_status != calculated_status:
845 PullRequestModel()._trigger_pull_request_hook(
849 PullRequestModel()._trigger_pull_request_hook(
846 co.pull_request, c.rhodecode_user, 'review_status_change')
850 co.pull_request, c.rhodecode_user, 'review_status_change')
847 return True
851 return True
848 else:
852 else:
849 raise HTTPForbidden()
853 raise HTTPForbidden()
@@ -1,70 +1,71 b''
1 # Copyright (C) 2016-2016 RhodeCode GmbH
1 # Copyright (C) 2016-2016 RhodeCode GmbH
2 #
2 #
3 # This program is free software: you can redistribute it and/or modify
3 # This program is free software: you can redistribute it and/or modify
4 # it under the terms of the GNU Affero General Public License, version 3
4 # it under the terms of the GNU Affero General Public License, version 3
5 # (only), as published by the Free Software Foundation.
5 # (only), as published by the Free Software Foundation.
6 #
6 #
7 # This program is distributed in the hope that it will be useful,
7 # This program is distributed in the hope that it will be useful,
8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 # GNU General Public License for more details.
10 # GNU General Public License for more details.
11 #
11 #
12 # You should have received a copy of the GNU Affero General Public License
12 # You should have received a copy of the GNU Affero General Public License
13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 #
14 #
15 # This program is dual-licensed. If you wish to learn more about the
15 # This program is dual-licensed. If you wish to learn more about the
16 # RhodeCode Enterprise Edition, including its added features, Support services,
16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 # and proprietary license terms, please see https://rhodecode.com/licenses/
17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18
18
19 import logging
19 import logging
20 from pyramid.threadlocal import get_current_registry
20 from pyramid.threadlocal import get_current_registry
21
21
22 log = logging.getLogger()
22 log = logging.getLogger()
23
23
24
24
25 def trigger(event, registry=None):
25 def trigger(event, registry=None):
26 """
26 """
27 Helper method to send an event. This wraps the pyramid logic to send an
27 Helper method to send an event. This wraps the pyramid logic to send an
28 event.
28 event.
29 """
29 """
30 # For the first step we are using pyramids thread locals here. If the
30 # For the first step we are using pyramids thread locals here. If the
31 # event mechanism works out as a good solution we should think about
31 # event mechanism works out as a good solution we should think about
32 # passing the registry as an argument to get rid of it.
32 # passing the registry as an argument to get rid of it.
33 registry = registry or get_current_registry()
33 registry = registry or get_current_registry()
34 registry.notify(event)
34 registry.notify(event)
35 log.debug('event %s triggered', event)
35 log.debug('event %s triggered', event)
36
36
37 # Until we can work around the problem that VCS operations do not have a
37 # Until we can work around the problem that VCS operations do not have a
38 # pyramid context to work with, we send the events to integrations directly
38 # pyramid context to work with, we send the events to integrations directly
39
39
40 # Later it will be possible to use regular pyramid subscribers ie:
40 # Later it will be possible to use regular pyramid subscribers ie:
41 # config.add_subscriber(integrations_event_handler, RhodecodeEvent)
41 # config.add_subscriber(integrations_event_handler, RhodecodeEvent)
42 from rhodecode.integrations import integrations_event_handler
42 from rhodecode.integrations import integrations_event_handler
43 if isinstance(event, RhodecodeEvent):
43 if isinstance(event, RhodecodeEvent):
44 integrations_event_handler(event)
44 integrations_event_handler(event)
45
45
46
46
47 from rhodecode.events.base import RhodecodeEvent
47 from rhodecode.events.base import RhodecodeEvent
48
48
49 from rhodecode.events.user import (
49 from rhodecode.events.user import (
50 UserPreCreate,
50 UserPreCreate,
51 UserPreUpdate,
51 UserPreUpdate,
52 UserRegistered
52 UserRegistered
53 )
53 )
54
54
55 from rhodecode.events.repo import (
55 from rhodecode.events.repo import (
56 RepoEvent,
56 RepoEvent,
57 RepoPreCreateEvent, RepoCreateEvent,
57 RepoPreCreateEvent, RepoCreateEvent,
58 RepoPreDeleteEvent, RepoDeleteEvent,
58 RepoPreDeleteEvent, RepoDeleteEvent,
59 RepoPrePushEvent, RepoPushEvent,
59 RepoPrePushEvent, RepoPushEvent,
60 RepoPrePullEvent, RepoPullEvent,
60 RepoPrePullEvent, RepoPullEvent,
61 )
61 )
62
62
63 from rhodecode.events.pullrequest import (
63 from rhodecode.events.pullrequest import (
64 PullRequestEvent,
64 PullRequestEvent,
65 PullRequestCreateEvent,
65 PullRequestCreateEvent,
66 PullRequestUpdateEvent,
66 PullRequestUpdateEvent,
67 PullRequestCommentEvent,
67 PullRequestReviewEvent,
68 PullRequestReviewEvent,
68 PullRequestMergeEvent,
69 PullRequestMergeEvent,
69 PullRequestCloseEvent,
70 PullRequestCloseEvent,
70 )
71 )
@@ -1,97 +1,126 b''
1 # Copyright (C) 2016-2016 RhodeCode GmbH
1 # Copyright (C) 2016-2016 RhodeCode GmbH
2 #
2 #
3 # This program is free software: you can redistribute it and/or modify
3 # This program is free software: you can redistribute it and/or modify
4 # it under the terms of the GNU Affero General Public License, version 3
4 # it under the terms of the GNU Affero General Public License, version 3
5 # (only), as published by the Free Software Foundation.
5 # (only), as published by the Free Software Foundation.
6 #
6 #
7 # This program is distributed in the hope that it will be useful,
7 # This program is distributed in the hope that it will be useful,
8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 # GNU General Public License for more details.
10 # GNU General Public License for more details.
11 #
11 #
12 # You should have received a copy of the GNU Affero General Public License
12 # You should have received a copy of the GNU Affero General Public License
13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 #
14 #
15 # This program is dual-licensed. If you wish to learn more about the
15 # This program is dual-licensed. If you wish to learn more about the
16 # RhodeCode Enterprise Edition, including its added features, Support services,
16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 # and proprietary license terms, please see https://rhodecode.com/licenses/
17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18
18
19
19
20 from rhodecode.translation import lazy_ugettext
20 from rhodecode.translation import lazy_ugettext
21 from rhodecode.events.repo import RepoEvent
21 from rhodecode.events.repo import RepoEvent
22
22
23
23
24 class PullRequestEvent(RepoEvent):
24 class PullRequestEvent(RepoEvent):
25 """
25 """
26 Base class for pull request events.
26 Base class for pull request events.
27
27
28 :param pullrequest: a :class:`PullRequest` instance
28 :param pullrequest: a :class:`PullRequest` instance
29 """
29 """
30
30
31 def __init__(self, pullrequest):
31 def __init__(self, pullrequest):
32 super(PullRequestEvent, self).__init__(pullrequest.target_repo)
32 super(PullRequestEvent, self).__init__(pullrequest.target_repo)
33 self.pullrequest = pullrequest
33 self.pullrequest = pullrequest
34
34
35 def as_dict(self):
35 def as_dict(self):
36 from rhodecode.model.pull_request import PullRequestModel
36 from rhodecode.model.pull_request import PullRequestModel
37 data = super(PullRequestEvent, self).as_dict()
37 data = super(PullRequestEvent, self).as_dict()
38
38
39 commits = self._commits_as_dict(self.pullrequest.revisions)
39 commits = self._commits_as_dict(self.pullrequest.revisions)
40 issues = self._issues_as_dict(commits)
40 issues = self._issues_as_dict(commits)
41
41
42 data.update({
42 data.update({
43 'pullrequest': {
43 'pullrequest': {
44 'title': self.pullrequest.title,
44 'title': self.pullrequest.title,
45 'issues': issues,
45 'issues': issues,
46 'pull_request_id': self.pullrequest.pull_request_id,
46 'pull_request_id': self.pullrequest.pull_request_id,
47 'url': PullRequestModel().get_url(self.pullrequest)
47 'url': PullRequestModel().get_url(self.pullrequest),
48 'status': self.pullrequest.calculated_review_status(),
48 }
49 }
49 })
50 })
50 return data
51 return data
51
52
52
53
53 class PullRequestCreateEvent(PullRequestEvent):
54 class PullRequestCreateEvent(PullRequestEvent):
54 """
55 """
55 An instance of this class is emitted as an :term:`event` after a pull
56 An instance of this class is emitted as an :term:`event` after a pull
56 request is created.
57 request is created.
57 """
58 """
58 name = 'pullrequest-create'
59 name = 'pullrequest-create'
59 display_name = lazy_ugettext('pullrequest created')
60 display_name = lazy_ugettext('pullrequest created')
60
61
61
62
62 class PullRequestCloseEvent(PullRequestEvent):
63 class PullRequestCloseEvent(PullRequestEvent):
63 """
64 """
64 An instance of this class is emitted as an :term:`event` after a pull
65 An instance of this class is emitted as an :term:`event` after a pull
65 request is closed.
66 request is closed.
66 """
67 """
67 name = 'pullrequest-close'
68 name = 'pullrequest-close'
68 display_name = lazy_ugettext('pullrequest closed')
69 display_name = lazy_ugettext('pullrequest closed')
69
70
70
71
71 class PullRequestUpdateEvent(PullRequestEvent):
72 class PullRequestUpdateEvent(PullRequestEvent):
72 """
73 """
73 An instance of this class is emitted as an :term:`event` after a pull
74 An instance of this class is emitted as an :term:`event` after a pull
74 request is updated.
75 request's commits have been updated.
75 """
76 """
76 name = 'pullrequest-update'
77 name = 'pullrequest-update'
77 display_name = lazy_ugettext('pullrequest updated')
78 display_name = lazy_ugettext('pullrequest commits updated')
79
80
81 class PullRequestReviewEvent(PullRequestEvent):
82 """
83 An instance of this class is emitted as an :term:`event` after a pull
84 request review has changed.
85 """
86 name = 'pullrequest-review'
87 display_name = lazy_ugettext('pullrequest review changed')
78
88
79
89
80 class PullRequestMergeEvent(PullRequestEvent):
90 class PullRequestMergeEvent(PullRequestEvent):
81 """
91 """
82 An instance of this class is emitted as an :term:`event` after a pull
92 An instance of this class is emitted as an :term:`event` after a pull
83 request is merged.
93 request is merged.
84 """
94 """
85 name = 'pullrequest-merge'
95 name = 'pullrequest-merge'
86 display_name = lazy_ugettext('pullrequest merged')
96 display_name = lazy_ugettext('pullrequest merged')
87
97
88
98
89 class PullRequestReviewEvent(PullRequestEvent):
99 class PullRequestCommentEvent(PullRequestEvent):
90 """
100 """
91 An instance of this class is emitted as an :term:`event` after a pull
101 An instance of this class is emitted as an :term:`event` after a pull
92 request is reviewed.
102 request comment is created.
93 """
103 """
94 name = 'pullrequest-review'
104 name = 'pullrequest-comment'
95 display_name = lazy_ugettext('pullrequest reviewed')
105 display_name = lazy_ugettext('pullrequest commented')
106
107 def __init__(self, pullrequest, comment):
108 super(PullRequestCommentEvent, self).__init__(pullrequest)
109 self.comment = comment
110
111 def as_dict(self):
112 from rhodecode.model.comment import ChangesetCommentsModel
113 data = super(PullRequestCommentEvent, self).as_dict()
96
114
115 status = None
116 if self.comment.status_change:
117 status = self.comment.status_change[0].status
97
118
119 data.update({
120 'comment': {
121 'status': status,
122 'text': self.comment.text,
123 'url': ChangesetCommentsModel().get_url(self.comment)
124 }
125 })
126 return data
@@ -1,201 +1,246 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2012-2016 RhodeCode GmbH
3 # Copyright (C) 2012-2016 RhodeCode GmbH
4 #
4 #
5 # This program is free software: you can redistribute it and/or modify
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
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
7 # (only), as published by the Free Software Foundation.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU Affero General Public License
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/>.
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
16 #
17 # This program is dual-licensed. If you wish to learn more about the
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
20
21 from __future__ import unicode_literals
21 from __future__ import unicode_literals
22
22
23 import re
23 import re
24 import logging
24 import logging
25 import requests
25 import requests
26 import colander
26 import colander
27 import textwrap
27 from celery.task import task
28 from celery.task import task
28 from mako.template import Template
29 from mako.template import Template
29
30
30 from rhodecode import events
31 from rhodecode import events
31 from rhodecode.translation import lazy_ugettext
32 from rhodecode.translation import lazy_ugettext
32 from rhodecode.lib import helpers as h
33 from rhodecode.lib import helpers as h
33 from rhodecode.lib.celerylib import run_task
34 from rhodecode.lib.celerylib import run_task
34 from rhodecode.lib.colander_utils import strip_whitespace
35 from rhodecode.lib.colander_utils import strip_whitespace
35 from rhodecode.integrations.types.base import IntegrationTypeBase
36 from rhodecode.integrations.types.base import IntegrationTypeBase
36 from rhodecode.integrations.schema import IntegrationSettingsSchemaBase
37 from rhodecode.integrations.schema import IntegrationSettingsSchemaBase
37
38
38 log = logging.getLogger()
39 log = logging.getLogger()
39
40
40
41
41 class SlackSettingsSchema(IntegrationSettingsSchemaBase):
42 class SlackSettingsSchema(IntegrationSettingsSchemaBase):
42 service = colander.SchemaNode(
43 service = colander.SchemaNode(
43 colander.String(),
44 colander.String(),
44 title=lazy_ugettext('Slack service URL'),
45 title=lazy_ugettext('Slack service URL'),
45 description=h.literal(lazy_ugettext(
46 description=h.literal(lazy_ugettext(
46 'This can be setup at the '
47 'This can be setup at the '
47 '<a href="https://my.slack.com/services/new/incoming-webhook/">'
48 '<a href="https://my.slack.com/services/new/incoming-webhook/">'
48 'slack app manager</a>')),
49 'slack app manager</a>')),
49 default='',
50 default='',
50 placeholder='https://hooks.slack.com/services/...',
51 placeholder='https://hooks.slack.com/services/...',
51 preparer=strip_whitespace,
52 preparer=strip_whitespace,
52 validator=colander.url,
53 validator=colander.url,
53 widget='string'
54 widget='string'
54 )
55 )
55 username = colander.SchemaNode(
56 username = colander.SchemaNode(
56 colander.String(),
57 colander.String(),
57 title=lazy_ugettext('Username'),
58 title=lazy_ugettext('Username'),
58 description=lazy_ugettext('Username to show notifications coming from.'),
59 description=lazy_ugettext('Username to show notifications coming from.'),
59 missing='Rhodecode',
60 missing='Rhodecode',
60 preparer=strip_whitespace,
61 preparer=strip_whitespace,
61 widget='string',
62 widget='string',
62 placeholder='Rhodecode'
63 placeholder='Rhodecode'
63 )
64 )
64 channel = colander.SchemaNode(
65 channel = colander.SchemaNode(
65 colander.String(),
66 colander.String(),
66 title=lazy_ugettext('Channel'),
67 title=lazy_ugettext('Channel'),
67 description=lazy_ugettext('Channel to send notifications to.'),
68 description=lazy_ugettext('Channel to send notifications to.'),
68 missing='',
69 missing='',
69 preparer=strip_whitespace,
70 preparer=strip_whitespace,
70 widget='string',
71 widget='string',
71 placeholder='#general'
72 placeholder='#general'
72 )
73 )
73 icon_emoji = colander.SchemaNode(
74 icon_emoji = colander.SchemaNode(
74 colander.String(),
75 colander.String(),
75 title=lazy_ugettext('Emoji'),
76 title=lazy_ugettext('Emoji'),
76 description=lazy_ugettext('Emoji to use eg. :studio_microphone:'),
77 description=lazy_ugettext('Emoji to use eg. :studio_microphone:'),
77 missing='',
78 missing='',
78 preparer=strip_whitespace,
79 preparer=strip_whitespace,
79 widget='string',
80 widget='string',
80 placeholder=':studio_microphone:'
81 placeholder=':studio_microphone:'
81 )
82 )
82
83
83
84
84 repo_push_template = Template(r'''
85 repo_push_template = Template(r'''
85 *${data['actor']['username']}* pushed to \
86 *${data['actor']['username']}* pushed to \
86 %if data['push']['branches']:
87 %if data['push']['branches']:
87 ${len(data['push']['branches']) > 1 and 'branches' or 'branch'} \
88 ${len(data['push']['branches']) > 1 and 'branches' or 'branch'} \
88 ${', '.join('<%s|%s>' % (branch['url'], branch['name']) for branch in data['push']['branches'])} \
89 ${', '.join('<%s|%s>' % (branch['url'], branch['name']) for branch in data['push']['branches'])} \
89 %else:
90 %else:
90 unknown branch \
91 unknown branch \
91 %endif
92 %endif
92 in <${data['repo']['url']}|${data['repo']['repo_name']}>
93 in <${data['repo']['url']}|${data['repo']['repo_name']}>
93 >>>
94 >>>
94 %for commit in data['push']['commits']:
95 %for commit in data['push']['commits']:
95 <${commit['url']}|${commit['short_id']}> - ${commit['message_html']|html_to_slack_links}
96 <${commit['url']}|${commit['short_id']}> - ${commit['message_html']|html_to_slack_links}
96 %endfor
97 %endfor
97 ''')
98 ''')
98
99
99
100
100 class SlackIntegrationType(IntegrationTypeBase):
101 class SlackIntegrationType(IntegrationTypeBase):
101 key = 'slack'
102 key = 'slack'
102 display_name = lazy_ugettext('Slack')
103 display_name = lazy_ugettext('Slack')
103 SettingsSchema = SlackSettingsSchema
104 SettingsSchema = SlackSettingsSchema
104 valid_events = [
105 valid_events = [
105 events.PullRequestCloseEvent,
106 events.PullRequestCloseEvent,
106 events.PullRequestMergeEvent,
107 events.PullRequestMergeEvent,
107 events.PullRequestUpdateEvent,
108 events.PullRequestUpdateEvent,
109 events.PullRequestCommentEvent,
108 events.PullRequestReviewEvent,
110 events.PullRequestReviewEvent,
109 events.PullRequestCreateEvent,
111 events.PullRequestCreateEvent,
110 events.RepoPushEvent,
112 events.RepoPushEvent,
111 events.RepoCreateEvent,
113 events.RepoCreateEvent,
112 ]
114 ]
113
115
114 def send_event(self, event):
116 def send_event(self, event):
115 if event.__class__ not in self.valid_events:
117 if event.__class__ not in self.valid_events:
116 log.debug('event not valid: %r' % event)
118 log.debug('event not valid: %r' % event)
117 return
119 return
118
120
119 if event.name not in self.settings['events']:
121 if event.name not in self.settings['events']:
120 log.debug('event ignored: %r' % event)
122 log.debug('event ignored: %r' % event)
121 return
123 return
122
124
123 data = event.as_dict()
125 data = event.as_dict()
124
126
125 text = '*%s* caused a *%s* event' % (
127 text = '*%s* caused a *%s* event' % (
126 data['actor']['username'], event.name)
128 data['actor']['username'], event.name)
127
129
128 log.debug('handling slack event for %s' % event.name)
130 log.debug('handling slack event for %s' % event.name)
129
131
130 if isinstance(event, events.PullRequestEvent):
132 if isinstance(event, events.PullRequestCommentEvent):
133 text = self.format_pull_request_comment_event(event, data)
134 elif isinstance(event, events.PullRequestReviewEvent):
135 text = self.format_pull_request_review_event(event, data)
136 elif isinstance(event, events.PullRequestEvent):
131 text = self.format_pull_request_event(event, data)
137 text = self.format_pull_request_event(event, data)
132 elif isinstance(event, events.RepoPushEvent):
138 elif isinstance(event, events.RepoPushEvent):
133 text = self.format_repo_push_event(data)
139 text = self.format_repo_push_event(data)
134 elif isinstance(event, events.RepoCreateEvent):
140 elif isinstance(event, events.RepoCreateEvent):
135 text = self.format_repo_create_event(data)
141 text = self.format_repo_create_event(data)
136 else:
142 else:
137 log.error('unhandled event type: %r' % event)
143 log.error('unhandled event type: %r' % event)
138
144
139 run_task(post_text_to_slack, self.settings, text)
145 run_task(post_text_to_slack, self.settings, text)
140
146
141 @classmethod
147 @classmethod
142 def settings_schema(cls):
148 def settings_schema(cls):
143 schema = SlackSettingsSchema()
149 schema = SlackSettingsSchema()
144 schema.add(colander.SchemaNode(
150 schema.add(colander.SchemaNode(
145 colander.Set(),
151 colander.Set(),
146 widget='checkbox_list',
152 widget='checkbox_list',
147 choices=sorted([e.name for e in cls.valid_events]),
153 choices=sorted([e.name for e in cls.valid_events]),
148 description="Events activated for this integration",
154 description="Events activated for this integration",
149 name='events'
155 name='events'
150 ))
156 ))
151 return schema
157 return schema
152
158
159 def format_pull_request_comment_event(self, event, data):
160 comment_text = data['comment']['text']
161 if len(comment_text) > 200:
162 comment_text = '<{comment_url}|{comment_text}...>'.format(
163 comment_text=comment_text[:200],
164 comment_url=data['comment']['url'],
165 )
166
167 comment_status = ''
168 if data['comment']['status']:
169 comment_status = '[{}]: '.format(data['comment']['status'])
170
171 return (textwrap.dedent(
172 '''
173 {user} commented on pull request <{pr_url}|#{number}> - {pr_title}:
174 >>> {comment_status}{comment_text}
175 ''').format(
176 comment_status=comment_status,
177 user=data['actor']['username'],
178 number=data['pullrequest']['pull_request_id'],
179 pr_url=data['pullrequest']['url'],
180 pr_status=data['pullrequest']['status'],
181 pr_title=data['pullrequest']['title'],
182 comment_text=comment_text
183 )
184 )
185
186 def format_pull_request_review_event(self, event, data):
187 return (textwrap.dedent(
188 '''
189 Status changed to {pr_status} for pull request <{pr_url}|#{number}> - {pr_title}
190 ''').format(
191 user=data['actor']['username'],
192 number=data['pullrequest']['pull_request_id'],
193 pr_url=data['pullrequest']['url'],
194 pr_status=data['pullrequest']['status'],
195 pr_title=data['pullrequest']['title'],
196 )
197 )
198
153 def format_pull_request_event(self, event, data):
199 def format_pull_request_event(self, event, data):
154 action = {
200 action = {
155 events.PullRequestCloseEvent: 'closed',
201 events.PullRequestCloseEvent: 'closed',
156 events.PullRequestMergeEvent: 'merged',
202 events.PullRequestMergeEvent: 'merged',
157 events.PullRequestUpdateEvent: 'updated',
203 events.PullRequestUpdateEvent: 'updated',
158 events.PullRequestReviewEvent: 'reviewed',
159 events.PullRequestCreateEvent: 'created',
204 events.PullRequestCreateEvent: 'created',
160 }.get(event.__class__, '<unknown action>')
205 }.get(event.__class__, str(event.__class__))
161
206
162 return ('Pull request <{url}|#{number}> ({title}) '
207 return ('Pull request <{url}|#{number}> - {title} '
163 '{action} by {user}').format(
208 '{action} by {user}').format(
164 user=data['actor']['username'],
209 user=data['actor']['username'],
165 number=data['pullrequest']['pull_request_id'],
210 number=data['pullrequest']['pull_request_id'],
166 url=data['pullrequest']['url'],
211 url=data['pullrequest']['url'],
167 title=data['pullrequest']['title'],
212 title=data['pullrequest']['title'],
168 action=action
213 action=action
169 )
214 )
170
215
171 def format_repo_push_event(self, data):
216 def format_repo_push_event(self, data):
172 result = repo_push_template.render(
217 result = repo_push_template.render(
173 data=data,
218 data=data,
174 html_to_slack_links=html_to_slack_links,
219 html_to_slack_links=html_to_slack_links,
175 )
220 )
176 return result
221 return result
177
222
178 def format_repo_create_event(self, data):
223 def format_repo_create_event(self, data):
179 return '<{}|{}> ({}) repository created by *{}*'.format(
224 return '<{}|{}> ({}) repository created by *{}*'.format(
180 data['repo']['url'],
225 data['repo']['url'],
181 data['repo']['repo_name'],
226 data['repo']['repo_name'],
182 data['repo']['repo_type'],
227 data['repo']['repo_type'],
183 data['actor']['username'],
228 data['actor']['username'],
184 )
229 )
185
230
186
231
187 def html_to_slack_links(message):
232 def html_to_slack_links(message):
188 return re.compile(r'<a .*?href=["\'](.+?)".*?>(.+?)</a>').sub(
233 return re.compile(r'<a .*?href=["\'](.+?)".*?>(.+?)</a>').sub(
189 r'<\1|\2>', message)
234 r'<\1|\2>', message)
190
235
191
236
192 @task(ignore_result=True)
237 @task(ignore_result=True)
193 def post_text_to_slack(settings, text):
238 def post_text_to_slack(settings, text):
194 log.debug('sending %s to slack %s' % (text, settings['service']))
239 log.debug('sending %s to slack %s' % (text, settings['service']))
195 resp = requests.post(settings['service'], json={
240 resp = requests.post(settings['service'], json={
196 "channel": settings.get('channel', ''),
241 "channel": settings.get('channel', ''),
197 "username": settings.get('username', 'Rhodecode'),
242 "username": settings.get('username', 'Rhodecode'),
198 "text": text,
243 "text": text,
199 "icon_emoji": settings.get('icon_emoji', ':studio_microphone:')
244 "icon_emoji": settings.get('icon_emoji', ':studio_microphone:')
200 })
245 })
201 resp.raise_for_status() # raise exception on a failed request
246 resp.raise_for_status() # raise exception on a failed request
@@ -1,459 +1,471 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2011-2016 RhodeCode GmbH
3 # Copyright (C) 2011-2016 RhodeCode GmbH
4 #
4 #
5 # This program is free software: you can redistribute it and/or modify
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
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
7 # (only), as published by the Free Software Foundation.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU Affero General Public License
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/>.
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
16 #
17 # This program is dual-licensed. If you wish to learn more about the
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
20
21 """
21 """
22 comments model for RhodeCode
22 comments model for RhodeCode
23 """
23 """
24
24
25 import logging
25 import logging
26 import traceback
26 import traceback
27 import collections
27 import collections
28
28
29 from sqlalchemy.sql.expression import null
29 from sqlalchemy.sql.expression import null
30 from sqlalchemy.sql.functions import coalesce
30 from sqlalchemy.sql.functions import coalesce
31
31
32 from rhodecode.lib import helpers as h, diffs
32 from rhodecode.lib import helpers as h, diffs
33 from rhodecode.lib.utils import action_logger
33 from rhodecode.lib.utils import action_logger
34 from rhodecode.lib.utils2 import extract_mentioned_users
34 from rhodecode.lib.utils2 import extract_mentioned_users
35 from rhodecode.model import BaseModel
35 from rhodecode.model import BaseModel
36 from rhodecode.model.db import (
36 from rhodecode.model.db import (
37 ChangesetComment, User, Notification, PullRequest)
37 ChangesetComment, User, Notification, PullRequest)
38 from rhodecode.model.notification import NotificationModel
38 from rhodecode.model.notification import NotificationModel
39 from rhodecode.model.meta import Session
39 from rhodecode.model.meta import Session
40 from rhodecode.model.settings import VcsSettingsModel
40 from rhodecode.model.settings import VcsSettingsModel
41 from rhodecode.model.notification import EmailNotificationModel
41 from rhodecode.model.notification import EmailNotificationModel
42
42
43 log = logging.getLogger(__name__)
43 log = logging.getLogger(__name__)
44
44
45
45
46 class ChangesetCommentsModel(BaseModel):
46 class ChangesetCommentsModel(BaseModel):
47
47
48 cls = ChangesetComment
48 cls = ChangesetComment
49
49
50 DIFF_CONTEXT_BEFORE = 3
50 DIFF_CONTEXT_BEFORE = 3
51 DIFF_CONTEXT_AFTER = 3
51 DIFF_CONTEXT_AFTER = 3
52
52
53 def __get_commit_comment(self, changeset_comment):
53 def __get_commit_comment(self, changeset_comment):
54 return self._get_instance(ChangesetComment, changeset_comment)
54 return self._get_instance(ChangesetComment, changeset_comment)
55
55
56 def __get_pull_request(self, pull_request):
56 def __get_pull_request(self, pull_request):
57 return self._get_instance(PullRequest, pull_request)
57 return self._get_instance(PullRequest, pull_request)
58
58
59 def _extract_mentions(self, s):
59 def _extract_mentions(self, s):
60 user_objects = []
60 user_objects = []
61 for username in extract_mentioned_users(s):
61 for username in extract_mentioned_users(s):
62 user_obj = User.get_by_username(username, case_insensitive=True)
62 user_obj = User.get_by_username(username, case_insensitive=True)
63 if user_obj:
63 if user_obj:
64 user_objects.append(user_obj)
64 user_objects.append(user_obj)
65 return user_objects
65 return user_objects
66
66
67 def _get_renderer(self, global_renderer='rst'):
67 def _get_renderer(self, global_renderer='rst'):
68 try:
68 try:
69 # try reading from visual context
69 # try reading from visual context
70 from pylons import tmpl_context
70 from pylons import tmpl_context
71 global_renderer = tmpl_context.visual.default_renderer
71 global_renderer = tmpl_context.visual.default_renderer
72 except AttributeError:
72 except AttributeError:
73 log.debug("Renderer not set, falling back "
73 log.debug("Renderer not set, falling back "
74 "to default renderer '%s'", global_renderer)
74 "to default renderer '%s'", global_renderer)
75 except Exception:
75 except Exception:
76 log.error(traceback.format_exc())
76 log.error(traceback.format_exc())
77 return global_renderer
77 return global_renderer
78
78
79 def create(self, text, repo, user, revision=None, pull_request=None,
79 def create(self, text, repo, user, revision=None, pull_request=None,
80 f_path=None, line_no=None, status_change=None, closing_pr=False,
80 f_path=None, line_no=None, status_change=None, closing_pr=False,
81 send_email=True, renderer=None):
81 send_email=True, renderer=None):
82 """
82 """
83 Creates new comment for commit or pull request.
83 Creates new comment for commit or pull request.
84 IF status_change is not none this comment is associated with a
84 IF status_change is not none this comment is associated with a
85 status change of commit or commit associated with pull request
85 status change of commit or commit associated with pull request
86
86
87 :param text:
87 :param text:
88 :param repo:
88 :param repo:
89 :param user:
89 :param user:
90 :param revision:
90 :param revision:
91 :param pull_request:
91 :param pull_request:
92 :param f_path:
92 :param f_path:
93 :param line_no:
93 :param line_no:
94 :param status_change:
94 :param status_change:
95 :param closing_pr:
95 :param closing_pr:
96 :param send_email:
96 :param send_email:
97 """
97 """
98 if not text:
98 if not text:
99 log.warning('Missing text for comment, skipping...')
99 log.warning('Missing text for comment, skipping...')
100 return
100 return
101
101
102 if not renderer:
102 if not renderer:
103 renderer = self._get_renderer()
103 renderer = self._get_renderer()
104
104
105 repo = self._get_repo(repo)
105 repo = self._get_repo(repo)
106 user = self._get_user(user)
106 user = self._get_user(user)
107 comment = ChangesetComment()
107 comment = ChangesetComment()
108 comment.renderer = renderer
108 comment.renderer = renderer
109 comment.repo = repo
109 comment.repo = repo
110 comment.author = user
110 comment.author = user
111 comment.text = text
111 comment.text = text
112 comment.f_path = f_path
112 comment.f_path = f_path
113 comment.line_no = line_no
113 comment.line_no = line_no
114
114
115 #TODO (marcink): fix this and remove revision as param
115 #TODO (marcink): fix this and remove revision as param
116 commit_id = revision
116 commit_id = revision
117 pull_request_id = pull_request
117 pull_request_id = pull_request
118
118
119 commit_obj = None
119 commit_obj = None
120 pull_request_obj = None
120 pull_request_obj = None
121
121
122 if commit_id:
122 if commit_id:
123 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
123 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
124 # do a lookup, so we don't pass something bad here
124 # do a lookup, so we don't pass something bad here
125 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
125 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
126 comment.revision = commit_obj.raw_id
126 comment.revision = commit_obj.raw_id
127
127
128 elif pull_request_id:
128 elif pull_request_id:
129 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
129 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
130 pull_request_obj = self.__get_pull_request(pull_request_id)
130 pull_request_obj = self.__get_pull_request(pull_request_id)
131 comment.pull_request = pull_request_obj
131 comment.pull_request = pull_request_obj
132 else:
132 else:
133 raise Exception('Please specify commit or pull_request_id')
133 raise Exception('Please specify commit or pull_request_id')
134
134
135 Session().add(comment)
135 Session().add(comment)
136 Session().flush()
136 Session().flush()
137
137
138 if send_email:
138 if send_email:
139 kwargs = {
139 kwargs = {
140 'user': user,
140 'user': user,
141 'renderer_type': renderer,
141 'renderer_type': renderer,
142 'repo_name': repo.repo_name,
142 'repo_name': repo.repo_name,
143 'status_change': status_change,
143 'status_change': status_change,
144 'comment_body': text,
144 'comment_body': text,
145 'comment_file': f_path,
145 'comment_file': f_path,
146 'comment_line': line_no,
146 'comment_line': line_no,
147 }
147 }
148
148
149 if commit_obj:
149 if commit_obj:
150 recipients = ChangesetComment.get_users(
150 recipients = ChangesetComment.get_users(
151 revision=commit_obj.raw_id)
151 revision=commit_obj.raw_id)
152 # add commit author if it's in RhodeCode system
152 # add commit author if it's in RhodeCode system
153 cs_author = User.get_from_cs_author(commit_obj.author)
153 cs_author = User.get_from_cs_author(commit_obj.author)
154 if not cs_author:
154 if not cs_author:
155 # use repo owner if we cannot extract the author correctly
155 # use repo owner if we cannot extract the author correctly
156 cs_author = repo.user
156 cs_author = repo.user
157 recipients += [cs_author]
157 recipients += [cs_author]
158
158
159 commit_comment_url = h.url(
159 commit_comment_url = self.get_url(comment)
160 'changeset_home',
161 repo_name=repo.repo_name,
162 revision=commit_obj.raw_id,
163 anchor='comment-%s' % comment.comment_id,
164 qualified=True,)
165
160
166 target_repo_url = h.link_to(
161 target_repo_url = h.link_to(
167 repo.repo_name,
162 repo.repo_name,
168 h.url('summary_home',
163 h.url('summary_home',
169 repo_name=repo.repo_name, qualified=True))
164 repo_name=repo.repo_name, qualified=True))
170
165
171 # commit specifics
166 # commit specifics
172 kwargs.update({
167 kwargs.update({
173 'commit': commit_obj,
168 'commit': commit_obj,
174 'commit_message': commit_obj.message,
169 'commit_message': commit_obj.message,
175 'commit_target_repo': target_repo_url,
170 'commit_target_repo': target_repo_url,
176 'commit_comment_url': commit_comment_url,
171 'commit_comment_url': commit_comment_url,
177 })
172 })
178
173
179 elif pull_request_obj:
174 elif pull_request_obj:
180 # get the current participants of this pull request
175 # get the current participants of this pull request
181 recipients = ChangesetComment.get_users(
176 recipients = ChangesetComment.get_users(
182 pull_request_id=pull_request_obj.pull_request_id)
177 pull_request_id=pull_request_obj.pull_request_id)
183 # add pull request author
178 # add pull request author
184 recipients += [pull_request_obj.author]
179 recipients += [pull_request_obj.author]
185
180
186 # add the reviewers to notification
181 # add the reviewers to notification
187 recipients += [x.user for x in pull_request_obj.reviewers]
182 recipients += [x.user for x in pull_request_obj.reviewers]
188
183
189 pr_target_repo = pull_request_obj.target_repo
184 pr_target_repo = pull_request_obj.target_repo
190 pr_source_repo = pull_request_obj.source_repo
185 pr_source_repo = pull_request_obj.source_repo
191
186
192 pr_comment_url = h.url(
187 pr_comment_url = h.url(
193 'pullrequest_show',
188 'pullrequest_show',
194 repo_name=pr_target_repo.repo_name,
189 repo_name=pr_target_repo.repo_name,
195 pull_request_id=pull_request_obj.pull_request_id,
190 pull_request_id=pull_request_obj.pull_request_id,
196 anchor='comment-%s' % comment.comment_id,
191 anchor='comment-%s' % comment.comment_id,
197 qualified=True,)
192 qualified=True,)
198
193
199 # set some variables for email notification
194 # set some variables for email notification
200 pr_target_repo_url = h.url(
195 pr_target_repo_url = h.url(
201 'summary_home', repo_name=pr_target_repo.repo_name,
196 'summary_home', repo_name=pr_target_repo.repo_name,
202 qualified=True)
197 qualified=True)
203
198
204 pr_source_repo_url = h.url(
199 pr_source_repo_url = h.url(
205 'summary_home', repo_name=pr_source_repo.repo_name,
200 'summary_home', repo_name=pr_source_repo.repo_name,
206 qualified=True)
201 qualified=True)
207
202
208 # pull request specifics
203 # pull request specifics
209 kwargs.update({
204 kwargs.update({
210 'pull_request': pull_request_obj,
205 'pull_request': pull_request_obj,
211 'pr_id': pull_request_obj.pull_request_id,
206 'pr_id': pull_request_obj.pull_request_id,
212 'pr_target_repo': pr_target_repo,
207 'pr_target_repo': pr_target_repo,
213 'pr_target_repo_url': pr_target_repo_url,
208 'pr_target_repo_url': pr_target_repo_url,
214 'pr_source_repo': pr_source_repo,
209 'pr_source_repo': pr_source_repo,
215 'pr_source_repo_url': pr_source_repo_url,
210 'pr_source_repo_url': pr_source_repo_url,
216 'pr_comment_url': pr_comment_url,
211 'pr_comment_url': pr_comment_url,
217 'pr_closing': closing_pr,
212 'pr_closing': closing_pr,
218 })
213 })
219
214
220 # pre-generate the subject for notification itself
215 # pre-generate the subject for notification itself
221 (subject,
216 (subject,
222 _h, _e, # we don't care about those
217 _h, _e, # we don't care about those
223 body_plaintext) = EmailNotificationModel().render_email(
218 body_plaintext) = EmailNotificationModel().render_email(
224 notification_type, **kwargs)
219 notification_type, **kwargs)
225
220
226 mention_recipients = set(
221 mention_recipients = set(
227 self._extract_mentions(text)).difference(recipients)
222 self._extract_mentions(text)).difference(recipients)
228
223
229 # create notification objects, and emails
224 # create notification objects, and emails
230 NotificationModel().create(
225 NotificationModel().create(
231 created_by=user,
226 created_by=user,
232 notification_subject=subject,
227 notification_subject=subject,
233 notification_body=body_plaintext,
228 notification_body=body_plaintext,
234 notification_type=notification_type,
229 notification_type=notification_type,
235 recipients=recipients,
230 recipients=recipients,
236 mention_recipients=mention_recipients,
231 mention_recipients=mention_recipients,
237 email_kwargs=kwargs,
232 email_kwargs=kwargs,
238 )
233 )
239
234
240 action = (
235 action = (
241 'user_commented_pull_request:{}'.format(
236 'user_commented_pull_request:{}'.format(
242 comment.pull_request.pull_request_id)
237 comment.pull_request.pull_request_id)
243 if comment.pull_request
238 if comment.pull_request
244 else 'user_commented_revision:{}'.format(comment.revision)
239 else 'user_commented_revision:{}'.format(comment.revision)
245 )
240 )
246 action_logger(user, action, comment.repo)
241 action_logger(user, action, comment.repo)
247
242
248 return comment
243 return comment
249
244
250 def delete(self, comment):
245 def delete(self, comment):
251 """
246 """
252 Deletes given comment
247 Deletes given comment
253
248
254 :param comment_id:
249 :param comment_id:
255 """
250 """
256 comment = self.__get_commit_comment(comment)
251 comment = self.__get_commit_comment(comment)
257 Session().delete(comment)
252 Session().delete(comment)
258
253
259 return comment
254 return comment
260
255
261 def get_all_comments(self, repo_id, revision=None, pull_request=None):
256 def get_all_comments(self, repo_id, revision=None, pull_request=None):
262 q = ChangesetComment.query()\
257 q = ChangesetComment.query()\
263 .filter(ChangesetComment.repo_id == repo_id)
258 .filter(ChangesetComment.repo_id == repo_id)
264 if revision:
259 if revision:
265 q = q.filter(ChangesetComment.revision == revision)
260 q = q.filter(ChangesetComment.revision == revision)
266 elif pull_request:
261 elif pull_request:
267 pull_request = self.__get_pull_request(pull_request)
262 pull_request = self.__get_pull_request(pull_request)
268 q = q.filter(ChangesetComment.pull_request == pull_request)
263 q = q.filter(ChangesetComment.pull_request == pull_request)
269 else:
264 else:
270 raise Exception('Please specify commit or pull_request')
265 raise Exception('Please specify commit or pull_request')
271 q = q.order_by(ChangesetComment.created_on)
266 q = q.order_by(ChangesetComment.created_on)
272 return q.all()
267 return q.all()
273
268
269 def get_url(self, comment):
270 comment = self.__get_commit_comment(comment)
271 if comment.pull_request:
272 return h.url(
273 'pullrequest_show',
274 repo_name=comment.pull_request.target_repo.repo_name,
275 pull_request_id=comment.pull_request.pull_request_id,
276 anchor='comment-%s' % comment.comment_id,
277 qualified=True,)
278 else:
279 return h.url(
280 'changeset_home',
281 repo_name=comment.repo.repo_name,
282 revision=comment.revision,
283 anchor='comment-%s' % comment.comment_id,
284 qualified=True,)
285
274 def get_comments(self, repo_id, revision=None, pull_request=None):
286 def get_comments(self, repo_id, revision=None, pull_request=None):
275 """
287 """
276 Gets main comments based on revision or pull_request_id
288 Gets main comments based on revision or pull_request_id
277
289
278 :param repo_id:
290 :param repo_id:
279 :param revision:
291 :param revision:
280 :param pull_request:
292 :param pull_request:
281 """
293 """
282
294
283 q = ChangesetComment.query()\
295 q = ChangesetComment.query()\
284 .filter(ChangesetComment.repo_id == repo_id)\
296 .filter(ChangesetComment.repo_id == repo_id)\
285 .filter(ChangesetComment.line_no == None)\
297 .filter(ChangesetComment.line_no == None)\
286 .filter(ChangesetComment.f_path == None)
298 .filter(ChangesetComment.f_path == None)
287 if revision:
299 if revision:
288 q = q.filter(ChangesetComment.revision == revision)
300 q = q.filter(ChangesetComment.revision == revision)
289 elif pull_request:
301 elif pull_request:
290 pull_request = self.__get_pull_request(pull_request)
302 pull_request = self.__get_pull_request(pull_request)
291 q = q.filter(ChangesetComment.pull_request == pull_request)
303 q = q.filter(ChangesetComment.pull_request == pull_request)
292 else:
304 else:
293 raise Exception('Please specify commit or pull_request')
305 raise Exception('Please specify commit or pull_request')
294 q = q.order_by(ChangesetComment.created_on)
306 q = q.order_by(ChangesetComment.created_on)
295 return q.all()
307 return q.all()
296
308
297 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
309 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
298 q = self._get_inline_comments_query(repo_id, revision, pull_request)
310 q = self._get_inline_comments_query(repo_id, revision, pull_request)
299 return self._group_comments_by_path_and_line_number(q)
311 return self._group_comments_by_path_and_line_number(q)
300
312
301 def get_outdated_comments(self, repo_id, pull_request):
313 def get_outdated_comments(self, repo_id, pull_request):
302 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
314 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
303 # of a pull request.
315 # of a pull request.
304 q = self._all_inline_comments_of_pull_request(pull_request)
316 q = self._all_inline_comments_of_pull_request(pull_request)
305 q = q.filter(
317 q = q.filter(
306 ChangesetComment.display_state ==
318 ChangesetComment.display_state ==
307 ChangesetComment.COMMENT_OUTDATED
319 ChangesetComment.COMMENT_OUTDATED
308 ).order_by(ChangesetComment.comment_id.asc())
320 ).order_by(ChangesetComment.comment_id.asc())
309
321
310 return self._group_comments_by_path_and_line_number(q)
322 return self._group_comments_by_path_and_line_number(q)
311
323
312 def _get_inline_comments_query(self, repo_id, revision, pull_request):
324 def _get_inline_comments_query(self, repo_id, revision, pull_request):
313 # TODO: johbo: Split this into two methods: One for PR and one for
325 # TODO: johbo: Split this into two methods: One for PR and one for
314 # commit.
326 # commit.
315 if revision:
327 if revision:
316 q = Session().query(ChangesetComment).filter(
328 q = Session().query(ChangesetComment).filter(
317 ChangesetComment.repo_id == repo_id,
329 ChangesetComment.repo_id == repo_id,
318 ChangesetComment.line_no != null(),
330 ChangesetComment.line_no != null(),
319 ChangesetComment.f_path != null(),
331 ChangesetComment.f_path != null(),
320 ChangesetComment.revision == revision)
332 ChangesetComment.revision == revision)
321
333
322 elif pull_request:
334 elif pull_request:
323 pull_request = self.__get_pull_request(pull_request)
335 pull_request = self.__get_pull_request(pull_request)
324 if ChangesetCommentsModel.use_outdated_comments(pull_request):
336 if ChangesetCommentsModel.use_outdated_comments(pull_request):
325 q = self._visible_inline_comments_of_pull_request(pull_request)
337 q = self._visible_inline_comments_of_pull_request(pull_request)
326 else:
338 else:
327 q = self._all_inline_comments_of_pull_request(pull_request)
339 q = self._all_inline_comments_of_pull_request(pull_request)
328
340
329 else:
341 else:
330 raise Exception('Please specify commit or pull_request_id')
342 raise Exception('Please specify commit or pull_request_id')
331 q = q.order_by(ChangesetComment.comment_id.asc())
343 q = q.order_by(ChangesetComment.comment_id.asc())
332 return q
344 return q
333
345
334 def _group_comments_by_path_and_line_number(self, q):
346 def _group_comments_by_path_and_line_number(self, q):
335 comments = q.all()
347 comments = q.all()
336 paths = collections.defaultdict(lambda: collections.defaultdict(list))
348 paths = collections.defaultdict(lambda: collections.defaultdict(list))
337 for co in comments:
349 for co in comments:
338 paths[co.f_path][co.line_no].append(co)
350 paths[co.f_path][co.line_no].append(co)
339 return paths
351 return paths
340
352
341 @classmethod
353 @classmethod
342 def needed_extra_diff_context(cls):
354 def needed_extra_diff_context(cls):
343 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
355 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
344
356
345 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
357 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
346 if not ChangesetCommentsModel.use_outdated_comments(pull_request):
358 if not ChangesetCommentsModel.use_outdated_comments(pull_request):
347 return
359 return
348
360
349 comments = self._visible_inline_comments_of_pull_request(pull_request)
361 comments = self._visible_inline_comments_of_pull_request(pull_request)
350 comments_to_outdate = comments.all()
362 comments_to_outdate = comments.all()
351
363
352 for comment in comments_to_outdate:
364 for comment in comments_to_outdate:
353 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
365 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
354
366
355 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
367 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
356 diff_line = _parse_comment_line_number(comment.line_no)
368 diff_line = _parse_comment_line_number(comment.line_no)
357
369
358 try:
370 try:
359 old_context = old_diff_proc.get_context_of_line(
371 old_context = old_diff_proc.get_context_of_line(
360 path=comment.f_path, diff_line=diff_line)
372 path=comment.f_path, diff_line=diff_line)
361 new_context = new_diff_proc.get_context_of_line(
373 new_context = new_diff_proc.get_context_of_line(
362 path=comment.f_path, diff_line=diff_line)
374 path=comment.f_path, diff_line=diff_line)
363 except (diffs.LineNotInDiffException,
375 except (diffs.LineNotInDiffException,
364 diffs.FileNotInDiffException):
376 diffs.FileNotInDiffException):
365 comment.display_state = ChangesetComment.COMMENT_OUTDATED
377 comment.display_state = ChangesetComment.COMMENT_OUTDATED
366 return
378 return
367
379
368 if old_context == new_context:
380 if old_context == new_context:
369 return
381 return
370
382
371 if self._should_relocate_diff_line(diff_line):
383 if self._should_relocate_diff_line(diff_line):
372 new_diff_lines = new_diff_proc.find_context(
384 new_diff_lines = new_diff_proc.find_context(
373 path=comment.f_path, context=old_context,
385 path=comment.f_path, context=old_context,
374 offset=self.DIFF_CONTEXT_BEFORE)
386 offset=self.DIFF_CONTEXT_BEFORE)
375 if not new_diff_lines:
387 if not new_diff_lines:
376 comment.display_state = ChangesetComment.COMMENT_OUTDATED
388 comment.display_state = ChangesetComment.COMMENT_OUTDATED
377 else:
389 else:
378 new_diff_line = self._choose_closest_diff_line(
390 new_diff_line = self._choose_closest_diff_line(
379 diff_line, new_diff_lines)
391 diff_line, new_diff_lines)
380 comment.line_no = _diff_to_comment_line_number(new_diff_line)
392 comment.line_no = _diff_to_comment_line_number(new_diff_line)
381 else:
393 else:
382 comment.display_state = ChangesetComment.COMMENT_OUTDATED
394 comment.display_state = ChangesetComment.COMMENT_OUTDATED
383
395
384 def _should_relocate_diff_line(self, diff_line):
396 def _should_relocate_diff_line(self, diff_line):
385 """
397 """
386 Checks if relocation shall be tried for the given `diff_line`.
398 Checks if relocation shall be tried for the given `diff_line`.
387
399
388 If a comment points into the first lines, then we can have a situation
400 If a comment points into the first lines, then we can have a situation
389 that after an update another line has been added on top. In this case
401 that after an update another line has been added on top. In this case
390 we would find the context still and move the comment around. This
402 we would find the context still and move the comment around. This
391 would be wrong.
403 would be wrong.
392 """
404 """
393 should_relocate = (
405 should_relocate = (
394 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
406 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
395 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
407 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
396 return should_relocate
408 return should_relocate
397
409
398 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
410 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
399 candidate = new_diff_lines[0]
411 candidate = new_diff_lines[0]
400 best_delta = _diff_line_delta(diff_line, candidate)
412 best_delta = _diff_line_delta(diff_line, candidate)
401 for new_diff_line in new_diff_lines[1:]:
413 for new_diff_line in new_diff_lines[1:]:
402 delta = _diff_line_delta(diff_line, new_diff_line)
414 delta = _diff_line_delta(diff_line, new_diff_line)
403 if delta < best_delta:
415 if delta < best_delta:
404 candidate = new_diff_line
416 candidate = new_diff_line
405 best_delta = delta
417 best_delta = delta
406 return candidate
418 return candidate
407
419
408 def _visible_inline_comments_of_pull_request(self, pull_request):
420 def _visible_inline_comments_of_pull_request(self, pull_request):
409 comments = self._all_inline_comments_of_pull_request(pull_request)
421 comments = self._all_inline_comments_of_pull_request(pull_request)
410 comments = comments.filter(
422 comments = comments.filter(
411 coalesce(ChangesetComment.display_state, '') !=
423 coalesce(ChangesetComment.display_state, '') !=
412 ChangesetComment.COMMENT_OUTDATED)
424 ChangesetComment.COMMENT_OUTDATED)
413 return comments
425 return comments
414
426
415 def _all_inline_comments_of_pull_request(self, pull_request):
427 def _all_inline_comments_of_pull_request(self, pull_request):
416 comments = Session().query(ChangesetComment)\
428 comments = Session().query(ChangesetComment)\
417 .filter(ChangesetComment.line_no != None)\
429 .filter(ChangesetComment.line_no != None)\
418 .filter(ChangesetComment.f_path != None)\
430 .filter(ChangesetComment.f_path != None)\
419 .filter(ChangesetComment.pull_request == pull_request)
431 .filter(ChangesetComment.pull_request == pull_request)
420 return comments
432 return comments
421
433
422 @staticmethod
434 @staticmethod
423 def use_outdated_comments(pull_request):
435 def use_outdated_comments(pull_request):
424 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
436 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
425 settings = settings_model.get_general_settings()
437 settings = settings_model.get_general_settings()
426 return settings.get('rhodecode_use_outdated_comments', False)
438 return settings.get('rhodecode_use_outdated_comments', False)
427
439
428
440
429 def _parse_comment_line_number(line_no):
441 def _parse_comment_line_number(line_no):
430 """
442 """
431 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
443 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
432 """
444 """
433 old_line = None
445 old_line = None
434 new_line = None
446 new_line = None
435 if line_no.startswith('o'):
447 if line_no.startswith('o'):
436 old_line = int(line_no[1:])
448 old_line = int(line_no[1:])
437 elif line_no.startswith('n'):
449 elif line_no.startswith('n'):
438 new_line = int(line_no[1:])
450 new_line = int(line_no[1:])
439 else:
451 else:
440 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
452 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
441 return diffs.DiffLineNumber(old_line, new_line)
453 return diffs.DiffLineNumber(old_line, new_line)
442
454
443
455
444 def _diff_to_comment_line_number(diff_line):
456 def _diff_to_comment_line_number(diff_line):
445 if diff_line.new is not None:
457 if diff_line.new is not None:
446 return u'n{}'.format(diff_line.new)
458 return u'n{}'.format(diff_line.new)
447 elif diff_line.old is not None:
459 elif diff_line.old is not None:
448 return u'o{}'.format(diff_line.old)
460 return u'o{}'.format(diff_line.old)
449 return u''
461 return u''
450
462
451
463
452 def _diff_line_delta(a, b):
464 def _diff_line_delta(a, b):
453 if None not in (a.new, b.new):
465 if None not in (a.new, b.new):
454 return abs(a.new - b.new)
466 return abs(a.new - b.new)
455 elif None not in (a.old, b.old):
467 elif None not in (a.old, b.old):
456 return abs(a.old - b.old)
468 return abs(a.old - b.old)
457 else:
469 else:
458 raise ValueError(
470 raise ValueError(
459 "Cannot compute delta between {} and {}".format(a, b))
471 "Cannot compute delta between {} and {}".format(a, b))
@@ -1,78 +1,93 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2010-2016 RhodeCode GmbH
3 # Copyright (C) 2010-2016 RhodeCode GmbH
4 #
4 #
5 # This program is free software: you can redistribute it and/or modify
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
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
7 # (only), as published by the Free Software Foundation.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU Affero General Public License
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/>.
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
16 #
17 # This program is dual-licensed. If you wish to learn more about the
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
20
21 import pytest
21 import pytest
22
22
23 from rhodecode.tests.events.conftest import EventCatcher
23 from rhodecode.tests.events.conftest import EventCatcher
24
24
25 from rhodecode.model.comment import ChangesetCommentsModel
25 from rhodecode.model.pull_request import PullRequestModel
26 from rhodecode.model.pull_request import PullRequestModel
26 from rhodecode.events import (
27 from rhodecode.events import (
27 PullRequestCreateEvent,
28 PullRequestCreateEvent,
28 PullRequestUpdateEvent,
29 PullRequestUpdateEvent,
30 PullRequestCommentEvent,
29 PullRequestReviewEvent,
31 PullRequestReviewEvent,
30 PullRequestMergeEvent,
32 PullRequestMergeEvent,
31 PullRequestCloseEvent,
33 PullRequestCloseEvent,
32 )
34 )
33
35
34 # TODO: dan: make the serialization tests complete json comparisons
36 # TODO: dan: make the serialization tests complete json comparisons
35 @pytest.mark.backends("git", "hg")
37 @pytest.mark.backends("git", "hg")
36 @pytest.mark.parametrize('EventClass', [
38 @pytest.mark.parametrize('EventClass', [
37 PullRequestCreateEvent,
39 PullRequestCreateEvent,
38 PullRequestUpdateEvent,
40 PullRequestUpdateEvent,
39 PullRequestReviewEvent,
41 PullRequestReviewEvent,
40 PullRequestMergeEvent,
42 PullRequestMergeEvent,
41 PullRequestCloseEvent,
43 PullRequestCloseEvent,
42 ])
44 ])
43 def test_pullrequest_events_serialized(pr_util, EventClass):
45 def test_pullrequest_events_serialized(pr_util, EventClass):
44 pr = pr_util.create_pull_request()
46 pr = pr_util.create_pull_request()
45 event = EventClass(pr)
47 event = EventClass(pr)
46 data = event.as_dict()
48 data = event.as_dict()
47 assert data['name'] == EventClass.name
49 assert data['name'] == EventClass.name
48 assert data['repo']['repo_name'] == pr.target_repo.repo_name
50 assert data['repo']['repo_name'] == pr.target_repo.repo_name
49 assert data['pullrequest']['pull_request_id'] == pr.pull_request_id
51 assert data['pullrequest']['pull_request_id'] == pr.pull_request_id
50 assert data['pullrequest']['url']
52 assert data['pullrequest']['url']
51
53
52 @pytest.mark.backends("git", "hg")
54 @pytest.mark.backends("git", "hg")
53 def test_create_pull_request_events(pr_util):
55 def test_create_pull_request_events(pr_util):
54 with EventCatcher() as event_catcher:
56 with EventCatcher() as event_catcher:
55 pr_util.create_pull_request()
57 pr_util.create_pull_request()
56
58
57 assert PullRequestCreateEvent in event_catcher.events_types
59 assert PullRequestCreateEvent in event_catcher.events_types
58
60
61 @pytest.mark.backends("git", "hg")
62 def test_pullrequest_comment_events_serialized(pr_util):
63 pr = pr_util.create_pull_request()
64 comment = ChangesetCommentsModel().get_comments(
65 pr.target_repo.repo_id, pull_request=pr)[0]
66 event = PullRequestCommentEvent(pr, comment)
67 data = event.as_dict()
68 assert data['name'] == PullRequestCommentEvent.name
69 assert data['repo']['repo_name'] == pr.target_repo.repo_name
70 assert data['pullrequest']['pull_request_id'] == pr.pull_request_id
71 assert data['pullrequest']['url']
72 assert data['comment']['text'] == comment.text
73
59
74
60 @pytest.mark.backends("git", "hg")
75 @pytest.mark.backends("git", "hg")
61 def test_close_pull_request_events(pr_util, user_admin):
76 def test_close_pull_request_events(pr_util, user_admin):
62 pr = pr_util.create_pull_request()
77 pr = pr_util.create_pull_request()
63
78
64 with EventCatcher() as event_catcher:
79 with EventCatcher() as event_catcher:
65 PullRequestModel().close_pull_request(pr, user_admin)
80 PullRequestModel().close_pull_request(pr, user_admin)
66
81
67 assert PullRequestCloseEvent in event_catcher.events_types
82 assert PullRequestCloseEvent in event_catcher.events_types
68
83
69
84
70 @pytest.mark.backends("git", "hg")
85 @pytest.mark.backends("git", "hg")
71 def test_close_pull_request_with_comment_events(pr_util, user_admin):
86 def test_close_pull_request_with_comment_events(pr_util, user_admin):
72 pr = pr_util.create_pull_request()
87 pr = pr_util.create_pull_request()
73
88
74 with EventCatcher() as event_catcher:
89 with EventCatcher() as event_catcher:
75 PullRequestModel().close_pull_request_with_comment(
90 PullRequestModel().close_pull_request_with_comment(
76 pr, user_admin, pr.target_repo)
91 pr, user_admin, pr.target_repo)
77
92
78 assert PullRequestCloseEvent in event_catcher.events_types
93 assert PullRequestCloseEvent in event_catcher.events_types
General Comments 0
You need to be logged in to leave comments. Login now