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