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