| [13497] | 1 | # |
|---|
| 2 | # Copyright (C) 2005-2006 Team5 |
|---|
| [18242] | 3 | # Copyright (C) 2016-2021 Cinc |
|---|
| [15494] | 4 | # |
|---|
| [13497] | 5 | # All rights reserved. |
|---|
| 6 | # |
|---|
| [15216] | 7 | # This software is licensed as described in the file COPYING.txt, which |
|---|
| [13497] | 8 | # you should have received as part of this distribution. |
|---|
| 9 | # |
|---|
| [717] | 10 | |
|---|
| 11 | # Provides functionality to create a new code review. |
|---|
| [18246] | 12 | # Works with peerreview_new.html |
|---|
| [717] | 13 | |
|---|
| [13497] | 14 | import itertools |
|---|
| [15172] | 15 | from trac.core import Component, implements, TracError |
|---|
| [18252] | 16 | from trac.util.text import CRLF, to_unicode |
|---|
| [16617] | 17 | from trac.web.chrome import INavigationContributor, add_script, add_script_data, \ |
|---|
| [15399] | 18 | add_warning, add_notice, add_stylesheet, Chrome |
|---|
| [717] | 19 | from trac.web.main import IRequestHandler |
|---|
| [15281] | 20 | from trac.versioncontrol.api import RepositoryManager |
|---|
| [18249] | 21 | from .model import Comment, get_users, \ |
|---|
| [17438] | 22 | PeerReviewerModel, PeerReviewModel, ReviewFileModel |
|---|
| [18249] | 23 | from .peerReviewMain import add_ctxt_nav_items |
|---|
| 24 | from .repobrowser import get_node_from_repo |
|---|
| [15494] | 25 | from .repo import hash_from_file_node |
|---|
| [717] | 26 | |
|---|
| [15494] | 27 | |
|---|
| [15207] | 28 | def java_string_hashcode(s): |
|---|
| 29 | # See: http://garage.pimentech.net/libcommonPython_src_python_libcommon_javastringhashcode/ |
|---|
| 30 | h = 0 |
|---|
| 31 | for c in s: |
|---|
| 32 | h = (31 * h + ord(c)) & 0xFFFFFFFF |
|---|
| 33 | return ((h + 0x80000000) & 0xFFFFFFFF) - 0x80000000 |
|---|
| 34 | |
|---|
| 35 | |
|---|
| [17262] | 36 | def create_id_string(f, rev=None): |
|---|
| 37 | # Use rev to override the revision in the id string. Used in followup review creation |
|---|
| 38 | f_rev = rev or f['revision'] |
|---|
| [15582] | 39 | return "%s,%s,%s,%s,%s" %\ |
|---|
| [17262] | 40 | (f['path'], f_rev, f['line_start'], f['line_end'], f['repo']) |
|---|
| [15582] | 41 | |
|---|
| 42 | |
|---|
| [15207] | 43 | def create_file_hash_id(f): |
|---|
| [15582] | 44 | return 'id%s' % java_string_hashcode(create_id_string(f)) |
|---|
| [15207] | 45 | |
|---|
| 46 | |
|---|
| 47 | def add_users_to_data(env, reviewID, data): |
|---|
| [15509] | 48 | """Add user, assigned and unassigned users to dict data. |
|---|
| [15207] | 49 | |
|---|
| [15509] | 50 | This function searches all users assigned to the given review and adds the list to the data dictionary using |
|---|
| 51 | key 'assigned_users'. Not yet assigned users are added using the key 'unassigned_users'. |
|---|
| 52 | If data['user'] doesn't exist this function will query the list of available users and add them. |
|---|
| 53 | |
|---|
| 54 | :param env: Trac environment object |
|---|
| 55 | :param reviewID: Id of a review |
|---|
| [15207] | 56 | :param data: |
|---|
| [15509] | 57 | |
|---|
| 58 | :return: None. Data is added to dict data using keys 'users', 'assigned_users', 'unassigned_users', 'emptyList' |
|---|
| [15207] | 59 | """ |
|---|
| 60 | if 'users' not in data: |
|---|
| 61 | data['users'] = get_users(env) |
|---|
| 62 | all_users = data['users'] |
|---|
| 63 | |
|---|
| 64 | # get code review data and populate |
|---|
| [15509] | 65 | reviewers = PeerReviewerModel.select_by_review_id(env, reviewID) |
|---|
| [15207] | 66 | popUsers = [] |
|---|
| 67 | for reviewer in reviewers: |
|---|
| [15509] | 68 | popUsers.append(reviewer['reviewer']) |
|---|
| [15207] | 69 | data['assigned_users'] = popUsers |
|---|
| 70 | |
|---|
| 71 | # Figure out the users that were not included |
|---|
| 72 | # in the previous code review so that they can be |
|---|
| 73 | # added to the dropdown to select more users |
|---|
| 74 | # (only check if all users were not included in previous code review) |
|---|
| 75 | notUsers = [] |
|---|
| 76 | if len(popUsers) != len(all_users): |
|---|
| 77 | notUsers = list(set(all_users)-set(popUsers)) |
|---|
| 78 | data['emptyList'] = 0 |
|---|
| 79 | else: |
|---|
| 80 | data['emptyList'] = 1 |
|---|
| 81 | |
|---|
| 82 | data['unassigned_users'] = notUsers |
|---|
| 83 | |
|---|
| 84 | |
|---|
| [15169] | 85 | class NewReviewModule(Component): |
|---|
| [17405] | 86 | """Component handling the creation of code reviews. |
|---|
| [17265] | 87 | |
|---|
| [17405] | 88 | [[BR]] |
|---|
| 89 | This component handles the creation of a new review and creation of followup reviews. |
|---|
| 90 | """ |
|---|
| 91 | |
|---|
| [15170] | 92 | implements(IRequestHandler, INavigationContributor) |
|---|
| [717] | 93 | |
|---|
| 94 | # INavigationContributor methods |
|---|
| [17262] | 95 | |
|---|
| [717] | 96 | def get_active_navigation_item(self, req): |
|---|
| [18261] | 97 | return 'peerreviewmain' |
|---|
| [717] | 98 | |
|---|
| 99 | def get_navigation_items(self, req): |
|---|
| [13497] | 100 | return [] |
|---|
| [717] | 101 | |
|---|
| 102 | # IRequestHandler methods |
|---|
| [17262] | 103 | |
|---|
| [717] | 104 | def match_request(self, req): |
|---|
| 105 | |
|---|
| [18261] | 106 | if req.path_info == '/peerreviewnew': |
|---|
| 107 | return True |
|---|
| 108 | elif req.path_info == '/peerReviewNew': |
|---|
| 109 | self.env.log.info("Legacy URL 'peerReviewNew' called from: %s", req.get_header('Referer')) |
|---|
| 110 | return True |
|---|
| 111 | return False |
|---|
| 112 | |
|---|
| 113 | |
|---|
| [717] | 114 | def process_request(self, req): |
|---|
| [15164] | 115 | req.perm.require('CODE_REVIEW_DEV') |
|---|
| 116 | |
|---|
| [15204] | 117 | if req.method == 'POST': |
|---|
| [15207] | 118 | oldid = req.args.get('oldid') |
|---|
| [15204] | 119 | if req.args.get('create'): |
|---|
| [17262] | 120 | returnid = self.createCodeReview(req, 'create') |
|---|
| [15204] | 121 | if oldid: |
|---|
| 122 | # Automatically close the review we resubmitted from |
|---|
| [15292] | 123 | review = PeerReviewModel(self.env, oldid) |
|---|
| 124 | review['status'] = "closed" |
|---|
| 125 | review.save_changes(req.authname, comment="Closed after resubmitting as review '#%s'." % |
|---|
| 126 | returnid) |
|---|
| 127 | add_notice(req, "Review '%s' (#%s) was automatically closed after resubmitting as '#%s'." % |
|---|
| 128 | (review['name'], oldid, returnid)) |
|---|
| [17262] | 129 | # If no errors then redirect to the viewCodeReview page |
|---|
| [17448] | 130 | req.redirect(req.href.peerreviewview(returnid)) |
|---|
| [15204] | 131 | if req.args.get('createfollowup'): |
|---|
| [17262] | 132 | returnid = self.createCodeReview(req, 'followup') |
|---|
| 133 | # If no errors then redirect to the viewCodeReview page of the new review |
|---|
| [17448] | 134 | req.redirect(req.href.peerreviewview(returnid)) |
|---|
| [15207] | 135 | if req.args.get('save'): |
|---|
| 136 | self.save_changes(req) |
|---|
| [17448] | 137 | req.redirect(req.href.peerreviewview(oldid)) |
|---|
| [15207] | 138 | if req.args.get('cancel'): |
|---|
| [17448] | 139 | req.redirect(req.href.peerreviewview(oldid)) |
|---|
| [3542] | 140 | |
|---|
| [17262] | 141 | # Handling of GET request |
|---|
| [15204] | 142 | |
|---|
| [17262] | 143 | data = {'users': get_users(self.env), |
|---|
| 144 | 'new': "no", |
|---|
| 145 | 'cycle': itertools.cycle, |
|---|
| 146 | 'followup': req.args.get('followup') |
|---|
| 147 | } |
|---|
| 148 | |
|---|
| 149 | is_followup = req.args.get('followup', None) |
|---|
| [15288] | 150 | review_id = req.args.get('resubmit') |
|---|
| [17262] | 151 | review = PeerReviewModel(self.env, review_id) |
|---|
| [717] | 152 | |
|---|
| [15288] | 153 | # If we tried resubmitting and the review_id is not a valid number or not a valid code review, error |
|---|
| 154 | if review_id and (not review_id.isdigit() or not review): |
|---|
| [15201] | 155 | raise TracError("Invalid resubmit ID supplied - unable to load page correctly.", "Resubmit ID error") |
|---|
| [3542] | 156 | |
|---|
| [15285] | 157 | if review['status'] == 'closed' and req.args.get('modify'): |
|---|
| 158 | raise TracError("The Review '#%s' is already closed and can't be modified." % review['review_id'], |
|---|
| [15208] | 159 | "Modify Review error") |
|---|
| 160 | |
|---|
| [17262] | 161 | # If we are resubmitting a code review, and are neither the author nor the manager |
|---|
| 162 | if review_id and not review['owner'] == req.authname and not 'CODE_REVIEW_MGR' in req.perm: |
|---|
| 163 | raise TracError("You need to be a manager or the author of this code review to resubmit it.", |
|---|
| 164 | "Access error") |
|---|
| 165 | |
|---|
| [15288] | 166 | # If we are resubmitting a code review and we are the author or the manager |
|---|
| 167 | if review_id and (review['owner'] == req.authname or 'CODE_REVIEW_MGR' in req.perm): |
|---|
| 168 | data['oldid'] = review_id |
|---|
| [3542] | 169 | |
|---|
| [15288] | 170 | add_users_to_data(self.env, review_id, data) |
|---|
| [15207] | 171 | |
|---|
| [15513] | 172 | rfiles = ReviewFileModel.select_by_review(self.env, review_id) |
|---|
| [717] | 173 | popFiles = [] |
|---|
| 174 | # Set up the file information |
|---|
| [15174] | 175 | for f in rfiles: |
|---|
| [18278] | 176 | repo = RepositoryManager(self.env).get_repository(f['repo']) |
|---|
| 177 | f['display_rev'] = repo.display_rev |
|---|
| [17262] | 178 | if is_followup: |
|---|
| 179 | # Get the current file and repo revision |
|---|
| 180 | node, display_rev, context = get_node_from_repo(req, repo, f['path'], None) |
|---|
| [18252] | 181 | f.curchangerevision = to_unicode(node.created_rev) |
|---|
| [17262] | 182 | f.curreporev = repo.youngest_rev |
|---|
| 183 | # We use the current repo revision here so on POST that revision is used for creating |
|---|
| 184 | # the file entry in the database. The POST handler parses the string for necessary information. |
|---|
| 185 | f.id_string = create_id_string(f, repo.youngest_rev) |
|---|
| 186 | else: |
|---|
| 187 | # The id_String holds info like revision, line numbers, path and repo. It is later used to save |
|---|
| 188 | # file info to the database during a post. |
|---|
| 189 | f.id_string = create_id_string(f) |
|---|
| [15288] | 190 | # This id is used by the javascript code to find duplicate entries. |
|---|
| [15207] | 191 | f.element_id = create_file_hash_id(f) |
|---|
| [15226] | 192 | if req.args.get('modify'): |
|---|
| [15513] | 193 | comments = Comment.select_by_file_id(self.env, f['file_id']) |
|---|
| [15226] | 194 | f.num_comments = len(comments) or 0 |
|---|
| [15181] | 195 | popFiles.append(f) |
|---|
| [717] | 196 | |
|---|
| [15285] | 197 | data['name'] = review['name'] |
|---|
| [17405] | 198 | if req.args.get('modify'): |
|---|
| [15285] | 199 | data['notes'] = review['notes'] |
|---|
| [17405] | 200 | elif req.args.get('followup'): |
|---|
| 201 | data['notes'] = "%sReview is followup to review ''%s''." % \ |
|---|
| 202 | (review['notes']+ CRLF, review['name']) |
|---|
| [15216] | 203 | else: |
|---|
| [15292] | 204 | data['notes'] = "%sReview based on ''%s'' (resubmitted)." %\ |
|---|
| [17405] | 205 | (review['notes']+ CRLF, review['name']) |
|---|
| [3542] | 206 | data['prevFiles'] = popFiles |
|---|
| [15288] | 207 | # If we are not resubmitting |
|---|
| [717] | 208 | else: |
|---|
| [15204] | 209 | data['new'] = "yes" |
|---|
| [717] | 210 | |
|---|
| [15536] | 211 | prj = self.env.config.getlist("peerreview", "projects", default=[]) |
|---|
| [15285] | 212 | if not prj: |
|---|
| 213 | prj = self.env.config.getlist("ticket-custom", "project.options", default=[], sep='|') |
|---|
| [3542] | 214 | |
|---|
| [15285] | 215 | data['projects'] = prj |
|---|
| 216 | data['curproj'] = review['project'] |
|---|
| 217 | |
|---|
| [17283] | 218 | Chrome(self.env).add_jquery_ui(req) |
|---|
| [15194] | 219 | add_stylesheet(req, 'common/css/browser.css') |
|---|
| 220 | add_stylesheet(req, 'common/css/code.css') |
|---|
| [15203] | 221 | add_stylesheet(req, 'hw/css/peerreview.css') |
|---|
| [16617] | 222 | add_script(req, 'common/js/auto_preview.js') |
|---|
| [17448] | 223 | add_script_data(req, {'repo_browser': req.href.peerReviewBrowser(), |
|---|
| [15332] | 224 | 'auto_preview_timeout': self.env.config.get('trac', 'auto_preview_timeout', '2.0'), |
|---|
| [15693] | 225 | 'form_token': req.form_token, |
|---|
| [17262] | 226 | 'peer_is_modify': req.args.get('modify', '0'), |
|---|
| 227 | 'peer_is_followup': req.args.get('followup', '0')}) |
|---|
| [16617] | 228 | add_script(req, "hw/js/peer_review_new.js") |
|---|
| 229 | add_script(req, 'hw/js/peer_user_list.js') |
|---|
| [15172] | 230 | add_ctxt_nav_items(req) |
|---|
| [18242] | 231 | if hasattr(Chrome, 'jenv'): |
|---|
| 232 | return 'peerreview_new_jinja.html', data |
|---|
| 233 | else: |
|---|
| [18246] | 234 | return 'peerreview_new.html', data, None |
|---|
| [3542] | 235 | |
|---|
| [17262] | 236 | def createCodeReview(self, req, action): |
|---|
| 237 | """Create a new code review from the data in the request object req. |
|---|
| 238 | |
|---|
| 239 | Takes the information given when the page is posted and creates a |
|---|
| 240 | new code review struct in the database and populates it with the |
|---|
| 241 | information. Also creates new reviewer structs and file structs for |
|---|
| 242 | the review. |
|---|
| 243 | """ |
|---|
| [15292] | 244 | oldid = req.args.get('oldid', 0) |
|---|
| [15285] | 245 | review = PeerReviewModel(self.env) |
|---|
| 246 | review['owner'] = req.authname |
|---|
| 247 | review['name'] = req.args.get('Name') |
|---|
| 248 | review['notes'] = req.args.get('Notes') |
|---|
| 249 | if req.args.get('project'): |
|---|
| [16451] | 250 | review['project'] = req.args.get('project') |
|---|
| [15292] | 251 | if oldid: |
|---|
| [15313] | 252 | # Resubmit or follow up |
|---|
| [17262] | 253 | if action == 'followup': |
|---|
| [15313] | 254 | review['parent_id'] = oldid |
|---|
| 255 | else: |
|---|
| 256 | # Keep parent -> follow up relationship when resubmitting |
|---|
| 257 | old_review = PeerReviewModel(self.env, oldid) |
|---|
| 258 | review['parent_id'] = old_review['parent_id'] |
|---|
| [15179] | 259 | review.insert() |
|---|
| [15285] | 260 | id_ = review['review_id'] |
|---|
| [15179] | 261 | self.log.debug('New review created: %s', id_) |
|---|
| 262 | |
|---|
| [717] | 263 | # loop here through all the reviewers |
|---|
| 264 | # and create new reviewer structs based on them |
|---|
| [18253] | 265 | user = req.args.getlist('user') |
|---|
| [15188] | 266 | if not type(user) is list: |
|---|
| 267 | user = [user] |
|---|
| 268 | for name in user: |
|---|
| 269 | if name != "": |
|---|
| [15284] | 270 | reviewer = PeerReviewerModel(self.env) |
|---|
| 271 | reviewer['review_id'] = id_ |
|---|
| 272 | reviewer['reviewer'] = name |
|---|
| 273 | reviewer['vote'] = -1 |
|---|
| [15188] | 274 | reviewer.insert() |
|---|
| [717] | 275 | |
|---|
| 276 | # loop here through all included files |
|---|
| 277 | # and create new file structs based on them |
|---|
| [18253] | 278 | files = req.args.getlist('file') |
|---|
| [15190] | 279 | if not type(files) is list: |
|---|
| 280 | files = [files] |
|---|
| 281 | for item in files: |
|---|
| [15510] | 282 | segment = item.split(',') |
|---|
| 283 | rfile = ReviewFileModel(self.env) |
|---|
| 284 | rfile['review_id'] = id_ |
|---|
| [18274] | 285 | rfile['path'] = '/' + segment[0].lstrip('/') # We save the path with leading '/' for historical reasons |
|---|
| [17262] | 286 | rfile['revision'] = segment[1] # If we create a followup review this is the current repo revision |
|---|
| [15510] | 287 | rfile['line_start'] = segment[2] |
|---|
| 288 | rfile['line_end'] = segment[3] |
|---|
| [15582] | 289 | rfile['repo'] = segment[4] |
|---|
| 290 | repo = RepositoryManager(self.env).get_repository(rfile['repo']) |
|---|
| 291 | node, display_rev, context = get_node_from_repo(req, repo, rfile['path'], rfile['revision']) |
|---|
| [18252] | 292 | rfile['changerevision'] = to_unicode(node.created_rev) |
|---|
| [15510] | 293 | rfile['hash'] = self._hash_from_file_node(node) |
|---|
| 294 | rfile.insert() |
|---|
| [15169] | 295 | return id_ |
|---|
| [15207] | 296 | |
|---|
| [15281] | 297 | def _hash_from_file_node(self, node): |
|---|
| [15494] | 298 | return hash_from_file_node(node) |
|---|
| [15281] | 299 | |
|---|
| [15207] | 300 | def save_changes(self, req): |
|---|
| 301 | def file_is_commented(author): |
|---|
| [15513] | 302 | rfiles = ReviewFileModel.select_by_review(self.env, review['review_id']) |
|---|
| [15207] | 303 | for f in rfiles: |
|---|
| [15513] | 304 | comments = [c for c in Comment.select_by_file_id(self.env, f['file_id']) if c.author == author] |
|---|
| [15207] | 305 | if comments: |
|---|
| 306 | return True |
|---|
| 307 | return False |
|---|
| 308 | |
|---|
| [15292] | 309 | review = PeerReviewModel(self.env, req.args.get('oldid')) |
|---|
| 310 | review['name'] = req.args.get('Name') |
|---|
| 311 | review['notes'] = req.args.get('Notes') |
|---|
| 312 | review['project'] = req.args.get('project') |
|---|
| 313 | review.save_changes(req.authname) |
|---|
| [15207] | 314 | |
|---|
| [18253] | 315 | user = req.args.getlist('user') |
|---|
| [15207] | 316 | data = {} |
|---|
| [15292] | 317 | add_users_to_data(self.env,review['review_id'], data) |
|---|
| [15207] | 318 | # Handle new users if any |
|---|
| 319 | new_users = list(set(user) - set(data['assigned_users'])) |
|---|
| 320 | for name in new_users: |
|---|
| 321 | if name != "": |
|---|
| [15284] | 322 | reviewer = PeerReviewerModel(self.env) |
|---|
| [15292] | 323 | reviewer['review_id'] = review['review_id'] |
|---|
| [15284] | 324 | reviewer['reviewer'] = name |
|---|
| 325 | reviewer['vote'] = -1 |
|---|
| [15207] | 326 | reviewer.insert() |
|---|
| 327 | # Handle removed users if any |
|---|
| 328 | rem_users = list(set(data['assigned_users']) - set(user)) |
|---|
| 329 | for name in rem_users: |
|---|
| 330 | if name != "": |
|---|
| 331 | if file_is_commented(name): |
|---|
| 332 | add_warning(req, "User '%s' already commented a file. Not removed from review '#%s'", |
|---|
| [15292] | 333 | name, review['review_id']) |
|---|
| [17438] | 334 | else: |
|---|
| 335 | PeerReviewerModel.delete_by_review_id_and_name(self.env, review['review_id'], name) |
|---|
| [15226] | 336 | |
|---|
| 337 | # Handle file removal |
|---|
| [18253] | 338 | new_files = req.args.getlist('file') |
|---|
| [15226] | 339 | old_files = [] |
|---|
| 340 | rfiles = {} |
|---|
| [15513] | 341 | for f in ReviewFileModel.select_by_review(self.env, review['review_id']): |
|---|
| [15582] | 342 | fid = u"%s,%s,%s,%s,%s" % (f['path'], f['revision'], f['line_start'], f['line_end'], f['repo']) |
|---|
| [15226] | 343 | old_files.append(fid) |
|---|
| 344 | rfiles[fid] = f |
|---|
| 345 | |
|---|
| 346 | rem_files = list(set(old_files) - set(new_files)) |
|---|
| 347 | for fid in rem_files: |
|---|
| 348 | rfiles[fid].delete() |
|---|