##// END OF EJS Templates
pull requests: give slightly more useful error messages on failing form validation
Mads Kiilerich -
r4080:b622e684 default
parent child Browse files
Show More
@@ -1,552 +1,552 b''
1 1 # -*- coding: utf-8 -*-
2 2 """
3 3 rhodecode.controllers.pullrequests
4 4 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
5 5
6 6 pull requests controller for rhodecode for initializing pull requests
7 7
8 8 :created_on: May 7, 2012
9 9 :author: marcink
10 10 :copyright: (C) 2010-2012 Marcin Kuzminski <marcin@python-works.com>
11 11 :license: GPLv3, see COPYING for more details.
12 12 """
13 13 # This program is free software: you can redistribute it and/or modify
14 14 # it under the terms of the GNU General Public License as published by
15 15 # the Free Software Foundation, either version 3 of the License, or
16 16 # (at your option) any later version.
17 17 #
18 18 # This program is distributed in the hope that it will be useful,
19 19 # but WITHOUT ANY WARRANTY; without even the implied warranty of
20 20 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21 21 # GNU General Public License for more details.
22 22 #
23 23 # You should have received a copy of the GNU General Public License
24 24 # along with this program. If not, see <http://www.gnu.org/licenses/>.
25 25 import logging
26 26 import traceback
27 27 import formencode
28 28
29 29 from webob.exc import HTTPNotFound, HTTPForbidden
30 30 from collections import defaultdict
31 31 from itertools import groupby
32 32
33 33 from pylons import request, tmpl_context as c, url
34 34 from pylons.controllers.util import redirect
35 35 from pylons.i18n.translation import _
36 36
37 37 from rhodecode.lib.compat import json
38 38 from rhodecode.lib.base import BaseRepoController, render
39 39 from rhodecode.lib.auth import LoginRequired, HasRepoPermissionAnyDecorator,\
40 40 NotAnonymous
41 41 from rhodecode.lib.helpers import Page
42 42 from rhodecode.lib import helpers as h
43 43 from rhodecode.lib import diffs
44 44 from rhodecode.lib.utils import action_logger, jsonify
45 45 from rhodecode.lib.vcs.utils import safe_str
46 46 from rhodecode.lib.vcs.exceptions import EmptyRepositoryError
47 47 from rhodecode.lib.diffs import LimitedDiffContainer
48 48 from rhodecode.model.db import PullRequest, ChangesetStatus, ChangesetComment
49 49 from rhodecode.model.pull_request import PullRequestModel
50 50 from rhodecode.model.meta import Session
51 51 from rhodecode.model.repo import RepoModel
52 52 from rhodecode.model.comment import ChangesetCommentsModel
53 53 from rhodecode.model.changeset_status import ChangesetStatusModel
54 54 from rhodecode.model.forms import PullRequestForm
55 55 from rhodecode.lib.utils2 import safe_int
56 56
57 57 log = logging.getLogger(__name__)
58 58
59 59
60 60 class PullrequestsController(BaseRepoController):
61 61
62 62 def __before__(self):
63 63 super(PullrequestsController, self).__before__()
64 64 repo_model = RepoModel()
65 65 c.users_array = repo_model.get_users_js()
66 66 c.users_groups_array = repo_model.get_users_groups_js()
67 67
68 68 def _get_repo_refs(self, repo, rev=None, branch=None, branch_rev=None):
69 69 """return a structure with repo's interesting changesets, suitable for
70 70 the selectors in pullrequest.html
71 71
72 72 rev: a revision that must be in the list somehow and selected by default
73 73 branch: a branch that must be in the list and selected by default - even if closed
74 74 branch_rev: a revision of which peers should be preferred and available."""
75 75 # list named branches that has been merged to this named branch - it should probably merge back
76 76 peers = []
77 77
78 78 if rev:
79 79 rev = safe_str(rev)
80 80
81 81 if branch:
82 82 branch = safe_str(branch)
83 83
84 84 if branch_rev:
85 85 branch_rev = safe_str(branch_rev)
86 86 # not restricting to merge() would also get branch point and be better
87 87 # (especially because it would get the branch point) ... but is currently too expensive
88 88 otherbranches = {}
89 89 for i in repo._repo.revs(
90 90 "sort(parents(branch(id(%s)) and merge()) - branch(id(%s)))",
91 91 branch_rev, branch_rev):
92 92 cs = repo.get_changeset(i)
93 93 otherbranches[cs.branch] = cs.raw_id
94 94 for abranch, node in otherbranches.iteritems():
95 95 selected = 'branch:%s:%s' % (abranch, node)
96 96 peers.append((selected, abranch))
97 97
98 98 selected = None
99 99
100 100 branches = []
101 101 for abranch, branchrev in repo.branches.iteritems():
102 102 n = 'branch:%s:%s' % (abranch, branchrev)
103 103 branches.append((n, abranch))
104 104 if rev == branchrev:
105 105 selected = n
106 106 if branch == abranch:
107 107 selected = n
108 108 branch = None
109 109 if branch: # branch not in list - it is probably closed
110 110 revs = repo._repo.revs('max(branch(%s))', branch)
111 111 if revs:
112 112 cs = repo.get_changeset(revs[0])
113 113 selected = 'branch:%s:%s' % (branch, cs.raw_id)
114 114 branches.append((selected, branch))
115 115
116 116 bookmarks = []
117 117 for bookmark, bookmarkrev in repo.bookmarks.iteritems():
118 118 n = 'book:%s:%s' % (bookmark, bookmarkrev)
119 119 bookmarks.append((n, bookmark))
120 120 if rev == bookmarkrev:
121 121 selected = n
122 122
123 123 tags = []
124 124 for tag, tagrev in repo.tags.iteritems():
125 125 n = 'tag:%s:%s' % (tag, tagrev)
126 126 tags.append((n, tag))
127 127 if rev == tagrev and tag != 'tip': # tip is not a real tag - and its branch is better
128 128 selected = n
129 129
130 130 # prio 1: rev was selected as existing entry above
131 131
132 132 # prio 2: create special entry for rev; rev _must_ be used
133 133 specials = []
134 134 if rev and selected is None:
135 135 selected = 'rev:%s:%s' % (rev, rev)
136 136 specials = [(selected, '%s: %s' % (_("Changeset"), rev[:12]))]
137 137
138 138 # prio 3: most recent peer branch
139 139 if peers and not selected:
140 140 selected = peers[0][0][0]
141 141
142 142 # prio 4: tip revision
143 143 if not selected:
144 144 selected = 'tag:tip:%s' % repo.tags['tip']
145 145
146 146 groups = [(specials, _("Special")),
147 147 (peers, _("Peer branches")),
148 148 (bookmarks, _("Bookmarks")),
149 149 (branches, _("Branches")),
150 150 (tags, _("Tags")),
151 151 ]
152 152 return [g for g in groups if g[0]], selected
153 153
154 154 def _get_is_allowed_change_status(self, pull_request):
155 155 owner = self.rhodecode_user.user_id == pull_request.user_id
156 156 reviewer = self.rhodecode_user.user_id in [x.user_id for x in
157 157 pull_request.reviewers]
158 158 return (self.rhodecode_user.admin or owner or reviewer)
159 159
160 160 def _load_compare_data(self, pull_request, enable_comments=True):
161 161 """
162 162 Load context data needed for generating compare diff
163 163
164 164 :param pull_request:
165 165 """
166 166 org_repo = pull_request.org_repo
167 167 (org_ref_type,
168 168 org_ref_name,
169 169 org_ref_rev) = pull_request.org_ref.split(':')
170 170
171 171 other_repo = org_repo
172 172 (other_ref_type,
173 173 other_ref_name,
174 174 other_ref_rev) = pull_request.other_ref.split(':')
175 175
176 176 # despite opening revisions for bookmarks/branches/tags, we always
177 177 # convert this to rev to prevent changes after bookmark or branch change
178 178 org_ref = ('rev', org_ref_rev)
179 179 other_ref = ('rev', other_ref_rev)
180 180
181 181 c.org_repo = org_repo
182 182 c.other_repo = other_repo
183 183
184 184 c.fulldiff = fulldiff = request.GET.get('fulldiff')
185 185
186 186 c.cs_ranges = [org_repo.get_changeset(x) for x in pull_request.revisions]
187 187
188 188 c.statuses = org_repo.statuses([x.raw_id for x in c.cs_ranges])
189 189
190 190 c.org_ref = org_ref[1]
191 191 c.org_ref_type = org_ref[0]
192 192 c.other_ref = other_ref[1]
193 193 c.other_ref_type = other_ref[0]
194 194
195 195 diff_limit = self.cut_off_limit if not fulldiff else None
196 196
197 197 # we swap org/other ref since we run a simple diff on one repo
198 198 log.debug('running diff between %s and %s in %s'
199 199 % (other_ref, org_ref, org_repo.scm_instance.path))
200 200 txtdiff = org_repo.scm_instance.get_diff(rev1=safe_str(other_ref[1]), rev2=safe_str(org_ref[1]))
201 201
202 202 diff_processor = diffs.DiffProcessor(txtdiff or '', format='gitdiff',
203 203 diff_limit=diff_limit)
204 204 _parsed = diff_processor.prepare()
205 205
206 206 c.limited_diff = False
207 207 if isinstance(_parsed, LimitedDiffContainer):
208 208 c.limited_diff = True
209 209
210 210 c.files = []
211 211 c.changes = {}
212 212 c.lines_added = 0
213 213 c.lines_deleted = 0
214 214
215 215 for f in _parsed:
216 216 st = f['stats']
217 217 c.lines_added += st['added']
218 218 c.lines_deleted += st['deleted']
219 219 fid = h.FID('', f['filename'])
220 220 c.files.append([fid, f['operation'], f['filename'], f['stats']])
221 221 htmldiff = diff_processor.as_html(enable_comments=enable_comments,
222 222 parsed_lines=[f])
223 223 c.changes[fid] = [f['operation'], f['filename'], htmldiff]
224 224
225 225 @LoginRequired()
226 226 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
227 227 'repository.admin')
228 228 def show_all(self, repo_name):
229 229 c.from_ = request.GET.get('from_') or ''
230 230 c.closed = request.GET.get('closed') or ''
231 231 c.pull_requests = PullRequestModel().get_all(repo_name, from_=c.from_, closed=c.closed)
232 232 c.repo_name = repo_name
233 233 p = safe_int(request.GET.get('page', 1), 1)
234 234
235 235 c.pullrequests_pager = Page(c.pull_requests, page=p, items_per_page=10)
236 236
237 237 c.pullrequest_data = render('/pullrequests/pullrequest_data.html')
238 238
239 239 if request.environ.get('HTTP_X_PARTIAL_XHR'):
240 240 return c.pullrequest_data
241 241
242 242 return render('/pullrequests/pullrequest_show_all.html')
243 243
244 244 @LoginRequired()
245 245 @NotAnonymous()
246 246 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
247 247 'repository.admin')
248 248 def index(self):
249 249 org_repo = c.rhodecode_db_repo
250 250
251 251 if org_repo.scm_instance.alias != 'hg':
252 252 log.error('Review not available for GIT REPOS')
253 253 raise HTTPNotFound
254 254
255 255 try:
256 256 org_repo.scm_instance.get_changeset()
257 257 except EmptyRepositoryError, e:
258 258 h.flash(h.literal(_('There are no changesets yet')),
259 259 category='warning')
260 260 redirect(url('summary_home', repo_name=org_repo.repo_name))
261 261
262 262 org_rev = request.GET.get('rev_end')
263 263 # rev_start is not directly useful - its parent could however be used
264 264 # as default for other and thus give a simple compare view
265 265 #other_rev = request.POST.get('rev_start')
266 266 branch = request.GET.get('branch')
267 267
268 268 c.org_repos = []
269 269 c.org_repos.append((org_repo.repo_name, org_repo.repo_name))
270 270 c.default_org_repo = org_repo.repo_name
271 271 c.org_refs, c.default_org_ref = self._get_repo_refs(org_repo.scm_instance, rev=org_rev, branch=branch)
272 272
273 273 c.other_repos = []
274 274 other_repos_info = {}
275 275
276 276 def add_other_repo(repo, branch_rev=None):
277 277 if repo.repo_name in other_repos_info: # shouldn't happen
278 278 return
279 279 c.other_repos.append((repo.repo_name, repo.repo_name))
280 280 other_refs, selected_other_ref = self._get_repo_refs(repo.scm_instance, branch_rev=branch_rev)
281 281 other_repos_info[repo.repo_name] = {
282 282 'user': dict(user_id=repo.user.user_id,
283 283 username=repo.user.username,
284 284 firstname=repo.user.firstname,
285 285 lastname=repo.user.lastname,
286 286 gravatar_link=h.gravatar_url(repo.user.email, 14)),
287 287 'description': repo.description.split('\n', 1)[0],
288 288 'revs': h.select('other_ref', selected_other_ref, other_refs, class_='refs')
289 289 }
290 290
291 291 # add org repo to other so we can open pull request against peer branches on itself
292 292 add_other_repo(org_repo, branch_rev=org_rev)
293 293 c.default_other_repo = org_repo.repo_name
294 294
295 295 # gather forks and add to this list ... even though it is rare to
296 296 # request forks to pull from their parent
297 297 for fork in org_repo.forks:
298 298 add_other_repo(fork)
299 299
300 300 # add parents of this fork also, but only if it's not empty
301 301 if org_repo.parent and org_repo.parent.scm_instance.revisions:
302 302 add_other_repo(org_repo.parent)
303 303 c.default_other_repo = org_repo.parent.repo_name
304 304
305 305 c.default_other_repo_info = other_repos_info[c.default_other_repo]
306 306 c.other_repos_info = json.dumps(other_repos_info)
307 307
308 308 return render('/pullrequests/pullrequest.html')
309 309
310 310 @LoginRequired()
311 311 @NotAnonymous()
312 312 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
313 313 'repository.admin')
314 314 def create(self, repo_name):
315 315 repo = RepoModel()._get_repo(repo_name)
316 316 try:
317 317 _form = PullRequestForm(repo.repo_id)().to_python(request.POST)
318 318 except formencode.Invalid, errors:
319 319 log.error(traceback.format_exc())
320 320 if errors.error_dict.get('revisions'):
321 321 msg = 'Revisions: %s' % errors.error_dict['revisions']
322 322 elif errors.error_dict.get('pullrequest_title'):
323 323 msg = _('Pull request requires a title with min. 3 chars')
324 324 else:
325 msg = _('Error creating pull request')
325 msg = _('Error creating pull request: %s') % errors.msg
326 326
327 327 h.flash(msg, 'error')
328 return redirect(url('pullrequest_home', repo_name=repo_name))
328 return redirect(url('pullrequest_home', repo_name=repo_name)) ## would rather just go back to form ...
329 329
330 330 org_repo = _form['org_repo']
331 331 org_ref = _form['org_ref'] # will end with merge_rev but have symbolic name
332 332 other_repo = _form['other_repo']
333 333 other_ref = 'rev:ancestor:%s' % _form['ancestor_rev'] # could be calculated from other_ref ...
334 334 revisions = [x for x in reversed(_form['revisions'])]
335 335 reviewers = _form['review_members']
336 336
337 337 title = _form['pullrequest_title']
338 338 if not title:
339 339 title = '%s#%s to %s' % (org_repo, org_ref.split(':', 2)[1], other_repo)
340 340 description = _form['pullrequest_desc']
341 341 try:
342 342 pull_request = PullRequestModel().create(
343 343 self.rhodecode_user.user_id, org_repo, org_ref, other_repo,
344 344 other_ref, revisions, reviewers, title, description
345 345 )
346 346 Session().commit()
347 347 h.flash(_('Successfully opened new pull request'),
348 348 category='success')
349 349 except Exception:
350 350 h.flash(_('Error occurred during sending pull request'),
351 351 category='error')
352 352 log.error(traceback.format_exc())
353 353 return redirect(url('pullrequest_home', repo_name=repo_name))
354 354
355 355 return redirect(url('pullrequest_show', repo_name=other_repo,
356 356 pull_request_id=pull_request.pull_request_id))
357 357
358 358 @LoginRequired()
359 359 @NotAnonymous()
360 360 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
361 361 'repository.admin')
362 362 @jsonify
363 363 def update(self, repo_name, pull_request_id):
364 364 pull_request = PullRequest.get_or_404(pull_request_id)
365 365 if pull_request.is_closed():
366 366 raise HTTPForbidden()
367 367 #only owner or admin can update it
368 368 owner = pull_request.author.user_id == c.rhodecode_user.user_id
369 369 if h.HasPermissionAny('hg.admin', 'repository.admin')() or owner:
370 370 reviewers_ids = map(int, filter(lambda v: v not in [None, ''],
371 371 request.POST.get('reviewers_ids', '').split(',')))
372 372
373 373 PullRequestModel().update_reviewers(pull_request_id, reviewers_ids)
374 374 Session().commit()
375 375 return True
376 376 raise HTTPForbidden()
377 377
378 378 @LoginRequired()
379 379 @NotAnonymous()
380 380 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
381 381 'repository.admin')
382 382 @jsonify
383 383 def delete(self, repo_name, pull_request_id):
384 384 pull_request = PullRequest.get_or_404(pull_request_id)
385 385 #only owner can delete it !
386 386 if pull_request.author.user_id == c.rhodecode_user.user_id:
387 387 PullRequestModel().delete(pull_request)
388 388 Session().commit()
389 389 h.flash(_('Successfully deleted pull request'),
390 390 category='success')
391 391 return redirect(url('admin_settings_my_account', anchor='pullrequests'))
392 392 raise HTTPForbidden()
393 393
394 394 @LoginRequired()
395 395 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
396 396 'repository.admin')
397 397 def show(self, repo_name, pull_request_id):
398 398 repo_model = RepoModel()
399 399 c.users_array = repo_model.get_users_js()
400 400 c.users_groups_array = repo_model.get_users_groups_js()
401 401 c.pull_request = PullRequest.get_or_404(pull_request_id)
402 402 c.allowed_to_change_status = self._get_is_allowed_change_status(c.pull_request)
403 403 cc_model = ChangesetCommentsModel()
404 404 cs_model = ChangesetStatusModel()
405 405 _cs_statuses = cs_model.get_statuses(c.pull_request.org_repo,
406 406 pull_request=c.pull_request,
407 407 with_revisions=True)
408 408
409 409 cs_statuses = defaultdict(list)
410 410 for st in _cs_statuses:
411 411 cs_statuses[st.author.username] += [st]
412 412
413 413 c.pull_request_reviewers = []
414 414 c.pull_request_pending_reviewers = []
415 415 for o in c.pull_request.reviewers:
416 416 st = cs_statuses.get(o.user.username, None)
417 417 if st:
418 418 sorter = lambda k: k.version
419 419 st = [(x, list(y)[0])
420 420 for x, y in (groupby(sorted(st, key=sorter), sorter))]
421 421 else:
422 422 c.pull_request_pending_reviewers.append(o.user)
423 423 c.pull_request_reviewers.append([o.user, st])
424 424
425 425 # pull_requests repo_name we opened it against
426 426 # ie. other_repo must match
427 427 if repo_name != c.pull_request.other_repo.repo_name:
428 428 raise HTTPNotFound
429 429
430 430 # load compare data into template context
431 431 enable_comments = not c.pull_request.is_closed()
432 432 self._load_compare_data(c.pull_request, enable_comments=enable_comments)
433 433
434 434 # inline comments
435 435 c.inline_cnt = 0
436 436 c.inline_comments = cc_model.get_inline_comments(
437 437 c.rhodecode_db_repo.repo_id,
438 438 pull_request=pull_request_id)
439 439 # count inline comments
440 440 for __, lines in c.inline_comments:
441 441 for comments in lines.values():
442 442 c.inline_cnt += len(comments)
443 443 # comments
444 444 c.comments = cc_model.get_comments(c.rhodecode_db_repo.repo_id,
445 445 pull_request=pull_request_id)
446 446
447 447 # (badly named) pull-request status calculation based on reviewer votes
448 448 c.current_changeset_status = cs_model.calculate_status(
449 449 c.pull_request_reviewers,
450 450 )
451 451 c.changeset_statuses = ChangesetStatus.STATUSES
452 452
453 453 c.as_form = False
454 454 c.ancestor = None # there is one - but right here we don't know which
455 455 return render('/pullrequests/pullrequest_show.html')
456 456
457 457 @LoginRequired()
458 458 @NotAnonymous()
459 459 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
460 460 'repository.admin')
461 461 @jsonify
462 462 def comment(self, repo_name, pull_request_id):
463 463 pull_request = PullRequest.get_or_404(pull_request_id)
464 464 if pull_request.is_closed():
465 465 raise HTTPForbidden()
466 466
467 467 status = request.POST.get('changeset_status')
468 468 change_status = request.POST.get('change_changeset_status')
469 469 text = request.POST.get('text')
470 470 close_pr = request.POST.get('save_close')
471 471
472 472 allowed_to_change_status = self._get_is_allowed_change_status(pull_request)
473 473 if status and change_status and allowed_to_change_status:
474 474 _def = (_('Status change -> %s')
475 475 % ChangesetStatus.get_status_lbl(status))
476 476 if close_pr:
477 477 _def = _('Closing with') + ' ' + _def
478 478 text = text or _def
479 479 comm = ChangesetCommentsModel().create(
480 480 text=text,
481 481 repo=c.rhodecode_db_repo.repo_id,
482 482 user=c.rhodecode_user.user_id,
483 483 pull_request=pull_request_id,
484 484 f_path=request.POST.get('f_path'),
485 485 line_no=request.POST.get('line'),
486 486 status_change=(ChangesetStatus.get_status_lbl(status)
487 487 if status and change_status
488 488 and allowed_to_change_status else None),
489 489 closing_pr=close_pr
490 490 )
491 491
492 492 action_logger(self.rhodecode_user,
493 493 'user_commented_pull_request:%s' % pull_request_id,
494 494 c.rhodecode_db_repo, self.ip_addr, self.sa)
495 495
496 496 if allowed_to_change_status:
497 497 # get status if set !
498 498 if status and change_status:
499 499 ChangesetStatusModel().set_status(
500 500 c.rhodecode_db_repo.repo_id,
501 501 status,
502 502 c.rhodecode_user.user_id,
503 503 comm,
504 504 pull_request=pull_request_id
505 505 )
506 506
507 507 if close_pr:
508 508 if status in ['rejected', 'approved']:
509 509 PullRequestModel().close_pull_request(pull_request_id)
510 510 action_logger(self.rhodecode_user,
511 511 'user_closed_pull_request:%s' % pull_request_id,
512 512 c.rhodecode_db_repo, self.ip_addr, self.sa)
513 513 else:
514 514 h.flash(_('Closing pull request on other statuses than '
515 515 'rejected or approved forbidden'),
516 516 category='warning')
517 517
518 518 Session().commit()
519 519
520 520 if not request.environ.get('HTTP_X_PARTIAL_XHR'):
521 521 return redirect(h.url('pullrequest_show', repo_name=repo_name,
522 522 pull_request_id=pull_request_id))
523 523
524 524 data = {
525 525 'target_id': h.safeid(h.safe_unicode(request.POST.get('f_path'))),
526 526 }
527 527 if comm:
528 528 c.co = comm
529 529 data.update(comm.get_dict())
530 530 data.update({'rendered_text':
531 531 render('changeset/changeset_comment_block.html')})
532 532
533 533 return data
534 534
535 535 @LoginRequired()
536 536 @NotAnonymous()
537 537 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
538 538 'repository.admin')
539 539 @jsonify
540 540 def delete_comment(self, repo_name, comment_id):
541 541 co = ChangesetComment.get(comment_id)
542 542 if co.pull_request.is_closed():
543 543 #don't allow deleting comments on closed pull request
544 544 raise HTTPForbidden()
545 545
546 546 owner = co.author.user_id == c.rhodecode_user.user_id
547 547 if h.HasPermissionAny('hg.admin', 'repository.admin')() or owner:
548 548 ChangesetCommentsModel().delete(comment=co)
549 549 Session().commit()
550 550 return True
551 551 else:
552 552 raise HTTPForbidden()
General Comments 0
You need to be logged in to leave comments. Login now