| 1 | # |
|---|
| 2 | # Copyright (C) 2005-2006 Team5 |
|---|
| 3 | # All rights reserved. |
|---|
| 4 | # |
|---|
| 5 | # This software is licensed as described in the file COPYING.txt, which |
|---|
| 6 | # you should have received as part of this distribution. |
|---|
| 7 | # |
|---|
| 8 | # Author: Team5 |
|---|
| 9 | # |
|---|
| 10 | |
|---|
| 11 | # Code Review plugin |
|---|
| 12 | # This class handles the display for the perform code review page |
|---|
| 13 | # The file contents are taken from the respository and converted to |
|---|
| 14 | # an HTML friendly format. The line annotator customizes the |
|---|
| 15 | # repository browser's line number to indicate what lines are being |
|---|
| 16 | # reviewed and if there are any comments on a particular line. |
|---|
| 17 | |
|---|
| 18 | from genshi.core import QName |
|---|
| 19 | from genshi.filters.transform import Transformer |
|---|
| 20 | from pkg_resources import get_distribution, parse_version |
|---|
| 21 | from trac.core import * |
|---|
| 22 | from trac.mimeview import * |
|---|
| 23 | from trac.mimeview.api import IHTMLPreviewAnnotator |
|---|
| 24 | from trac.util import format_date |
|---|
| 25 | from trac.util.html import html as tag |
|---|
| 26 | from trac.web.chrome import INavigationContributor, ITemplateStreamFilter, Chrome, \ |
|---|
| 27 | add_link, add_stylesheet, add_script, add_script_data |
|---|
| 28 | from trac.web.main import IRequestHandler |
|---|
| 29 | from trac.versioncontrol.web_ui.util import * |
|---|
| 30 | from trac.versioncontrol.api import RepositoryManager |
|---|
| 31 | from trac.versioncontrol.diff import diff_blocks, get_diff_options |
|---|
| 32 | from model import Comment, PeerReviewModel, ReviewFileModel |
|---|
| 33 | from peerReviewMain import add_ctxt_nav_items |
|---|
| 34 | from repo import file_data_from_repo |
|---|
| 35 | from util import not_allowed_to_comment, review_is_finished, review_is_locked |
|---|
| 36 | |
|---|
| 37 | |
|---|
| 38 | class PeerReviewPerform(Component): |
|---|
| 39 | """Perform a code review. |
|---|
| 40 | |
|---|
| 41 | [[BR]] |
|---|
| 42 | Trac 0.12 comes with a very ancient version of jQuery. This plugin replaces that version with 1.11.2 on the |
|---|
| 43 | fly. Similar to Trac 1.0 you may specify your own jQuery in your config file. |
|---|
| 44 | |
|---|
| 45 | {{{#!ini |
|---|
| 46 | [trac] |
|---|
| 47 | jquery_location = https://path/to/jquery.js |
|---|
| 48 | }}} |
|---|
| 49 | If not set the bundled version will be used. |
|---|
| 50 | |
|---|
| 51 | The same can be done for the jQuery UI package and the theme to use. |
|---|
| 52 | {{{#!ini |
|---|
| 53 | [trac] |
|---|
| 54 | jquery_ui_location = https://path/to/jquery-ui.js |
|---|
| 55 | jquery_ui_theme_location = https://path/to/jquery-ui-theme.css |
|---|
| 56 | }}} |
|---|
| 57 | jQuery-ui 1.11.4 is bundled with this plugin. |
|---|
| 58 | """ |
|---|
| 59 | implements(INavigationContributor, IRequestHandler, IHTMLPreviewAnnotator, ITemplateStreamFilter) |
|---|
| 60 | |
|---|
| 61 | imagePath = '' |
|---|
| 62 | trac_version = get_distribution('trac').version |
|---|
| 63 | legacy_trac = parse_version(trac_version) < parse_version('1.0.0') # True if Trac V0.12.x |
|---|
| 64 | |
|---|
| 65 | # ITextAnnotator methods |
|---|
| 66 | def get_annotation_type(self): |
|---|
| 67 | return 'performCodeReview', 'Line', 'Line numbers' |
|---|
| 68 | |
|---|
| 69 | def get_annotation_data(self, context): |
|---|
| 70 | r_file = context.get_hint('reviewfile') |
|---|
| 71 | authname = context.get_hint('authname') |
|---|
| 72 | perm = context.get_hint('perm') |
|---|
| 73 | review = PeerReviewModel(self.env, r_file['review_id']) |
|---|
| 74 | |
|---|
| 75 | # Is it allowed to comment on the file? |
|---|
| 76 | if review_is_finished(self.env.config, review): |
|---|
| 77 | is_locked = True |
|---|
| 78 | else: |
|---|
| 79 | is_locked = review_is_locked(self.env.config, review, authname) |
|---|
| 80 | |
|---|
| 81 | # Don't let users comment who are not part of this review |
|---|
| 82 | if not_allowed_to_comment(self.env, review, perm, authname): |
|---|
| 83 | is_locked = True |
|---|
| 84 | |
|---|
| 85 | data = [[c.line_num for c in Comment.select_by_file_id(self.env, r_file['file_id'])], |
|---|
| 86 | review, is_locked] |
|---|
| 87 | return data |
|---|
| 88 | |
|---|
| 89 | #line annotator for Perform Code Review page |
|---|
| 90 | #if line has a comment, places an icon to indicate comment |
|---|
| 91 | #if line is not in the rage of reviewed lines, it makes |
|---|
| 92 | #the color a light gray |
|---|
| 93 | def annotate_row(self, context, row, lineno, line, data): |
|---|
| 94 | r_file = context.get_hint('reviewfile') |
|---|
| 95 | if (lineno <= int(r_file['line_end']) and lineno >= int(r_file['line_start'])) or int(r_file['line_start']) == 0: |
|---|
| 96 | # If there is a comment on this line |
|---|
| 97 | lines = data[0] |
|---|
| 98 | # review = data[1] |
|---|
| 99 | if lineno in lines: |
|---|
| 100 | return row.append(tag.th(id='L%s' % lineno)(tag.a(tag.img(src='%s' % self.imagePath) + ' ' + str(lineno), |
|---|
| 101 | href='javascript:getComments(%s, %s)' % |
|---|
| 102 | (lineno, r_file['file_id'])))) |
|---|
| 103 | if not data[2]: |
|---|
| 104 | return row.append(tag.th(id='L%s' % lineno)(tag.a(lineno, href='javascript:addComment(%s, %s, -1)' |
|---|
| 105 | % (lineno, r_file['file_id'])))) |
|---|
| 106 | else: |
|---|
| 107 | return row.append(tag.th(str(lineno), id='L%s' % lineno)) |
|---|
| 108 | |
|---|
| 109 | #color line numbers outside range light gray |
|---|
| 110 | return row.append(tag.th(id='L%s' % lineno)(tag.font(lineno, color='#CCCCCC'))) |
|---|
| 111 | |
|---|
| 112 | # ITemplateStreamFilter |
|---|
| 113 | |
|---|
| 114 | def filter_stream(self, req, method, filename, stream, data): |
|---|
| 115 | def repl_jquery(name, event): |
|---|
| 116 | """ Replace Trac jquery.js with jquery.js coming with plugin. """ |
|---|
| 117 | attrs = event[1][1] |
|---|
| 118 | #match=re.match(self.PATH_REGEX, req.path_info) |
|---|
| 119 | #if match and attrs.get(name) and attrs.get(name).endswith("common/js/jquery.js"): |
|---|
| 120 | if attrs.get(name): |
|---|
| 121 | if attrs.get(name).endswith("common/js/jquery.js"): |
|---|
| 122 | jquery = self.env.config.get('trac', 'jquery_location') |
|---|
| 123 | if jquery: |
|---|
| 124 | attrs -= name |
|---|
| 125 | attrs |= [(QName(name), jquery)] |
|---|
| 126 | else: |
|---|
| 127 | return attrs.get(name).replace("common/js/jquery.js", 'hw/js/jquery-1.11.2.min.js') |
|---|
| 128 | elif attrs.get(name) and attrs.get(name).endswith("common/js/keyboard_nav.js"): |
|---|
| 129 | #keyboard_nav.js uses function live() which was removed with jQuery 1.9. Use a fixed script here |
|---|
| 130 | return attrs.get(name) .replace("common/js/keyboard_nav.js", 'req/js/keyboard_nav.js') |
|---|
| 131 | return attrs.get(name) #.replace('#trac-add-comment', '?minview') |
|---|
| 132 | |
|---|
| 133 | # Replace jQuery with a more recent version when using Trac 0.12 |
|---|
| 134 | if self.legacy_trac: |
|---|
| 135 | stream = stream | Transformer('//head/script').attr('src', repl_jquery) |
|---|
| 136 | return stream |
|---|
| 137 | |
|---|
| 138 | # INavigationContributor methods |
|---|
| 139 | |
|---|
| 140 | def get_active_navigation_item(self, req): |
|---|
| 141 | return 'peerReviewMain' |
|---|
| 142 | |
|---|
| 143 | def get_navigation_items(self, req): |
|---|
| 144 | return [] |
|---|
| 145 | |
|---|
| 146 | # IRequestHandler methods |
|---|
| 147 | def match_request(self, req): |
|---|
| 148 | return req.path_info == '/peerReviewPerform' |
|---|
| 149 | |
|---|
| 150 | def process_request(self, req): |
|---|
| 151 | req.perm.require('CODE_REVIEW_DEV') |
|---|
| 152 | |
|---|
| 153 | #get the fileID from the request arguments |
|---|
| 154 | fileid = req.args.get('IDFile') |
|---|
| 155 | if not fileid: |
|---|
| 156 | raise TracError("No file ID given - unable to load page.", "File ID Error") |
|---|
| 157 | |
|---|
| 158 | #make the thumbtac image global so the line annotator has access to it |
|---|
| 159 | self.imagePath = 'chrome/hw/images/thumbtac11x11.gif' |
|---|
| 160 | |
|---|
| 161 | data = {'file_id': fileid} |
|---|
| 162 | |
|---|
| 163 | r_file = ReviewFileModel(self.env, fileid) # This will replace rfile |
|---|
| 164 | review = PeerReviewModel(self.env, r_file['review_id']) |
|---|
| 165 | review.date = format_date(review['created']) |
|---|
| 166 | data['review_file'] = r_file |
|---|
| 167 | data['review'] = review |
|---|
| 168 | |
|---|
| 169 | repos = RepositoryManager(self.env).get_repository(r_file['repo']) |
|---|
| 170 | if not repos: |
|---|
| 171 | raise TracError("Unable to acquire subversion repository.", |
|---|
| 172 | "Subversion Repository Error") |
|---|
| 173 | |
|---|
| 174 | # The following may raise an exception if revision can't be found |
|---|
| 175 | rev = r_file['changerevision'] |
|---|
| 176 | if rev: |
|---|
| 177 | rev = repos.normalize_rev(rev) |
|---|
| 178 | rev_or_latest = rev or repos.youngest_rev |
|---|
| 179 | node = get_existing_node(self.env, repos, r_file['path'], rev_or_latest) |
|---|
| 180 | |
|---|
| 181 | # Data for parent review if any |
|---|
| 182 | if review['parent_id'] != 0: |
|---|
| 183 | par_review = PeerReviewModel(self.env, review['parent_id']) # Raises 'ResourceNotFound' on error |
|---|
| 184 | par_review.date = format_date(par_review['created']) |
|---|
| 185 | par_file = ReviewFileModel(self.env, get_parent_file_id(self.env, r_file, review['parent_id'])) |
|---|
| 186 | lines = [c.line_num for c in Comment.select_by_file_id(self.env, par_file['file_id'])] |
|---|
| 187 | par_file.comments = list(set(lines)) # remove duplicates |
|---|
| 188 | par_revision = par_file['revision'] |
|---|
| 189 | if par_revision: |
|---|
| 190 | par_revision = repos.normalize_rev(par_revision) |
|---|
| 191 | rev_or_latest = par_revision or repos.youngest_rev |
|---|
| 192 | par_node = get_existing_node(self.env, repos, par_file['path'], rev_or_latest) |
|---|
| 193 | else: |
|---|
| 194 | par_review = None |
|---|
| 195 | par_file = None # TODO: there may be some error handling missing for this. Create a dummy here? |
|---|
| 196 | data['par_file'] = par_file |
|---|
| 197 | data['parent_review'] = par_review |
|---|
| 198 | |
|---|
| 199 | # Wether to show the full file in the browser. |
|---|
| 200 | if int(r_file['line_start']) == 0: |
|---|
| 201 | data['fullrange'] = True |
|---|
| 202 | else: |
|---|
| 203 | data['fullrange'] = False |
|---|
| 204 | |
|---|
| 205 | # Generate HTML preview - this code take from Trac - refer to their documentation |
|---|
| 206 | mime_type = node.content_type |
|---|
| 207 | self.env.log.debug("mime_type taken from node.content_type: %s" % (mime_type,)) |
|---|
| 208 | if not mime_type or mime_type == 'application/octet-stream': |
|---|
| 209 | mime_type = get_mimetype(node.name) or mime_type or 'text/plain' |
|---|
| 210 | |
|---|
| 211 | ctpos = mime_type.find('charset=') |
|---|
| 212 | if ctpos >= 0: |
|---|
| 213 | charset = mime_type[ctpos + 8:] |
|---|
| 214 | else: |
|---|
| 215 | charset = None |
|---|
| 216 | |
|---|
| 217 | mimeview = Mimeview(self.env) |
|---|
| 218 | rev = None # Is this correct? Seems to work with the call 'rev=rev or node.rev' further down |
|---|
| 219 | content = node.get_content().read(mimeview.max_preview_size) # We get the raw data without keyword substitution |
|---|
| 220 | if not is_binary(content): |
|---|
| 221 | if mime_type != 'text/plain': |
|---|
| 222 | plain_href = self.env.href.peerReviewBrowser(node.path, rev=rev or node.rev, format='txt') |
|---|
| 223 | add_link(req, 'alternate', plain_href, 'Plain Text', 'text/plain') |
|---|
| 224 | |
|---|
| 225 | if par_review: |
|---|
| 226 | # A followup review with diff viewer |
|---|
| 227 | create_diff_data(req, data, node, par_node) |
|---|
| 228 | else: |
|---|
| 229 | context = Context.from_request(req, 'source', node.path, node.created_rev) |
|---|
| 230 | context.set_hints(reviewfile=r_file) |
|---|
| 231 | context.set_hints(authname=req.authname) |
|---|
| 232 | context.set_hints(perm=req.perm) |
|---|
| 233 | |
|---|
| 234 | self.env.log.debug("Creating preview data for %s with mime_type = %s" % (node.created_path, mime_type)) |
|---|
| 235 | preview_data = mimeview.preview_data(context, content, len(content), |
|---|
| 236 | mime_type, node.created_path, |
|---|
| 237 | None, |
|---|
| 238 | annotations=['performCodeReview']) |
|---|
| 239 | data['preview'] = preview_data |
|---|
| 240 | # TODO: use in template 'preview.rendered' instead similar to preview_file.html |
|---|
| 241 | data['file_rendered'] = preview_data['rendered'] |
|---|
| 242 | |
|---|
| 243 | # A finished review can't be changed anymore except by a manager |
|---|
| 244 | data['is_finished'] = review_is_finished(self.env.config, review) |
|---|
| 245 | # A user can't chnage his voting for a reviewed review |
|---|
| 246 | data['review_locked'] = review_is_locked(self.env.config, review, req.authname) |
|---|
| 247 | data['not_allowed'] = not_allowed_to_comment(self.env, review, req.perm, req.authname) |
|---|
| 248 | |
|---|
| 249 | scr_data = {'peer_comments': sorted(list(set([c.line_num for c in |
|---|
| 250 | Comment.select_by_file_id(self.env, r_file['file_id'])]))), |
|---|
| 251 | 'peer_file_id': fileid, |
|---|
| 252 | 'peer_review_id': r_file['review_id'], |
|---|
| 253 | 'auto_preview_timeout': self.env.config.get('trac', 'auto_preview_timeout', '2.0'), |
|---|
| 254 | 'form_token': req.form_token, |
|---|
| 255 | 'peer_diff_style': data['style'] if 'style' in data else 'no_diff'} |
|---|
| 256 | if par_review: |
|---|
| 257 | scr_data['peer_parent_file_id'] = par_file['file_id'] |
|---|
| 258 | scr_data['peer_parent_comments'] = sorted(list(set([c.line_num for c in |
|---|
| 259 | Comment.select_by_file_id(self.env, par_file['file_id'])]))) |
|---|
| 260 | else: |
|---|
| 261 | scr_data['peer_parent_file_id'] = 0 # Mark that we don't have a parent |
|---|
| 262 | scr_data['peer_parent_comments'] = [] |
|---|
| 263 | |
|---|
| 264 | # For comment dialogs when using Trac 0.12. Otherwise use jQuery coming with Trac |
|---|
| 265 | if self.legacy_trac: |
|---|
| 266 | add_script(req, self.env.config.get('trac', 'jquery_ui_location') or |
|---|
| 267 | 'hw/js/jquery-ui-1.11.4.min.js') |
|---|
| 268 | add_stylesheet(req, self.env.config.get('trac', 'jquery_ui_theme_location') or |
|---|
| 269 | 'hw/css/jquery-ui-1.11.4.min.css') |
|---|
| 270 | else: |
|---|
| 271 | Chrome(self.env).add_jquery_ui(req) |
|---|
| 272 | |
|---|
| 273 | add_stylesheet(req, 'common/css/code.css') |
|---|
| 274 | add_stylesheet(req, 'common/css/diff.css') |
|---|
| 275 | add_stylesheet(req, 'hw/css/peerreview.css') |
|---|
| 276 | add_script_data(req, scr_data) |
|---|
| 277 | add_script(req, 'common/js/auto_preview.js') |
|---|
| 278 | add_script(req, "hw/js/peer_review_perform.js") |
|---|
| 279 | add_ctxt_nav_items(req) |
|---|
| 280 | |
|---|
| 281 | return 'peerReviewPerform.html', data, None |
|---|
| 282 | |
|---|
| 283 | |
|---|
| 284 | def get_parent_file_id(env, r_file, par_review_id): |
|---|
| 285 | |
|---|
| 286 | fid = u"%s%s%s" % (r_file['path'], r_file['line_start'], r_file['line_end']) |
|---|
| 287 | |
|---|
| 288 | rfiles = ReviewFileModel.select_by_review(env, par_review_id) |
|---|
| 289 | for f in rfiles: |
|---|
| 290 | tmp = u"%s%s%s" % (f['path'], f['line_start'], f['line_end']) |
|---|
| 291 | if tmp == fid: |
|---|
| 292 | return f['file_id'] |
|---|
| 293 | return 0 |
|---|
| 294 | |
|---|
| 295 | |
|---|
| 296 | def create_diff_data(req, data, node, par_node): |
|---|
| 297 | style, options, diff_data = get_diff_options(req) |
|---|
| 298 | |
|---|
| 299 | old = file_data_from_repo(par_node) |
|---|
| 300 | new = file_data_from_repo(node) |
|---|
| 301 | |
|---|
| 302 | if old == new: |
|---|
| 303 | data['nochanges'] = True |
|---|
| 304 | |
|---|
| 305 | if diff_data['options']['contextall']: |
|---|
| 306 | context = None |
|---|
| 307 | else: |
|---|
| 308 | context = diff_data['options']['contextlines'] |
|---|
| 309 | |
|---|
| 310 | diff = diff_blocks(old, new, context=context, |
|---|
| 311 | ignore_blank_lines=diff_data['options']['ignoreblanklines'], |
|---|
| 312 | ignore_case=diff_data['options']['ignorecase'], |
|---|
| 313 | ignore_space_changes=diff_data['options']['ignorewhitespace']) |
|---|
| 314 | |
|---|
| 315 | review = data['review'] |
|---|
| 316 | par_review = data['parent_review'] |
|---|
| 317 | changes = [] |
|---|
| 318 | info = {# 'title': '', |
|---|
| 319 | # 'comments': 'Ein Kommentar', |
|---|
| 320 | 'diffs': diff, |
|---|
| 321 | 'new': {'path': node.path, 'rev': "%s (Review #%s)" % (node.rev, review['review_id']), 'shortrev': node.rev}, |
|---|
| 322 | 'old': {'path': par_node.path, 'rev': "%s (Review #%s)" % (par_node.rev, par_review['review_id']), |
|---|
| 323 | 'shortrev': par_node.rev}, |
|---|
| 324 | 'props': []} |
|---|
| 325 | changes.append(info) |
|---|
| 326 | data['changes'] = changes |
|---|
| 327 | |
|---|
| 328 | data['diff'] = diff_data # {'style': 'inline', 'options': []}, |
|---|
| 329 | data['longcol'] = 'Revision', |
|---|
| 330 | data['shortcol'] = 'r' |
|---|
| 331 | data['style'] = style |
|---|