##// END OF EJS Templates
pull-requests: increase stability of concurrent pull requests creation by flushing prematurly the statuses of commits....
marcink -
r3368:a4f559a8 default
parent child Browse files
Show More
@@ -1,393 +1,394 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2019 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21
22 22 import itertools
23 23 import logging
24 24 import collections
25 25
26 26 from rhodecode.model import BaseModel
27 27 from rhodecode.model.db import (
28 28 ChangesetStatus, ChangesetComment, PullRequest, Session)
29 29 from rhodecode.lib.exceptions import StatusChangeOnClosedPullRequestError
30 30 from rhodecode.lib.markup_renderer import (
31 31 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
32 32
33 33 log = logging.getLogger(__name__)
34 34
35 35
36 36 class ChangesetStatusModel(BaseModel):
37 37
38 38 cls = ChangesetStatus
39 39
40 40 def __get_changeset_status(self, changeset_status):
41 41 return self._get_instance(ChangesetStatus, changeset_status)
42 42
43 43 def __get_pull_request(self, pull_request):
44 44 return self._get_instance(PullRequest, pull_request)
45 45
46 46 def _get_status_query(self, repo, revision, pull_request,
47 47 with_revisions=False):
48 48 repo = self._get_repo(repo)
49 49
50 50 q = ChangesetStatus.query()\
51 51 .filter(ChangesetStatus.repo == repo)
52 52 if not with_revisions:
53 53 q = q.filter(ChangesetStatus.version == 0)
54 54
55 55 if revision:
56 56 q = q.filter(ChangesetStatus.revision == revision)
57 57 elif pull_request:
58 58 pull_request = self.__get_pull_request(pull_request)
59 59 # TODO: johbo: Think about the impact of this join, there must
60 60 # be a reason why ChangesetStatus and ChanagesetComment is linked
61 61 # to the pull request. Might be that we want to do the same for
62 62 # the pull_request_version_id.
63 63 q = q.join(ChangesetComment).filter(
64 64 ChangesetStatus.pull_request == pull_request,
65 65 ChangesetComment.pull_request_version_id == None)
66 66 else:
67 67 raise Exception('Please specify revision or pull_request')
68 68 q = q.order_by(ChangesetStatus.version.asc())
69 69 return q
70 70
71 71 def calculate_group_vote(self, group_id, group_statuses_by_reviewers,
72 72 trim_votes=True):
73 73 """
74 74 Calculate status based on given group members, and voting rule
75 75
76 76
77 77 group1 - 4 members, 3 required for approval
78 78 user1 - approved
79 79 user2 - reject
80 80 user3 - approved
81 81 user4 - rejected
82 82
83 83 final_state: rejected, reasons not at least 3 votes
84 84
85 85
86 86 group1 - 4 members, 2 required for approval
87 87 user1 - approved
88 88 user2 - reject
89 89 user3 - approved
90 90 user4 - rejected
91 91
92 92 final_state: approved, reasons got at least 2 approvals
93 93
94 94 group1 - 4 members, ALL required for approval
95 95 user1 - approved
96 96 user2 - reject
97 97 user3 - approved
98 98 user4 - rejected
99 99
100 100 final_state: rejected, reasons not all approvals
101 101
102 102
103 103 group1 - 4 members, ALL required for approval
104 104 user1 - approved
105 105 user2 - approved
106 106 user3 - approved
107 107 user4 - approved
108 108
109 109 final_state: approved, reason all approvals received
110 110
111 111 group1 - 4 members, 5 required for approval
112 112 (approval should be shorted to number of actual members)
113 113
114 114 user1 - approved
115 115 user2 - approved
116 116 user3 - approved
117 117 user4 - approved
118 118
119 119 final_state: approved, reason all approvals received
120 120
121 121 """
122 122 group_vote_data = {}
123 123 got_rule = False
124 124 members = collections.OrderedDict()
125 125 for review_obj, user, reasons, mandatory, statuses \
126 126 in group_statuses_by_reviewers:
127 127
128 128 if not got_rule:
129 129 group_vote_data = review_obj.rule_user_group_data()
130 130 got_rule = bool(group_vote_data)
131 131
132 132 members[user.user_id] = statuses
133 133
134 134 if not group_vote_data:
135 135 return []
136 136
137 137 required_votes = group_vote_data['vote_rule']
138 138 if required_votes == -1:
139 139 # -1 means all required, so we replace it with how many people
140 140 # are in the members
141 141 required_votes = len(members)
142 142
143 143 if trim_votes and required_votes > len(members):
144 144 # we require more votes than we have members in the group
145 145 # in this case we trim the required votes to the number of members
146 146 required_votes = len(members)
147 147
148 148 approvals = sum([
149 149 1 for statuses in members.values()
150 150 if statuses and
151 151 statuses[0][1].status == ChangesetStatus.STATUS_APPROVED])
152 152
153 153 calculated_votes = []
154 154 # we have all votes from users, now check if we have enough votes
155 155 # to fill other
156 156 fill_in = ChangesetStatus.STATUS_UNDER_REVIEW
157 157 if approvals >= required_votes:
158 158 fill_in = ChangesetStatus.STATUS_APPROVED
159 159
160 160 for member, statuses in members.items():
161 161 if statuses:
162 162 ver, latest = statuses[0]
163 163 if fill_in == ChangesetStatus.STATUS_APPROVED:
164 164 calculated_votes.append(fill_in)
165 165 else:
166 166 calculated_votes.append(latest.status)
167 167 else:
168 168 calculated_votes.append(fill_in)
169 169
170 170 return calculated_votes
171 171
172 172 def calculate_status(self, statuses_by_reviewers):
173 173 """
174 174 Given the approval statuses from reviewers, calculates final approval
175 175 status. There can only be 3 results, all approved, all rejected. If
176 176 there is no consensus the PR is under review.
177 177
178 178 :param statuses_by_reviewers:
179 179 """
180 180
181 181 def group_rule(element):
182 182 review_obj = element[0]
183 183 rule_data = review_obj.rule_user_group_data()
184 184 if rule_data and rule_data['id']:
185 185 return rule_data['id']
186 186
187 187 voting_groups = itertools.groupby(
188 188 sorted(statuses_by_reviewers, key=group_rule), group_rule)
189 189
190 190 voting_by_groups = [(x, list(y)) for x, y in voting_groups]
191 191
192 192 reviewers_number = len(statuses_by_reviewers)
193 193 votes = collections.defaultdict(int)
194 194 for group, group_statuses_by_reviewers in voting_by_groups:
195 195 if group:
196 196 # calculate how the "group" voted
197 197 for vote_status in self.calculate_group_vote(
198 198 group, group_statuses_by_reviewers):
199 199 votes[vote_status] += 1
200 200 else:
201 201
202 202 for review_obj, user, reasons, mandatory, statuses \
203 203 in group_statuses_by_reviewers:
204 204 # individual vote
205 205 if statuses:
206 206 ver, latest = statuses[0]
207 207 votes[latest.status] += 1
208 208
209 209 approved_votes_count = votes[ChangesetStatus.STATUS_APPROVED]
210 210 rejected_votes_count = votes[ChangesetStatus.STATUS_REJECTED]
211 211
212 212 # TODO(marcink): with group voting, how does rejected work,
213 213 # do we ever get rejected state ?
214 214
215 215 if approved_votes_count == reviewers_number:
216 216 return ChangesetStatus.STATUS_APPROVED
217 217
218 218 if rejected_votes_count == reviewers_number:
219 219 return ChangesetStatus.STATUS_REJECTED
220 220
221 221 return ChangesetStatus.STATUS_UNDER_REVIEW
222 222
223 223 def get_statuses(self, repo, revision=None, pull_request=None,
224 224 with_revisions=False):
225 225 q = self._get_status_query(repo, revision, pull_request,
226 226 with_revisions)
227 227 return q.all()
228 228
229 229 def get_status(self, repo, revision=None, pull_request=None, as_str=True):
230 230 """
231 231 Returns latest status of changeset for given revision or for given
232 232 pull request. Statuses are versioned inside a table itself and
233 233 version == 0 is always the current one
234 234
235 235 :param repo:
236 236 :param revision: 40char hash or None
237 237 :param pull_request: pull_request reference
238 238 :param as_str: return status as string not object
239 239 """
240 240 q = self._get_status_query(repo, revision, pull_request)
241 241
242 242 # need to use first here since there can be multiple statuses
243 243 # returned from pull_request
244 244 status = q.first()
245 245 if as_str:
246 246 status = status.status if status else status
247 247 st = status or ChangesetStatus.DEFAULT
248 248 return str(st)
249 249 return status
250 250
251 251 def _render_auto_status_message(
252 252 self, status, commit_id=None, pull_request=None):
253 253 """
254 254 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
255 255 so it's always looking the same disregarding on which default
256 256 renderer system is using.
257 257
258 258 :param status: status text to change into
259 259 :param commit_id: the commit_id we change the status for
260 260 :param pull_request: the pull request we change the status for
261 261 """
262 262
263 263 new_status = ChangesetStatus.get_status_lbl(status)
264 264
265 265 params = {
266 266 'new_status_label': new_status,
267 267 'pull_request': pull_request,
268 268 'commit_id': commit_id,
269 269 }
270 270 renderer = RstTemplateRenderer()
271 271 return renderer.render('auto_status_change.mako', **params)
272 272
273 273 def set_status(self, repo, status, user, comment=None, revision=None,
274 274 pull_request=None, dont_allow_on_closed_pull_request=False):
275 275 """
276 276 Creates new status for changeset or updates the old ones bumping their
277 277 version, leaving the current status at
278 278
279 279 :param repo:
280 280 :param revision:
281 281 :param status:
282 282 :param user:
283 283 :param comment:
284 284 :param dont_allow_on_closed_pull_request: don't allow a status change
285 285 if last status was for pull request and it's closed. We shouldn't
286 286 mess around this manually
287 287 """
288 288 repo = self._get_repo(repo)
289 289
290 290 q = ChangesetStatus.query()
291 291
292 292 if revision:
293 293 q = q.filter(ChangesetStatus.repo == repo)
294 294 q = q.filter(ChangesetStatus.revision == revision)
295 295 elif pull_request:
296 296 pull_request = self.__get_pull_request(pull_request)
297 297 q = q.filter(ChangesetStatus.repo == pull_request.source_repo)
298 298 q = q.filter(ChangesetStatus.revision.in_(pull_request.revisions))
299 299 cur_statuses = q.all()
300 300
301 301 # if statuses exists and last is associated with a closed pull request
302 302 # we need to check if we can allow this status change
303 303 if (dont_allow_on_closed_pull_request and cur_statuses
304 304 and getattr(cur_statuses[0].pull_request, 'status', '')
305 305 == PullRequest.STATUS_CLOSED):
306 306 raise StatusChangeOnClosedPullRequestError(
307 307 'Changing status on closed pull request is not allowed'
308 308 )
309 309
310 310 # update all current statuses with older version
311 311 if cur_statuses:
312 312 for st in cur_statuses:
313 313 st.version += 1
314 314 Session().add(st)
315 Session().flush()
315 316
316 317 def _create_status(user, repo, status, comment, revision, pull_request):
317 318 new_status = ChangesetStatus()
318 319 new_status.author = self._get_user(user)
319 320 new_status.repo = self._get_repo(repo)
320 321 new_status.status = status
321 322 new_status.comment = comment
322 323 new_status.revision = revision
323 324 new_status.pull_request = pull_request
324 325 return new_status
325 326
326 327 if not comment:
327 328 from rhodecode.model.comment import CommentsModel
328 329 comment = CommentsModel().create(
329 330 text=self._render_auto_status_message(
330 331 status, commit_id=revision, pull_request=pull_request),
331 332 repo=repo,
332 333 user=user,
333 334 pull_request=pull_request,
334 335 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER
335 336 )
336 337
337 338 if revision:
338 339 new_status = _create_status(
339 340 user=user, repo=repo, status=status, comment=comment,
340 341 revision=revision, pull_request=pull_request)
341 342 Session().add(new_status)
342 343 return new_status
343 344 elif pull_request:
344 345 # pull request can have more than one revision associated to it
345 346 # we need to create new version for each one
346 347 new_statuses = []
347 348 repo = pull_request.source_repo
348 349 for rev in pull_request.revisions:
349 350 new_status = _create_status(
350 351 user=user, repo=repo, status=status, comment=comment,
351 352 revision=rev, pull_request=pull_request)
352 353 new_statuses.append(new_status)
353 354 Session().add(new_status)
354 355 return new_statuses
355 356
356 357 def reviewers_statuses(self, pull_request):
357 358 _commit_statuses = self.get_statuses(
358 359 pull_request.source_repo,
359 360 pull_request=pull_request,
360 361 with_revisions=True)
361 362
362 363 commit_statuses = collections.defaultdict(list)
363 364 for st in _commit_statuses:
364 365 commit_statuses[st.author.username] += [st]
365 366
366 367 pull_request_reviewers = []
367 368
368 369 def version(commit_status):
369 370 return commit_status.version
370 371
371 372 for obj in pull_request.reviewers:
372 373 if not obj.user:
373 374 continue
374 375 statuses = commit_statuses.get(obj.user.username, None)
375 376 if statuses:
376 377 status_groups = itertools.groupby(
377 378 sorted(statuses, key=version), version)
378 379 statuses = [(x, list(y)[0]) for x, y in status_groups]
379 380
380 381 pull_request_reviewers.append(
381 382 (obj, obj.user, obj.reasons, obj.mandatory, statuses))
382 383
383 384 return pull_request_reviewers
384 385
385 386 def calculated_review_status(self, pull_request, reviewers_statuses=None):
386 387 """
387 388 calculate pull request status based on reviewers, it should be a list
388 389 of two element lists.
389 390
390 391 :param reviewers_statuses:
391 392 """
392 393 reviewers = reviewers_statuses or self.reviewers_statuses(pull_request)
393 394 return self.calculate_status(reviewers)
@@ -1,1696 +1,1696 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2019 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21
22 22 """
23 23 pull request model for RhodeCode
24 24 """
25 25
26 26
27 27 import json
28 28 import logging
29 29 import datetime
30 30 import urllib
31 31 import collections
32 32
33 33 from pyramid.threadlocal import get_current_request
34 34
35 35 from rhodecode import events
36 36 from rhodecode.translation import lazy_ugettext
37 37 from rhodecode.lib import helpers as h, hooks_utils, diffs
38 38 from rhodecode.lib import audit_logger
39 39 from rhodecode.lib.compat import OrderedDict
40 40 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
41 41 from rhodecode.lib.markup_renderer import (
42 42 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
43 43 from rhodecode.lib.utils2 import safe_unicode, safe_str, md5_safe
44 44 from rhodecode.lib.vcs.backends.base import (
45 45 Reference, MergeResponse, MergeFailureReason, UpdateFailureReason)
46 46 from rhodecode.lib.vcs.conf import settings as vcs_settings
47 47 from rhodecode.lib.vcs.exceptions import (
48 48 CommitDoesNotExistError, EmptyRepositoryError)
49 49 from rhodecode.model import BaseModel
50 50 from rhodecode.model.changeset_status import ChangesetStatusModel
51 51 from rhodecode.model.comment import CommentsModel
52 52 from rhodecode.model.db import (
53 53 or_, PullRequest, PullRequestReviewers, ChangesetStatus,
54 54 PullRequestVersion, ChangesetComment, Repository, RepoReviewRule)
55 55 from rhodecode.model.meta import Session
56 56 from rhodecode.model.notification import NotificationModel, \
57 57 EmailNotificationModel
58 58 from rhodecode.model.scm import ScmModel
59 59 from rhodecode.model.settings import VcsSettingsModel
60 60
61 61
62 62 log = logging.getLogger(__name__)
63 63
64 64
65 65 # Data structure to hold the response data when updating commits during a pull
66 66 # request update.
67 67 UpdateResponse = collections.namedtuple('UpdateResponse', [
68 68 'executed', 'reason', 'new', 'old', 'changes',
69 69 'source_changed', 'target_changed'])
70 70
71 71
72 72 class PullRequestModel(BaseModel):
73 73
74 74 cls = PullRequest
75 75
76 76 DIFF_CONTEXT = diffs.DEFAULT_CONTEXT
77 77
78 78 UPDATE_STATUS_MESSAGES = {
79 79 UpdateFailureReason.NONE: lazy_ugettext(
80 80 'Pull request update successful.'),
81 81 UpdateFailureReason.UNKNOWN: lazy_ugettext(
82 82 'Pull request update failed because of an unknown error.'),
83 83 UpdateFailureReason.NO_CHANGE: lazy_ugettext(
84 84 'No update needed because the source and target have not changed.'),
85 85 UpdateFailureReason.WRONG_REF_TYPE: lazy_ugettext(
86 86 'Pull request cannot be updated because the reference type is '
87 87 'not supported for an update. Only Branch, Tag or Bookmark is allowed.'),
88 88 UpdateFailureReason.MISSING_TARGET_REF: lazy_ugettext(
89 89 'This pull request cannot be updated because the target '
90 90 'reference is missing.'),
91 91 UpdateFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
92 92 'This pull request cannot be updated because the source '
93 93 'reference is missing.'),
94 94 }
95 95 REF_TYPES = ['bookmark', 'book', 'tag', 'branch']
96 96 UPDATABLE_REF_TYPES = ['bookmark', 'book', 'branch']
97 97
98 98 def __get_pull_request(self, pull_request):
99 99 return self._get_instance((
100 100 PullRequest, PullRequestVersion), pull_request)
101 101
102 102 def _check_perms(self, perms, pull_request, user, api=False):
103 103 if not api:
104 104 return h.HasRepoPermissionAny(*perms)(
105 105 user=user, repo_name=pull_request.target_repo.repo_name)
106 106 else:
107 107 return h.HasRepoPermissionAnyApi(*perms)(
108 108 user=user, repo_name=pull_request.target_repo.repo_name)
109 109
110 110 def check_user_read(self, pull_request, user, api=False):
111 111 _perms = ('repository.admin', 'repository.write', 'repository.read',)
112 112 return self._check_perms(_perms, pull_request, user, api)
113 113
114 114 def check_user_merge(self, pull_request, user, api=False):
115 115 _perms = ('repository.admin', 'repository.write', 'hg.admin',)
116 116 return self._check_perms(_perms, pull_request, user, api)
117 117
118 118 def check_user_update(self, pull_request, user, api=False):
119 119 owner = user.user_id == pull_request.user_id
120 120 return self.check_user_merge(pull_request, user, api) or owner
121 121
122 122 def check_user_delete(self, pull_request, user):
123 123 owner = user.user_id == pull_request.user_id
124 124 _perms = ('repository.admin',)
125 125 return self._check_perms(_perms, pull_request, user) or owner
126 126
127 127 def check_user_change_status(self, pull_request, user, api=False):
128 128 reviewer = user.user_id in [x.user_id for x in
129 129 pull_request.reviewers]
130 130 return self.check_user_update(pull_request, user, api) or reviewer
131 131
132 132 def check_user_comment(self, pull_request, user):
133 133 owner = user.user_id == pull_request.user_id
134 134 return self.check_user_read(pull_request, user) or owner
135 135
136 136 def get(self, pull_request):
137 137 return self.__get_pull_request(pull_request)
138 138
139 139 def _prepare_get_all_query(self, repo_name, source=False, statuses=None,
140 140 opened_by=None, order_by=None,
141 141 order_dir='desc'):
142 142 repo = None
143 143 if repo_name:
144 144 repo = self._get_repo(repo_name)
145 145
146 146 q = PullRequest.query()
147 147
148 148 # source or target
149 149 if repo and source:
150 150 q = q.filter(PullRequest.source_repo == repo)
151 151 elif repo:
152 152 q = q.filter(PullRequest.target_repo == repo)
153 153
154 154 # closed,opened
155 155 if statuses:
156 156 q = q.filter(PullRequest.status.in_(statuses))
157 157
158 158 # opened by filter
159 159 if opened_by:
160 160 q = q.filter(PullRequest.user_id.in_(opened_by))
161 161
162 162 if order_by:
163 163 order_map = {
164 164 'name_raw': PullRequest.pull_request_id,
165 165 'title': PullRequest.title,
166 166 'updated_on_raw': PullRequest.updated_on,
167 167 'target_repo': PullRequest.target_repo_id
168 168 }
169 169 if order_dir == 'asc':
170 170 q = q.order_by(order_map[order_by].asc())
171 171 else:
172 172 q = q.order_by(order_map[order_by].desc())
173 173
174 174 return q
175 175
176 176 def count_all(self, repo_name, source=False, statuses=None,
177 177 opened_by=None):
178 178 """
179 179 Count the number of pull requests for a specific repository.
180 180
181 181 :param repo_name: target or source repo
182 182 :param source: boolean flag to specify if repo_name refers to source
183 183 :param statuses: list of pull request statuses
184 184 :param opened_by: author user of the pull request
185 185 :returns: int number of pull requests
186 186 """
187 187 q = self._prepare_get_all_query(
188 188 repo_name, source=source, statuses=statuses, opened_by=opened_by)
189 189
190 190 return q.count()
191 191
192 192 def get_all(self, repo_name, source=False, statuses=None, opened_by=None,
193 193 offset=0, length=None, order_by=None, order_dir='desc'):
194 194 """
195 195 Get all pull requests for a specific repository.
196 196
197 197 :param repo_name: target or source repo
198 198 :param source: boolean flag to specify if repo_name refers to source
199 199 :param statuses: list of pull request statuses
200 200 :param opened_by: author user of the pull request
201 201 :param offset: pagination offset
202 202 :param length: length of returned list
203 203 :param order_by: order of the returned list
204 204 :param order_dir: 'asc' or 'desc' ordering direction
205 205 :returns: list of pull requests
206 206 """
207 207 q = self._prepare_get_all_query(
208 208 repo_name, source=source, statuses=statuses, opened_by=opened_by,
209 209 order_by=order_by, order_dir=order_dir)
210 210
211 211 if length:
212 212 pull_requests = q.limit(length).offset(offset).all()
213 213 else:
214 214 pull_requests = q.all()
215 215
216 216 return pull_requests
217 217
218 218 def count_awaiting_review(self, repo_name, source=False, statuses=None,
219 219 opened_by=None):
220 220 """
221 221 Count the number of pull requests for a specific repository that are
222 222 awaiting review.
223 223
224 224 :param repo_name: target or source repo
225 225 :param source: boolean flag to specify if repo_name refers to source
226 226 :param statuses: list of pull request statuses
227 227 :param opened_by: author user of the pull request
228 228 :returns: int number of pull requests
229 229 """
230 230 pull_requests = self.get_awaiting_review(
231 231 repo_name, source=source, statuses=statuses, opened_by=opened_by)
232 232
233 233 return len(pull_requests)
234 234
235 235 def get_awaiting_review(self, repo_name, source=False, statuses=None,
236 236 opened_by=None, offset=0, length=None,
237 237 order_by=None, order_dir='desc'):
238 238 """
239 239 Get all pull requests for a specific repository that are awaiting
240 240 review.
241 241
242 242 :param repo_name: target or source repo
243 243 :param source: boolean flag to specify if repo_name refers to source
244 244 :param statuses: list of pull request statuses
245 245 :param opened_by: author user of the pull request
246 246 :param offset: pagination offset
247 247 :param length: length of returned list
248 248 :param order_by: order of the returned list
249 249 :param order_dir: 'asc' or 'desc' ordering direction
250 250 :returns: list of pull requests
251 251 """
252 252 pull_requests = self.get_all(
253 253 repo_name, source=source, statuses=statuses, opened_by=opened_by,
254 254 order_by=order_by, order_dir=order_dir)
255 255
256 256 _filtered_pull_requests = []
257 257 for pr in pull_requests:
258 258 status = pr.calculated_review_status()
259 259 if status in [ChangesetStatus.STATUS_NOT_REVIEWED,
260 260 ChangesetStatus.STATUS_UNDER_REVIEW]:
261 261 _filtered_pull_requests.append(pr)
262 262 if length:
263 263 return _filtered_pull_requests[offset:offset+length]
264 264 else:
265 265 return _filtered_pull_requests
266 266
267 267 def count_awaiting_my_review(self, repo_name, source=False, statuses=None,
268 268 opened_by=None, user_id=None):
269 269 """
270 270 Count the number of pull requests for a specific repository that are
271 271 awaiting review from a specific user.
272 272
273 273 :param repo_name: target or source repo
274 274 :param source: boolean flag to specify if repo_name refers to source
275 275 :param statuses: list of pull request statuses
276 276 :param opened_by: author user of the pull request
277 277 :param user_id: reviewer user of the pull request
278 278 :returns: int number of pull requests
279 279 """
280 280 pull_requests = self.get_awaiting_my_review(
281 281 repo_name, source=source, statuses=statuses, opened_by=opened_by,
282 282 user_id=user_id)
283 283
284 284 return len(pull_requests)
285 285
286 286 def get_awaiting_my_review(self, repo_name, source=False, statuses=None,
287 287 opened_by=None, user_id=None, offset=0,
288 288 length=None, order_by=None, order_dir='desc'):
289 289 """
290 290 Get all pull requests for a specific repository that are awaiting
291 291 review from a specific user.
292 292
293 293 :param repo_name: target or source repo
294 294 :param source: boolean flag to specify if repo_name refers to source
295 295 :param statuses: list of pull request statuses
296 296 :param opened_by: author user of the pull request
297 297 :param user_id: reviewer user of the pull request
298 298 :param offset: pagination offset
299 299 :param length: length of returned list
300 300 :param order_by: order of the returned list
301 301 :param order_dir: 'asc' or 'desc' ordering direction
302 302 :returns: list of pull requests
303 303 """
304 304 pull_requests = self.get_all(
305 305 repo_name, source=source, statuses=statuses, opened_by=opened_by,
306 306 order_by=order_by, order_dir=order_dir)
307 307
308 308 _my = PullRequestModel().get_not_reviewed(user_id)
309 309 my_participation = []
310 310 for pr in pull_requests:
311 311 if pr in _my:
312 312 my_participation.append(pr)
313 313 _filtered_pull_requests = my_participation
314 314 if length:
315 315 return _filtered_pull_requests[offset:offset+length]
316 316 else:
317 317 return _filtered_pull_requests
318 318
319 319 def get_not_reviewed(self, user_id):
320 320 return [
321 321 x.pull_request for x in PullRequestReviewers.query().filter(
322 322 PullRequestReviewers.user_id == user_id).all()
323 323 ]
324 324
325 325 def _prepare_participating_query(self, user_id=None, statuses=None,
326 326 order_by=None, order_dir='desc'):
327 327 q = PullRequest.query()
328 328 if user_id:
329 329 reviewers_subquery = Session().query(
330 330 PullRequestReviewers.pull_request_id).filter(
331 331 PullRequestReviewers.user_id == user_id).subquery()
332 332 user_filter = or_(
333 333 PullRequest.user_id == user_id,
334 334 PullRequest.pull_request_id.in_(reviewers_subquery)
335 335 )
336 336 q = PullRequest.query().filter(user_filter)
337 337
338 338 # closed,opened
339 339 if statuses:
340 340 q = q.filter(PullRequest.status.in_(statuses))
341 341
342 342 if order_by:
343 343 order_map = {
344 344 'name_raw': PullRequest.pull_request_id,
345 345 'title': PullRequest.title,
346 346 'updated_on_raw': PullRequest.updated_on,
347 347 'target_repo': PullRequest.target_repo_id
348 348 }
349 349 if order_dir == 'asc':
350 350 q = q.order_by(order_map[order_by].asc())
351 351 else:
352 352 q = q.order_by(order_map[order_by].desc())
353 353
354 354 return q
355 355
356 356 def count_im_participating_in(self, user_id=None, statuses=None):
357 357 q = self._prepare_participating_query(user_id, statuses=statuses)
358 358 return q.count()
359 359
360 360 def get_im_participating_in(
361 361 self, user_id=None, statuses=None, offset=0,
362 362 length=None, order_by=None, order_dir='desc'):
363 363 """
364 364 Get all Pull requests that i'm participating in, or i have opened
365 365 """
366 366
367 367 q = self._prepare_participating_query(
368 368 user_id, statuses=statuses, order_by=order_by,
369 369 order_dir=order_dir)
370 370
371 371 if length:
372 372 pull_requests = q.limit(length).offset(offset).all()
373 373 else:
374 374 pull_requests = q.all()
375 375
376 376 return pull_requests
377 377
378 378 def get_versions(self, pull_request):
379 379 """
380 380 returns version of pull request sorted by ID descending
381 381 """
382 382 return PullRequestVersion.query()\
383 383 .filter(PullRequestVersion.pull_request == pull_request)\
384 384 .order_by(PullRequestVersion.pull_request_version_id.asc())\
385 385 .all()
386 386
387 387 def get_pr_version(self, pull_request_id, version=None):
388 388 at_version = None
389 389
390 390 if version and version == 'latest':
391 391 pull_request_ver = PullRequest.get(pull_request_id)
392 392 pull_request_obj = pull_request_ver
393 393 _org_pull_request_obj = pull_request_obj
394 394 at_version = 'latest'
395 395 elif version:
396 396 pull_request_ver = PullRequestVersion.get_or_404(version)
397 397 pull_request_obj = pull_request_ver
398 398 _org_pull_request_obj = pull_request_ver.pull_request
399 399 at_version = pull_request_ver.pull_request_version_id
400 400 else:
401 401 _org_pull_request_obj = pull_request_obj = PullRequest.get_or_404(
402 402 pull_request_id)
403 403
404 404 pull_request_display_obj = PullRequest.get_pr_display_object(
405 405 pull_request_obj, _org_pull_request_obj)
406 406
407 407 return _org_pull_request_obj, pull_request_obj, \
408 408 pull_request_display_obj, at_version
409 409
410 410 def create(self, created_by, source_repo, source_ref, target_repo,
411 411 target_ref, revisions, reviewers, title, description=None,
412 412 description_renderer=None,
413 413 reviewer_data=None, translator=None, auth_user=None):
414 414 translator = translator or get_current_request().translate
415 415
416 416 created_by_user = self._get_user(created_by)
417 417 auth_user = auth_user or created_by_user.AuthUser()
418 418 source_repo = self._get_repo(source_repo)
419 419 target_repo = self._get_repo(target_repo)
420 420
421 421 pull_request = PullRequest()
422 422 pull_request.source_repo = source_repo
423 423 pull_request.source_ref = source_ref
424 424 pull_request.target_repo = target_repo
425 425 pull_request.target_ref = target_ref
426 426 pull_request.revisions = revisions
427 427 pull_request.title = title
428 428 pull_request.description = description
429 429 pull_request.description_renderer = description_renderer
430 430 pull_request.author = created_by_user
431 431 pull_request.reviewer_data = reviewer_data
432 432
433 433 Session().add(pull_request)
434 434 Session().flush()
435 435
436 436 reviewer_ids = set()
437 437 # members / reviewers
438 438 for reviewer_object in reviewers:
439 439 user_id, reasons, mandatory, rules = reviewer_object
440 440 user = self._get_user(user_id)
441 441
442 442 # skip duplicates
443 443 if user.user_id in reviewer_ids:
444 444 continue
445 445
446 446 reviewer_ids.add(user.user_id)
447 447
448 448 reviewer = PullRequestReviewers()
449 449 reviewer.user = user
450 450 reviewer.pull_request = pull_request
451 451 reviewer.reasons = reasons
452 452 reviewer.mandatory = mandatory
453 453
454 454 # NOTE(marcink): pick only first rule for now
455 455 rule_id = list(rules)[0] if rules else None
456 456 rule = RepoReviewRule.get(rule_id) if rule_id else None
457 457 if rule:
458 458 review_group = rule.user_group_vote_rule(user_id)
459 459 # we check if this particular reviewer is member of a voting group
460 460 if review_group:
461 461 # NOTE(marcink):
462 462 # can be that user is member of more but we pick the first same,
463 463 # same as default reviewers algo
464 464 review_group = review_group[0]
465 465
466 466 rule_data = {
467 467 'rule_name':
468 468 rule.review_rule_name,
469 469 'rule_user_group_entry_id':
470 470 review_group.repo_review_rule_users_group_id,
471 471 'rule_user_group_name':
472 472 review_group.users_group.users_group_name,
473 473 'rule_user_group_members':
474 474 [x.user.username for x in review_group.users_group.members],
475 475 'rule_user_group_members_id':
476 476 [x.user.user_id for x in review_group.users_group.members],
477 477 }
478 478 # e.g {'vote_rule': -1, 'mandatory': True}
479 479 rule_data.update(review_group.rule_data())
480 480
481 481 reviewer.rule_data = rule_data
482 482
483 483 Session().add(reviewer)
484 484 Session().flush()
485 485
486 486 # Set approval status to "Under Review" for all commits which are
487 487 # part of this pull request.
488 488 ChangesetStatusModel().set_status(
489 489 repo=target_repo,
490 490 status=ChangesetStatus.STATUS_UNDER_REVIEW,
491 491 user=created_by_user,
492 492 pull_request=pull_request
493 493 )
494 494 # we commit early at this point. This has to do with a fact
495 495 # that before queries do some row-locking. And because of that
496 # we need to commit and finish transation before below validate call
496 # we need to commit and finish transaction before below validate call
497 497 # that for large repos could be long resulting in long row locks
498 498 Session().commit()
499 499
500 500 # prepare workspace, and run initial merge simulation
501 501 MergeCheck.validate(
502 502 pull_request, auth_user=auth_user, translator=translator)
503 503
504 504 self.notify_reviewers(pull_request, reviewer_ids)
505 505 self._trigger_pull_request_hook(
506 506 pull_request, created_by_user, 'create')
507 507
508 508 creation_data = pull_request.get_api_data(with_merge_state=False)
509 509 self._log_audit_action(
510 510 'repo.pull_request.create', {'data': creation_data},
511 511 auth_user, pull_request)
512 512
513 513 return pull_request
514 514
515 515 def _trigger_pull_request_hook(self, pull_request, user, action):
516 516 pull_request = self.__get_pull_request(pull_request)
517 517 target_scm = pull_request.target_repo.scm_instance()
518 518 if action == 'create':
519 519 trigger_hook = hooks_utils.trigger_log_create_pull_request_hook
520 520 elif action == 'merge':
521 521 trigger_hook = hooks_utils.trigger_log_merge_pull_request_hook
522 522 elif action == 'close':
523 523 trigger_hook = hooks_utils.trigger_log_close_pull_request_hook
524 524 elif action == 'review_status_change':
525 525 trigger_hook = hooks_utils.trigger_log_review_pull_request_hook
526 526 elif action == 'update':
527 527 trigger_hook = hooks_utils.trigger_log_update_pull_request_hook
528 528 else:
529 529 return
530 530
531 531 trigger_hook(
532 532 username=user.username,
533 533 repo_name=pull_request.target_repo.repo_name,
534 534 repo_alias=target_scm.alias,
535 535 pull_request=pull_request)
536 536
537 537 def _get_commit_ids(self, pull_request):
538 538 """
539 539 Return the commit ids of the merged pull request.
540 540
541 541 This method is not dealing correctly yet with the lack of autoupdates
542 542 nor with the implicit target updates.
543 543 For example: if a commit in the source repo is already in the target it
544 544 will be reported anyways.
545 545 """
546 546 merge_rev = pull_request.merge_rev
547 547 if merge_rev is None:
548 548 raise ValueError('This pull request was not merged yet')
549 549
550 550 commit_ids = list(pull_request.revisions)
551 551 if merge_rev not in commit_ids:
552 552 commit_ids.append(merge_rev)
553 553
554 554 return commit_ids
555 555
556 556 def merge_repo(self, pull_request, user, extras):
557 557 log.debug("Merging pull request %s", pull_request.pull_request_id)
558 558 extras['user_agent'] = 'internal-merge'
559 559 merge_state = self._merge_pull_request(pull_request, user, extras)
560 560 if merge_state.executed:
561 561 log.debug("Merge was successful, updating the pull request comments.")
562 562 self._comment_and_close_pr(pull_request, user, merge_state)
563 563
564 564 self._log_audit_action(
565 565 'repo.pull_request.merge',
566 566 {'merge_state': merge_state.__dict__},
567 567 user, pull_request)
568 568
569 569 else:
570 570 log.warn("Merge failed, not updating the pull request.")
571 571 return merge_state
572 572
573 573 def _merge_pull_request(self, pull_request, user, extras, merge_msg=None):
574 574 target_vcs = pull_request.target_repo.scm_instance()
575 575 source_vcs = pull_request.source_repo.scm_instance()
576 576
577 577 message = safe_unicode(merge_msg or vcs_settings.MERGE_MESSAGE_TMPL).format(
578 578 pr_id=pull_request.pull_request_id,
579 579 pr_title=pull_request.title,
580 580 source_repo=source_vcs.name,
581 581 source_ref_name=pull_request.source_ref_parts.name,
582 582 target_repo=target_vcs.name,
583 583 target_ref_name=pull_request.target_ref_parts.name,
584 584 )
585 585
586 586 workspace_id = self._workspace_id(pull_request)
587 587 repo_id = pull_request.target_repo.repo_id
588 588 use_rebase = self._use_rebase_for_merging(pull_request)
589 589 close_branch = self._close_branch_before_merging(pull_request)
590 590
591 591 target_ref = self._refresh_reference(
592 592 pull_request.target_ref_parts, target_vcs)
593 593
594 594 callback_daemon, extras = prepare_callback_daemon(
595 595 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
596 596 host=vcs_settings.HOOKS_HOST,
597 597 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
598 598
599 599 with callback_daemon:
600 600 # TODO: johbo: Implement a clean way to run a config_override
601 601 # for a single call.
602 602 target_vcs.config.set(
603 603 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
604 604
605 605 user_name = user.short_contact
606 606 merge_state = target_vcs.merge(
607 607 repo_id, workspace_id, target_ref, source_vcs,
608 608 pull_request.source_ref_parts,
609 609 user_name=user_name, user_email=user.email,
610 610 message=message, use_rebase=use_rebase,
611 611 close_branch=close_branch)
612 612 return merge_state
613 613
614 614 def _comment_and_close_pr(self, pull_request, user, merge_state, close_msg=None):
615 615 pull_request.merge_rev = merge_state.merge_ref.commit_id
616 616 pull_request.updated_on = datetime.datetime.now()
617 617 close_msg = close_msg or 'Pull request merged and closed'
618 618
619 619 CommentsModel().create(
620 620 text=safe_unicode(close_msg),
621 621 repo=pull_request.target_repo.repo_id,
622 622 user=user.user_id,
623 623 pull_request=pull_request.pull_request_id,
624 624 f_path=None,
625 625 line_no=None,
626 626 closing_pr=True
627 627 )
628 628
629 629 Session().add(pull_request)
630 630 Session().flush()
631 631 # TODO: paris: replace invalidation with less radical solution
632 632 ScmModel().mark_for_invalidation(
633 633 pull_request.target_repo.repo_name)
634 634 self._trigger_pull_request_hook(pull_request, user, 'merge')
635 635
636 636 def has_valid_update_type(self, pull_request):
637 637 source_ref_type = pull_request.source_ref_parts.type
638 638 return source_ref_type in self.REF_TYPES
639 639
640 640 def update_commits(self, pull_request):
641 641 """
642 642 Get the updated list of commits for the pull request
643 643 and return the new pull request version and the list
644 644 of commits processed by this update action
645 645 """
646 646 pull_request = self.__get_pull_request(pull_request)
647 647 source_ref_type = pull_request.source_ref_parts.type
648 648 source_ref_name = pull_request.source_ref_parts.name
649 649 source_ref_id = pull_request.source_ref_parts.commit_id
650 650
651 651 target_ref_type = pull_request.target_ref_parts.type
652 652 target_ref_name = pull_request.target_ref_parts.name
653 653 target_ref_id = pull_request.target_ref_parts.commit_id
654 654
655 655 if not self.has_valid_update_type(pull_request):
656 656 log.debug(
657 657 "Skipping update of pull request %s due to ref type: %s",
658 658 pull_request, source_ref_type)
659 659 return UpdateResponse(
660 660 executed=False,
661 661 reason=UpdateFailureReason.WRONG_REF_TYPE,
662 662 old=pull_request, new=None, changes=None,
663 663 source_changed=False, target_changed=False)
664 664
665 665 # source repo
666 666 source_repo = pull_request.source_repo.scm_instance()
667 667 try:
668 668 source_commit = source_repo.get_commit(commit_id=source_ref_name)
669 669 except CommitDoesNotExistError:
670 670 return UpdateResponse(
671 671 executed=False,
672 672 reason=UpdateFailureReason.MISSING_SOURCE_REF,
673 673 old=pull_request, new=None, changes=None,
674 674 source_changed=False, target_changed=False)
675 675
676 676 source_changed = source_ref_id != source_commit.raw_id
677 677
678 678 # target repo
679 679 target_repo = pull_request.target_repo.scm_instance()
680 680 try:
681 681 target_commit = target_repo.get_commit(commit_id=target_ref_name)
682 682 except CommitDoesNotExistError:
683 683 return UpdateResponse(
684 684 executed=False,
685 685 reason=UpdateFailureReason.MISSING_TARGET_REF,
686 686 old=pull_request, new=None, changes=None,
687 687 source_changed=False, target_changed=False)
688 688 target_changed = target_ref_id != target_commit.raw_id
689 689
690 690 if not (source_changed or target_changed):
691 691 log.debug("Nothing changed in pull request %s", pull_request)
692 692 return UpdateResponse(
693 693 executed=False,
694 694 reason=UpdateFailureReason.NO_CHANGE,
695 695 old=pull_request, new=None, changes=None,
696 696 source_changed=target_changed, target_changed=source_changed)
697 697
698 698 change_in_found = 'target repo' if target_changed else 'source repo'
699 699 log.debug('Updating pull request because of change in %s detected',
700 700 change_in_found)
701 701
702 702 # Finally there is a need for an update, in case of source change
703 703 # we create a new version, else just an update
704 704 if source_changed:
705 705 pull_request_version = self._create_version_from_snapshot(pull_request)
706 706 self._link_comments_to_version(pull_request_version)
707 707 else:
708 708 try:
709 709 ver = pull_request.versions[-1]
710 710 except IndexError:
711 711 ver = None
712 712
713 713 pull_request.pull_request_version_id = \
714 714 ver.pull_request_version_id if ver else None
715 715 pull_request_version = pull_request
716 716
717 717 try:
718 718 if target_ref_type in self.REF_TYPES:
719 719 target_commit = target_repo.get_commit(target_ref_name)
720 720 else:
721 721 target_commit = target_repo.get_commit(target_ref_id)
722 722 except CommitDoesNotExistError:
723 723 return UpdateResponse(
724 724 executed=False,
725 725 reason=UpdateFailureReason.MISSING_TARGET_REF,
726 726 old=pull_request, new=None, changes=None,
727 727 source_changed=source_changed, target_changed=target_changed)
728 728
729 729 # re-compute commit ids
730 730 old_commit_ids = pull_request.revisions
731 731 pre_load = ["author", "branch", "date", "message"]
732 732 commit_ranges = target_repo.compare(
733 733 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
734 734 pre_load=pre_load)
735 735
736 736 ancestor = target_repo.get_common_ancestor(
737 737 target_commit.raw_id, source_commit.raw_id, source_repo)
738 738
739 739 pull_request.source_ref = '%s:%s:%s' % (
740 740 source_ref_type, source_ref_name, source_commit.raw_id)
741 741 pull_request.target_ref = '%s:%s:%s' % (
742 742 target_ref_type, target_ref_name, ancestor)
743 743
744 744 pull_request.revisions = [
745 745 commit.raw_id for commit in reversed(commit_ranges)]
746 746 pull_request.updated_on = datetime.datetime.now()
747 747 Session().add(pull_request)
748 748 new_commit_ids = pull_request.revisions
749 749
750 750 old_diff_data, new_diff_data = self._generate_update_diffs(
751 751 pull_request, pull_request_version)
752 752
753 753 # calculate commit and file changes
754 754 changes = self._calculate_commit_id_changes(
755 755 old_commit_ids, new_commit_ids)
756 756 file_changes = self._calculate_file_changes(
757 757 old_diff_data, new_diff_data)
758 758
759 759 # set comments as outdated if DIFFS changed
760 760 CommentsModel().outdate_comments(
761 761 pull_request, old_diff_data=old_diff_data,
762 762 new_diff_data=new_diff_data)
763 763
764 764 commit_changes = (changes.added or changes.removed)
765 765 file_node_changes = (
766 766 file_changes.added or file_changes.modified or file_changes.removed)
767 767 pr_has_changes = commit_changes or file_node_changes
768 768
769 769 # Add an automatic comment to the pull request, in case
770 770 # anything has changed
771 771 if pr_has_changes:
772 772 update_comment = CommentsModel().create(
773 773 text=self._render_update_message(changes, file_changes),
774 774 repo=pull_request.target_repo,
775 775 user=pull_request.author,
776 776 pull_request=pull_request,
777 777 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
778 778
779 779 # Update status to "Under Review" for added commits
780 780 for commit_id in changes.added:
781 781 ChangesetStatusModel().set_status(
782 782 repo=pull_request.source_repo,
783 783 status=ChangesetStatus.STATUS_UNDER_REVIEW,
784 784 comment=update_comment,
785 785 user=pull_request.author,
786 786 pull_request=pull_request,
787 787 revision=commit_id)
788 788
789 789 log.debug(
790 790 'Updated pull request %s, added_ids: %s, common_ids: %s, '
791 791 'removed_ids: %s', pull_request.pull_request_id,
792 792 changes.added, changes.common, changes.removed)
793 793 log.debug(
794 794 'Updated pull request with the following file changes: %s',
795 795 file_changes)
796 796
797 797 log.info(
798 798 "Updated pull request %s from commit %s to commit %s, "
799 799 "stored new version %s of this pull request.",
800 800 pull_request.pull_request_id, source_ref_id,
801 801 pull_request.source_ref_parts.commit_id,
802 802 pull_request_version.pull_request_version_id)
803 803 Session().commit()
804 804 self._trigger_pull_request_hook(
805 805 pull_request, pull_request.author, 'update')
806 806
807 807 return UpdateResponse(
808 808 executed=True, reason=UpdateFailureReason.NONE,
809 809 old=pull_request, new=pull_request_version, changes=changes,
810 810 source_changed=source_changed, target_changed=target_changed)
811 811
812 812 def _create_version_from_snapshot(self, pull_request):
813 813 version = PullRequestVersion()
814 814 version.title = pull_request.title
815 815 version.description = pull_request.description
816 816 version.status = pull_request.status
817 817 version.created_on = datetime.datetime.now()
818 818 version.updated_on = pull_request.updated_on
819 819 version.user_id = pull_request.user_id
820 820 version.source_repo = pull_request.source_repo
821 821 version.source_ref = pull_request.source_ref
822 822 version.target_repo = pull_request.target_repo
823 823 version.target_ref = pull_request.target_ref
824 824
825 825 version._last_merge_source_rev = pull_request._last_merge_source_rev
826 826 version._last_merge_target_rev = pull_request._last_merge_target_rev
827 827 version.last_merge_status = pull_request.last_merge_status
828 828 version.shadow_merge_ref = pull_request.shadow_merge_ref
829 829 version.merge_rev = pull_request.merge_rev
830 830 version.reviewer_data = pull_request.reviewer_data
831 831
832 832 version.revisions = pull_request.revisions
833 833 version.pull_request = pull_request
834 834 Session().add(version)
835 835 Session().flush()
836 836
837 837 return version
838 838
839 839 def _generate_update_diffs(self, pull_request, pull_request_version):
840 840
841 841 diff_context = (
842 842 self.DIFF_CONTEXT +
843 843 CommentsModel.needed_extra_diff_context())
844 844 hide_whitespace_changes = False
845 845 source_repo = pull_request_version.source_repo
846 846 source_ref_id = pull_request_version.source_ref_parts.commit_id
847 847 target_ref_id = pull_request_version.target_ref_parts.commit_id
848 848 old_diff = self._get_diff_from_pr_or_version(
849 849 source_repo, source_ref_id, target_ref_id,
850 850 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
851 851
852 852 source_repo = pull_request.source_repo
853 853 source_ref_id = pull_request.source_ref_parts.commit_id
854 854 target_ref_id = pull_request.target_ref_parts.commit_id
855 855
856 856 new_diff = self._get_diff_from_pr_or_version(
857 857 source_repo, source_ref_id, target_ref_id,
858 858 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
859 859
860 860 old_diff_data = diffs.DiffProcessor(old_diff)
861 861 old_diff_data.prepare()
862 862 new_diff_data = diffs.DiffProcessor(new_diff)
863 863 new_diff_data.prepare()
864 864
865 865 return old_diff_data, new_diff_data
866 866
867 867 def _link_comments_to_version(self, pull_request_version):
868 868 """
869 869 Link all unlinked comments of this pull request to the given version.
870 870
871 871 :param pull_request_version: The `PullRequestVersion` to which
872 872 the comments shall be linked.
873 873
874 874 """
875 875 pull_request = pull_request_version.pull_request
876 876 comments = ChangesetComment.query()\
877 877 .filter(
878 878 # TODO: johbo: Should we query for the repo at all here?
879 879 # Pending decision on how comments of PRs are to be related
880 880 # to either the source repo, the target repo or no repo at all.
881 881 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
882 882 ChangesetComment.pull_request == pull_request,
883 883 ChangesetComment.pull_request_version == None)\
884 884 .order_by(ChangesetComment.comment_id.asc())
885 885
886 886 # TODO: johbo: Find out why this breaks if it is done in a bulk
887 887 # operation.
888 888 for comment in comments:
889 889 comment.pull_request_version_id = (
890 890 pull_request_version.pull_request_version_id)
891 891 Session().add(comment)
892 892
893 893 def _calculate_commit_id_changes(self, old_ids, new_ids):
894 894 added = [x for x in new_ids if x not in old_ids]
895 895 common = [x for x in new_ids if x in old_ids]
896 896 removed = [x for x in old_ids if x not in new_ids]
897 897 total = new_ids
898 898 return ChangeTuple(added, common, removed, total)
899 899
900 900 def _calculate_file_changes(self, old_diff_data, new_diff_data):
901 901
902 902 old_files = OrderedDict()
903 903 for diff_data in old_diff_data.parsed_diff:
904 904 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
905 905
906 906 added_files = []
907 907 modified_files = []
908 908 removed_files = []
909 909 for diff_data in new_diff_data.parsed_diff:
910 910 new_filename = diff_data['filename']
911 911 new_hash = md5_safe(diff_data['raw_diff'])
912 912
913 913 old_hash = old_files.get(new_filename)
914 914 if not old_hash:
915 915 # file is not present in old diff, means it's added
916 916 added_files.append(new_filename)
917 917 else:
918 918 if new_hash != old_hash:
919 919 modified_files.append(new_filename)
920 920 # now remove a file from old, since we have seen it already
921 921 del old_files[new_filename]
922 922
923 923 # removed files is when there are present in old, but not in NEW,
924 924 # since we remove old files that are present in new diff, left-overs
925 925 # if any should be the removed files
926 926 removed_files.extend(old_files.keys())
927 927
928 928 return FileChangeTuple(added_files, modified_files, removed_files)
929 929
930 930 def _render_update_message(self, changes, file_changes):
931 931 """
932 932 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
933 933 so it's always looking the same disregarding on which default
934 934 renderer system is using.
935 935
936 936 :param changes: changes named tuple
937 937 :param file_changes: file changes named tuple
938 938
939 939 """
940 940 new_status = ChangesetStatus.get_status_lbl(
941 941 ChangesetStatus.STATUS_UNDER_REVIEW)
942 942
943 943 changed_files = (
944 944 file_changes.added + file_changes.modified + file_changes.removed)
945 945
946 946 params = {
947 947 'under_review_label': new_status,
948 948 'added_commits': changes.added,
949 949 'removed_commits': changes.removed,
950 950 'changed_files': changed_files,
951 951 'added_files': file_changes.added,
952 952 'modified_files': file_changes.modified,
953 953 'removed_files': file_changes.removed,
954 954 }
955 955 renderer = RstTemplateRenderer()
956 956 return renderer.render('pull_request_update.mako', **params)
957 957
958 958 def edit(self, pull_request, title, description, description_renderer, user):
959 959 pull_request = self.__get_pull_request(pull_request)
960 960 old_data = pull_request.get_api_data(with_merge_state=False)
961 961 if pull_request.is_closed():
962 962 raise ValueError('This pull request is closed')
963 963 if title:
964 964 pull_request.title = title
965 965 pull_request.description = description
966 966 pull_request.updated_on = datetime.datetime.now()
967 967 pull_request.description_renderer = description_renderer
968 968 Session().add(pull_request)
969 969 self._log_audit_action(
970 970 'repo.pull_request.edit', {'old_data': old_data},
971 971 user, pull_request)
972 972
973 973 def update_reviewers(self, pull_request, reviewer_data, user):
974 974 """
975 975 Update the reviewers in the pull request
976 976
977 977 :param pull_request: the pr to update
978 978 :param reviewer_data: list of tuples
979 979 [(user, ['reason1', 'reason2'], mandatory_flag, [rules])]
980 980 """
981 981 pull_request = self.__get_pull_request(pull_request)
982 982 if pull_request.is_closed():
983 983 raise ValueError('This pull request is closed')
984 984
985 985 reviewers = {}
986 986 for user_id, reasons, mandatory, rules in reviewer_data:
987 987 if isinstance(user_id, (int, basestring)):
988 988 user_id = self._get_user(user_id).user_id
989 989 reviewers[user_id] = {
990 990 'reasons': reasons, 'mandatory': mandatory}
991 991
992 992 reviewers_ids = set(reviewers.keys())
993 993 current_reviewers = PullRequestReviewers.query()\
994 994 .filter(PullRequestReviewers.pull_request ==
995 995 pull_request).all()
996 996 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
997 997
998 998 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
999 999 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
1000 1000
1001 1001 log.debug("Adding %s reviewers", ids_to_add)
1002 1002 log.debug("Removing %s reviewers", ids_to_remove)
1003 1003 changed = False
1004 1004 for uid in ids_to_add:
1005 1005 changed = True
1006 1006 _usr = self._get_user(uid)
1007 1007 reviewer = PullRequestReviewers()
1008 1008 reviewer.user = _usr
1009 1009 reviewer.pull_request = pull_request
1010 1010 reviewer.reasons = reviewers[uid]['reasons']
1011 1011 # NOTE(marcink): mandatory shouldn't be changed now
1012 1012 # reviewer.mandatory = reviewers[uid]['reasons']
1013 1013 Session().add(reviewer)
1014 1014 self._log_audit_action(
1015 1015 'repo.pull_request.reviewer.add', {'data': reviewer.get_dict()},
1016 1016 user, pull_request)
1017 1017
1018 1018 for uid in ids_to_remove:
1019 1019 changed = True
1020 1020 reviewers = PullRequestReviewers.query()\
1021 1021 .filter(PullRequestReviewers.user_id == uid,
1022 1022 PullRequestReviewers.pull_request == pull_request)\
1023 1023 .all()
1024 1024 # use .all() in case we accidentally added the same person twice
1025 1025 # this CAN happen due to the lack of DB checks
1026 1026 for obj in reviewers:
1027 1027 old_data = obj.get_dict()
1028 1028 Session().delete(obj)
1029 1029 self._log_audit_action(
1030 1030 'repo.pull_request.reviewer.delete',
1031 1031 {'old_data': old_data}, user, pull_request)
1032 1032
1033 1033 if changed:
1034 1034 pull_request.updated_on = datetime.datetime.now()
1035 1035 Session().add(pull_request)
1036 1036
1037 1037 self.notify_reviewers(pull_request, ids_to_add)
1038 1038 return ids_to_add, ids_to_remove
1039 1039
1040 1040 def get_url(self, pull_request, request=None, permalink=False):
1041 1041 if not request:
1042 1042 request = get_current_request()
1043 1043
1044 1044 if permalink:
1045 1045 return request.route_url(
1046 1046 'pull_requests_global',
1047 1047 pull_request_id=pull_request.pull_request_id,)
1048 1048 else:
1049 1049 return request.route_url('pullrequest_show',
1050 1050 repo_name=safe_str(pull_request.target_repo.repo_name),
1051 1051 pull_request_id=pull_request.pull_request_id,)
1052 1052
1053 1053 def get_shadow_clone_url(self, pull_request, request=None):
1054 1054 """
1055 1055 Returns qualified url pointing to the shadow repository. If this pull
1056 1056 request is closed there is no shadow repository and ``None`` will be
1057 1057 returned.
1058 1058 """
1059 1059 if pull_request.is_closed():
1060 1060 return None
1061 1061 else:
1062 1062 pr_url = urllib.unquote(self.get_url(pull_request, request=request))
1063 1063 return safe_unicode('{pr_url}/repository'.format(pr_url=pr_url))
1064 1064
1065 1065 def notify_reviewers(self, pull_request, reviewers_ids):
1066 1066 # notification to reviewers
1067 1067 if not reviewers_ids:
1068 1068 return
1069 1069
1070 1070 pull_request_obj = pull_request
1071 1071 # get the current participants of this pull request
1072 1072 recipients = reviewers_ids
1073 1073 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
1074 1074
1075 1075 pr_source_repo = pull_request_obj.source_repo
1076 1076 pr_target_repo = pull_request_obj.target_repo
1077 1077
1078 1078 pr_url = h.route_url('pullrequest_show',
1079 1079 repo_name=pr_target_repo.repo_name,
1080 1080 pull_request_id=pull_request_obj.pull_request_id,)
1081 1081
1082 1082 # set some variables for email notification
1083 1083 pr_target_repo_url = h.route_url(
1084 1084 'repo_summary', repo_name=pr_target_repo.repo_name)
1085 1085
1086 1086 pr_source_repo_url = h.route_url(
1087 1087 'repo_summary', repo_name=pr_source_repo.repo_name)
1088 1088
1089 1089 # pull request specifics
1090 1090 pull_request_commits = [
1091 1091 (x.raw_id, x.message)
1092 1092 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
1093 1093
1094 1094 kwargs = {
1095 1095 'user': pull_request.author,
1096 1096 'pull_request': pull_request_obj,
1097 1097 'pull_request_commits': pull_request_commits,
1098 1098
1099 1099 'pull_request_target_repo': pr_target_repo,
1100 1100 'pull_request_target_repo_url': pr_target_repo_url,
1101 1101
1102 1102 'pull_request_source_repo': pr_source_repo,
1103 1103 'pull_request_source_repo_url': pr_source_repo_url,
1104 1104
1105 1105 'pull_request_url': pr_url,
1106 1106 }
1107 1107
1108 1108 # pre-generate the subject for notification itself
1109 1109 (subject,
1110 1110 _h, _e, # we don't care about those
1111 1111 body_plaintext) = EmailNotificationModel().render_email(
1112 1112 notification_type, **kwargs)
1113 1113
1114 1114 # create notification objects, and emails
1115 1115 NotificationModel().create(
1116 1116 created_by=pull_request.author,
1117 1117 notification_subject=subject,
1118 1118 notification_body=body_plaintext,
1119 1119 notification_type=notification_type,
1120 1120 recipients=recipients,
1121 1121 email_kwargs=kwargs,
1122 1122 )
1123 1123
1124 1124 def delete(self, pull_request, user):
1125 1125 pull_request = self.__get_pull_request(pull_request)
1126 1126 old_data = pull_request.get_api_data(with_merge_state=False)
1127 1127 self._cleanup_merge_workspace(pull_request)
1128 1128 self._log_audit_action(
1129 1129 'repo.pull_request.delete', {'old_data': old_data},
1130 1130 user, pull_request)
1131 1131 Session().delete(pull_request)
1132 1132
1133 1133 def close_pull_request(self, pull_request, user):
1134 1134 pull_request = self.__get_pull_request(pull_request)
1135 1135 self._cleanup_merge_workspace(pull_request)
1136 1136 pull_request.status = PullRequest.STATUS_CLOSED
1137 1137 pull_request.updated_on = datetime.datetime.now()
1138 1138 Session().add(pull_request)
1139 1139 self._trigger_pull_request_hook(
1140 1140 pull_request, pull_request.author, 'close')
1141 1141
1142 1142 pr_data = pull_request.get_api_data(with_merge_state=False)
1143 1143 self._log_audit_action(
1144 1144 'repo.pull_request.close', {'data': pr_data}, user, pull_request)
1145 1145
1146 1146 def close_pull_request_with_comment(
1147 1147 self, pull_request, user, repo, message=None, auth_user=None):
1148 1148
1149 1149 pull_request_review_status = pull_request.calculated_review_status()
1150 1150
1151 1151 if pull_request_review_status == ChangesetStatus.STATUS_APPROVED:
1152 1152 # approved only if we have voting consent
1153 1153 status = ChangesetStatus.STATUS_APPROVED
1154 1154 else:
1155 1155 status = ChangesetStatus.STATUS_REJECTED
1156 1156 status_lbl = ChangesetStatus.get_status_lbl(status)
1157 1157
1158 1158 default_message = (
1159 1159 'Closing with status change {transition_icon} {status}.'
1160 1160 ).format(transition_icon='>', status=status_lbl)
1161 1161 text = message or default_message
1162 1162
1163 1163 # create a comment, and link it to new status
1164 1164 comment = CommentsModel().create(
1165 1165 text=text,
1166 1166 repo=repo.repo_id,
1167 1167 user=user.user_id,
1168 1168 pull_request=pull_request.pull_request_id,
1169 1169 status_change=status_lbl,
1170 1170 status_change_type=status,
1171 1171 closing_pr=True,
1172 1172 auth_user=auth_user,
1173 1173 )
1174 1174
1175 1175 # calculate old status before we change it
1176 1176 old_calculated_status = pull_request.calculated_review_status()
1177 1177 ChangesetStatusModel().set_status(
1178 1178 repo.repo_id,
1179 1179 status,
1180 1180 user.user_id,
1181 1181 comment=comment,
1182 1182 pull_request=pull_request.pull_request_id
1183 1183 )
1184 1184
1185 1185 Session().flush()
1186 1186 events.trigger(events.PullRequestCommentEvent(pull_request, comment))
1187 1187 # we now calculate the status of pull request again, and based on that
1188 1188 # calculation trigger status change. This might happen in cases
1189 1189 # that non-reviewer admin closes a pr, which means his vote doesn't
1190 1190 # change the status, while if he's a reviewer this might change it.
1191 1191 calculated_status = pull_request.calculated_review_status()
1192 1192 if old_calculated_status != calculated_status:
1193 1193 self._trigger_pull_request_hook(
1194 1194 pull_request, user, 'review_status_change')
1195 1195
1196 1196 # finally close the PR
1197 1197 PullRequestModel().close_pull_request(
1198 1198 pull_request.pull_request_id, user)
1199 1199
1200 1200 return comment, status
1201 1201
1202 1202 def merge_status(self, pull_request, translator=None,
1203 1203 force_shadow_repo_refresh=False):
1204 1204 _ = translator or get_current_request().translate
1205 1205
1206 1206 if not self._is_merge_enabled(pull_request):
1207 1207 return False, _('Server-side pull request merging is disabled.')
1208 1208 if pull_request.is_closed():
1209 1209 return False, _('This pull request is closed.')
1210 1210 merge_possible, msg = self._check_repo_requirements(
1211 1211 target=pull_request.target_repo, source=pull_request.source_repo,
1212 1212 translator=_)
1213 1213 if not merge_possible:
1214 1214 return merge_possible, msg
1215 1215
1216 1216 try:
1217 1217 resp = self._try_merge(
1218 1218 pull_request,
1219 1219 force_shadow_repo_refresh=force_shadow_repo_refresh)
1220 1220 log.debug("Merge response: %s", resp)
1221 1221 status = resp.possible, resp.merge_status_message
1222 1222 except NotImplementedError:
1223 1223 status = False, _('Pull request merging is not supported.')
1224 1224
1225 1225 return status
1226 1226
1227 1227 def _check_repo_requirements(self, target, source, translator):
1228 1228 """
1229 1229 Check if `target` and `source` have compatible requirements.
1230 1230
1231 1231 Currently this is just checking for largefiles.
1232 1232 """
1233 1233 _ = translator
1234 1234 target_has_largefiles = self._has_largefiles(target)
1235 1235 source_has_largefiles = self._has_largefiles(source)
1236 1236 merge_possible = True
1237 1237 message = u''
1238 1238
1239 1239 if target_has_largefiles != source_has_largefiles:
1240 1240 merge_possible = False
1241 1241 if source_has_largefiles:
1242 1242 message = _(
1243 1243 'Target repository large files support is disabled.')
1244 1244 else:
1245 1245 message = _(
1246 1246 'Source repository large files support is disabled.')
1247 1247
1248 1248 return merge_possible, message
1249 1249
1250 1250 def _has_largefiles(self, repo):
1251 1251 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
1252 1252 'extensions', 'largefiles')
1253 1253 return largefiles_ui and largefiles_ui[0].active
1254 1254
1255 1255 def _try_merge(self, pull_request, force_shadow_repo_refresh=False):
1256 1256 """
1257 1257 Try to merge the pull request and return the merge status.
1258 1258 """
1259 1259 log.debug(
1260 1260 "Trying out if the pull request %s can be merged. Force_refresh=%s",
1261 1261 pull_request.pull_request_id, force_shadow_repo_refresh)
1262 1262 target_vcs = pull_request.target_repo.scm_instance()
1263 1263 # Refresh the target reference.
1264 1264 try:
1265 1265 target_ref = self._refresh_reference(
1266 1266 pull_request.target_ref_parts, target_vcs)
1267 1267 except CommitDoesNotExistError:
1268 1268 merge_state = MergeResponse(
1269 1269 False, False, None, MergeFailureReason.MISSING_TARGET_REF,
1270 1270 metadata={'target_ref': pull_request.target_ref_parts})
1271 1271 return merge_state
1272 1272
1273 1273 target_locked = pull_request.target_repo.locked
1274 1274 if target_locked and target_locked[0]:
1275 1275 locked_by = 'user:{}'.format(target_locked[0])
1276 1276 log.debug("The target repository is locked by %s.", locked_by)
1277 1277 merge_state = MergeResponse(
1278 1278 False, False, None, MergeFailureReason.TARGET_IS_LOCKED,
1279 1279 metadata={'locked_by': locked_by})
1280 1280 elif force_shadow_repo_refresh or self._needs_merge_state_refresh(
1281 1281 pull_request, target_ref):
1282 1282 log.debug("Refreshing the merge status of the repository.")
1283 1283 merge_state = self._refresh_merge_state(
1284 1284 pull_request, target_vcs, target_ref)
1285 1285 else:
1286 1286 possible = pull_request.\
1287 1287 last_merge_status == MergeFailureReason.NONE
1288 1288 merge_state = MergeResponse(
1289 1289 possible, False, None, pull_request.last_merge_status)
1290 1290
1291 1291 return merge_state
1292 1292
1293 1293 def _refresh_reference(self, reference, vcs_repository):
1294 1294 if reference.type in self.UPDATABLE_REF_TYPES:
1295 1295 name_or_id = reference.name
1296 1296 else:
1297 1297 name_or_id = reference.commit_id
1298 1298 refreshed_commit = vcs_repository.get_commit(name_or_id)
1299 1299 refreshed_reference = Reference(
1300 1300 reference.type, reference.name, refreshed_commit.raw_id)
1301 1301 return refreshed_reference
1302 1302
1303 1303 def _needs_merge_state_refresh(self, pull_request, target_reference):
1304 1304 return not(
1305 1305 pull_request.revisions and
1306 1306 pull_request.revisions[0] == pull_request._last_merge_source_rev and
1307 1307 target_reference.commit_id == pull_request._last_merge_target_rev)
1308 1308
1309 1309 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
1310 1310 workspace_id = self._workspace_id(pull_request)
1311 1311 source_vcs = pull_request.source_repo.scm_instance()
1312 1312 repo_id = pull_request.target_repo.repo_id
1313 1313 use_rebase = self._use_rebase_for_merging(pull_request)
1314 1314 close_branch = self._close_branch_before_merging(pull_request)
1315 1315 merge_state = target_vcs.merge(
1316 1316 repo_id, workspace_id,
1317 1317 target_reference, source_vcs, pull_request.source_ref_parts,
1318 1318 dry_run=True, use_rebase=use_rebase,
1319 1319 close_branch=close_branch)
1320 1320
1321 1321 # Do not store the response if there was an unknown error.
1322 1322 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
1323 1323 pull_request._last_merge_source_rev = \
1324 1324 pull_request.source_ref_parts.commit_id
1325 1325 pull_request._last_merge_target_rev = target_reference.commit_id
1326 1326 pull_request.last_merge_status = merge_state.failure_reason
1327 1327 pull_request.shadow_merge_ref = merge_state.merge_ref
1328 1328 Session().add(pull_request)
1329 1329 Session().commit()
1330 1330
1331 1331 return merge_state
1332 1332
1333 1333 def _workspace_id(self, pull_request):
1334 1334 workspace_id = 'pr-%s' % pull_request.pull_request_id
1335 1335 return workspace_id
1336 1336
1337 1337 def generate_repo_data(self, repo, commit_id=None, branch=None,
1338 1338 bookmark=None, translator=None):
1339 1339 from rhodecode.model.repo import RepoModel
1340 1340
1341 1341 all_refs, selected_ref = \
1342 1342 self._get_repo_pullrequest_sources(
1343 1343 repo.scm_instance(), commit_id=commit_id,
1344 1344 branch=branch, bookmark=bookmark, translator=translator)
1345 1345
1346 1346 refs_select2 = []
1347 1347 for element in all_refs:
1348 1348 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
1349 1349 refs_select2.append({'text': element[1], 'children': children})
1350 1350
1351 1351 return {
1352 1352 'user': {
1353 1353 'user_id': repo.user.user_id,
1354 1354 'username': repo.user.username,
1355 1355 'firstname': repo.user.first_name,
1356 1356 'lastname': repo.user.last_name,
1357 1357 'gravatar_link': h.gravatar_url(repo.user.email, 14),
1358 1358 },
1359 1359 'name': repo.repo_name,
1360 1360 'link': RepoModel().get_url(repo),
1361 1361 'description': h.chop_at_smart(repo.description_safe, '\n'),
1362 1362 'refs': {
1363 1363 'all_refs': all_refs,
1364 1364 'selected_ref': selected_ref,
1365 1365 'select2_refs': refs_select2
1366 1366 }
1367 1367 }
1368 1368
1369 1369 def generate_pullrequest_title(self, source, source_ref, target):
1370 1370 return u'{source}#{at_ref} to {target}'.format(
1371 1371 source=source,
1372 1372 at_ref=source_ref,
1373 1373 target=target,
1374 1374 )
1375 1375
1376 1376 def _cleanup_merge_workspace(self, pull_request):
1377 1377 # Merging related cleanup
1378 1378 repo_id = pull_request.target_repo.repo_id
1379 1379 target_scm = pull_request.target_repo.scm_instance()
1380 1380 workspace_id = self._workspace_id(pull_request)
1381 1381
1382 1382 try:
1383 1383 target_scm.cleanup_merge_workspace(repo_id, workspace_id)
1384 1384 except NotImplementedError:
1385 1385 pass
1386 1386
1387 1387 def _get_repo_pullrequest_sources(
1388 1388 self, repo, commit_id=None, branch=None, bookmark=None,
1389 1389 translator=None):
1390 1390 """
1391 1391 Return a structure with repo's interesting commits, suitable for
1392 1392 the selectors in pullrequest controller
1393 1393
1394 1394 :param commit_id: a commit that must be in the list somehow
1395 1395 and selected by default
1396 1396 :param branch: a branch that must be in the list and selected
1397 1397 by default - even if closed
1398 1398 :param bookmark: a bookmark that must be in the list and selected
1399 1399 """
1400 1400 _ = translator or get_current_request().translate
1401 1401
1402 1402 commit_id = safe_str(commit_id) if commit_id else None
1403 1403 branch = safe_str(branch) if branch else None
1404 1404 bookmark = safe_str(bookmark) if bookmark else None
1405 1405
1406 1406 selected = None
1407 1407
1408 1408 # order matters: first source that has commit_id in it will be selected
1409 1409 sources = []
1410 1410 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1411 1411 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1412 1412
1413 1413 if commit_id:
1414 1414 ref_commit = (h.short_id(commit_id), commit_id)
1415 1415 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1416 1416
1417 1417 sources.append(
1418 1418 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1419 1419 )
1420 1420
1421 1421 groups = []
1422 1422 for group_key, ref_list, group_name, match in sources:
1423 1423 group_refs = []
1424 1424 for ref_name, ref_id in ref_list:
1425 1425 ref_key = '%s:%s:%s' % (group_key, ref_name, ref_id)
1426 1426 group_refs.append((ref_key, ref_name))
1427 1427
1428 1428 if not selected:
1429 1429 if set([commit_id, match]) & set([ref_id, ref_name]):
1430 1430 selected = ref_key
1431 1431
1432 1432 if group_refs:
1433 1433 groups.append((group_refs, group_name))
1434 1434
1435 1435 if not selected:
1436 1436 ref = commit_id or branch or bookmark
1437 1437 if ref:
1438 1438 raise CommitDoesNotExistError(
1439 1439 'No commit refs could be found matching: %s' % ref)
1440 1440 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1441 1441 selected = 'branch:%s:%s' % (
1442 1442 repo.DEFAULT_BRANCH_NAME,
1443 1443 repo.branches[repo.DEFAULT_BRANCH_NAME]
1444 1444 )
1445 1445 elif repo.commit_ids:
1446 1446 # make the user select in this case
1447 1447 selected = None
1448 1448 else:
1449 1449 raise EmptyRepositoryError()
1450 1450 return groups, selected
1451 1451
1452 1452 def get_diff(self, source_repo, source_ref_id, target_ref_id,
1453 1453 hide_whitespace_changes, diff_context):
1454 1454
1455 1455 return self._get_diff_from_pr_or_version(
1456 1456 source_repo, source_ref_id, target_ref_id,
1457 1457 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
1458 1458
1459 1459 def _get_diff_from_pr_or_version(
1460 1460 self, source_repo, source_ref_id, target_ref_id,
1461 1461 hide_whitespace_changes, diff_context):
1462 1462
1463 1463 target_commit = source_repo.get_commit(
1464 1464 commit_id=safe_str(target_ref_id))
1465 1465 source_commit = source_repo.get_commit(
1466 1466 commit_id=safe_str(source_ref_id))
1467 1467 if isinstance(source_repo, Repository):
1468 1468 vcs_repo = source_repo.scm_instance()
1469 1469 else:
1470 1470 vcs_repo = source_repo
1471 1471
1472 1472 # TODO: johbo: In the context of an update, we cannot reach
1473 1473 # the old commit anymore with our normal mechanisms. It needs
1474 1474 # some sort of special support in the vcs layer to avoid this
1475 1475 # workaround.
1476 1476 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1477 1477 vcs_repo.alias == 'git'):
1478 1478 source_commit.raw_id = safe_str(source_ref_id)
1479 1479
1480 1480 log.debug('calculating diff between '
1481 1481 'source_ref:%s and target_ref:%s for repo `%s`',
1482 1482 target_ref_id, source_ref_id,
1483 1483 safe_unicode(vcs_repo.path))
1484 1484
1485 1485 vcs_diff = vcs_repo.get_diff(
1486 1486 commit1=target_commit, commit2=source_commit,
1487 1487 ignore_whitespace=hide_whitespace_changes, context=diff_context)
1488 1488 return vcs_diff
1489 1489
1490 1490 def _is_merge_enabled(self, pull_request):
1491 1491 return self._get_general_setting(
1492 1492 pull_request, 'rhodecode_pr_merge_enabled')
1493 1493
1494 1494 def _use_rebase_for_merging(self, pull_request):
1495 1495 repo_type = pull_request.target_repo.repo_type
1496 1496 if repo_type == 'hg':
1497 1497 return self._get_general_setting(
1498 1498 pull_request, 'rhodecode_hg_use_rebase_for_merging')
1499 1499 elif repo_type == 'git':
1500 1500 return self._get_general_setting(
1501 1501 pull_request, 'rhodecode_git_use_rebase_for_merging')
1502 1502
1503 1503 return False
1504 1504
1505 1505 def _close_branch_before_merging(self, pull_request):
1506 1506 repo_type = pull_request.target_repo.repo_type
1507 1507 if repo_type == 'hg':
1508 1508 return self._get_general_setting(
1509 1509 pull_request, 'rhodecode_hg_close_branch_before_merging')
1510 1510 elif repo_type == 'git':
1511 1511 return self._get_general_setting(
1512 1512 pull_request, 'rhodecode_git_close_branch_before_merging')
1513 1513
1514 1514 return False
1515 1515
1516 1516 def _get_general_setting(self, pull_request, settings_key, default=False):
1517 1517 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1518 1518 settings = settings_model.get_general_settings()
1519 1519 return settings.get(settings_key, default)
1520 1520
1521 1521 def _log_audit_action(self, action, action_data, user, pull_request):
1522 1522 audit_logger.store(
1523 1523 action=action,
1524 1524 action_data=action_data,
1525 1525 user=user,
1526 1526 repo=pull_request.target_repo)
1527 1527
1528 1528 def get_reviewer_functions(self):
1529 1529 """
1530 1530 Fetches functions for validation and fetching default reviewers.
1531 1531 If available we use the EE package, else we fallback to CE
1532 1532 package functions
1533 1533 """
1534 1534 try:
1535 1535 from rc_reviewers.utils import get_default_reviewers_data
1536 1536 from rc_reviewers.utils import validate_default_reviewers
1537 1537 except ImportError:
1538 1538 from rhodecode.apps.repository.utils import get_default_reviewers_data
1539 1539 from rhodecode.apps.repository.utils import validate_default_reviewers
1540 1540
1541 1541 return get_default_reviewers_data, validate_default_reviewers
1542 1542
1543 1543
1544 1544 class MergeCheck(object):
1545 1545 """
1546 1546 Perform Merge Checks and returns a check object which stores information
1547 1547 about merge errors, and merge conditions
1548 1548 """
1549 1549 TODO_CHECK = 'todo'
1550 1550 PERM_CHECK = 'perm'
1551 1551 REVIEW_CHECK = 'review'
1552 1552 MERGE_CHECK = 'merge'
1553 1553
1554 1554 def __init__(self):
1555 1555 self.review_status = None
1556 1556 self.merge_possible = None
1557 1557 self.merge_msg = ''
1558 1558 self.failed = None
1559 1559 self.errors = []
1560 1560 self.error_details = OrderedDict()
1561 1561
1562 1562 def push_error(self, error_type, message, error_key, details):
1563 1563 self.failed = True
1564 1564 self.errors.append([error_type, message])
1565 1565 self.error_details[error_key] = dict(
1566 1566 details=details,
1567 1567 error_type=error_type,
1568 1568 message=message
1569 1569 )
1570 1570
1571 1571 @classmethod
1572 1572 def validate(cls, pull_request, auth_user, translator, fail_early=False,
1573 1573 force_shadow_repo_refresh=False):
1574 1574 _ = translator
1575 1575 merge_check = cls()
1576 1576
1577 1577 # permissions to merge
1578 1578 user_allowed_to_merge = PullRequestModel().check_user_merge(
1579 1579 pull_request, auth_user)
1580 1580 if not user_allowed_to_merge:
1581 1581 log.debug("MergeCheck: cannot merge, approval is pending.")
1582 1582
1583 1583 msg = _('User `{}` not allowed to perform merge.').format(auth_user.username)
1584 1584 merge_check.push_error('error', msg, cls.PERM_CHECK, auth_user.username)
1585 1585 if fail_early:
1586 1586 return merge_check
1587 1587
1588 1588 # permission to merge into the target branch
1589 1589 target_commit_id = pull_request.target_ref_parts.commit_id
1590 1590 if pull_request.target_ref_parts.type == 'branch':
1591 1591 branch_name = pull_request.target_ref_parts.name
1592 1592 else:
1593 1593 # for mercurial we can always figure out the branch from the commit
1594 1594 # in case of bookmark
1595 1595 target_commit = pull_request.target_repo.get_commit(target_commit_id)
1596 1596 branch_name = target_commit.branch
1597 1597
1598 1598 rule, branch_perm = auth_user.get_rule_and_branch_permission(
1599 1599 pull_request.target_repo.repo_name, branch_name)
1600 1600 if branch_perm and branch_perm == 'branch.none':
1601 1601 msg = _('Target branch `{}` changes rejected by rule {}.').format(
1602 1602 branch_name, rule)
1603 1603 merge_check.push_error('error', msg, cls.PERM_CHECK, auth_user.username)
1604 1604 if fail_early:
1605 1605 return merge_check
1606 1606
1607 1607 # review status, must be always present
1608 1608 review_status = pull_request.calculated_review_status()
1609 1609 merge_check.review_status = review_status
1610 1610
1611 1611 status_approved = review_status == ChangesetStatus.STATUS_APPROVED
1612 1612 if not status_approved:
1613 1613 log.debug("MergeCheck: cannot merge, approval is pending.")
1614 1614
1615 1615 msg = _('Pull request reviewer approval is pending.')
1616 1616
1617 1617 merge_check.push_error(
1618 1618 'warning', msg, cls.REVIEW_CHECK, review_status)
1619 1619
1620 1620 if fail_early:
1621 1621 return merge_check
1622 1622
1623 1623 # left over TODOs
1624 1624 todos = CommentsModel().get_unresolved_todos(pull_request)
1625 1625 if todos:
1626 1626 log.debug("MergeCheck: cannot merge, {} "
1627 1627 "unresolved todos left.".format(len(todos)))
1628 1628
1629 1629 if len(todos) == 1:
1630 1630 msg = _('Cannot merge, {} TODO still not resolved.').format(
1631 1631 len(todos))
1632 1632 else:
1633 1633 msg = _('Cannot merge, {} TODOs still not resolved.').format(
1634 1634 len(todos))
1635 1635
1636 1636 merge_check.push_error('warning', msg, cls.TODO_CHECK, todos)
1637 1637
1638 1638 if fail_early:
1639 1639 return merge_check
1640 1640
1641 1641 # merge possible, here is the filesystem simulation + shadow repo
1642 1642 merge_status, msg = PullRequestModel().merge_status(
1643 1643 pull_request, translator=translator,
1644 1644 force_shadow_repo_refresh=force_shadow_repo_refresh)
1645 1645 merge_check.merge_possible = merge_status
1646 1646 merge_check.merge_msg = msg
1647 1647 if not merge_status:
1648 1648 log.debug(
1649 1649 "MergeCheck: cannot merge, pull request merge not possible.")
1650 1650 merge_check.push_error('warning', msg, cls.MERGE_CHECK, None)
1651 1651
1652 1652 if fail_early:
1653 1653 return merge_check
1654 1654
1655 1655 log.debug('MergeCheck: is failed: %s', merge_check.failed)
1656 1656 return merge_check
1657 1657
1658 1658 @classmethod
1659 1659 def get_merge_conditions(cls, pull_request, translator):
1660 1660 _ = translator
1661 1661 merge_details = {}
1662 1662
1663 1663 model = PullRequestModel()
1664 1664 use_rebase = model._use_rebase_for_merging(pull_request)
1665 1665
1666 1666 if use_rebase:
1667 1667 merge_details['merge_strategy'] = dict(
1668 1668 details={},
1669 1669 message=_('Merge strategy: rebase')
1670 1670 )
1671 1671 else:
1672 1672 merge_details['merge_strategy'] = dict(
1673 1673 details={},
1674 1674 message=_('Merge strategy: explicit merge commit')
1675 1675 )
1676 1676
1677 1677 close_branch = model._close_branch_before_merging(pull_request)
1678 1678 if close_branch:
1679 1679 repo_type = pull_request.target_repo.repo_type
1680 1680 if repo_type == 'hg':
1681 1681 close_msg = _('Source branch will be closed after merge.')
1682 1682 elif repo_type == 'git':
1683 1683 close_msg = _('Source branch will be deleted after merge.')
1684 1684
1685 1685 merge_details['close_branch'] = dict(
1686 1686 details={},
1687 1687 message=close_msg
1688 1688 )
1689 1689
1690 1690 return merge_details
1691 1691
1692 1692 ChangeTuple = collections.namedtuple(
1693 1693 'ChangeTuple', ['added', 'common', 'removed', 'total'])
1694 1694
1695 1695 FileChangeTuple = collections.namedtuple(
1696 1696 'FileChangeTuple', ['added', 'modified', 'removed'])
General Comments 0
You need to be logged in to leave comments. Login now