source: peerreviewplugin/trunk/codereview/peerReviewPerform.py

Last change on this file was 18287, checked in by Cinc-th, 2 years ago

PeerReviewPlugin: improvements to changeset reviews:

  • Allow to create another changeset review when old review was closed.
  • Show pin icon in lines with comments
  • Hide/show comments

Some minor improvements to presentation like titles for revision links.

Refs #14007

File size: 22.1 KB
RevLine 
[13497]1#
2# Copyright (C) 2005-2006 Team5
[18287]3# Copyright (C) 2016-2021 Cinc
[13497]4# All rights reserved.
5#
6# This software is licensed as described in the file COPYING.txt, which
7# you should have received as part of this distribution.
8#
[717]9# Author: Team5
10#
11
12# Code Review plugin
13# This class handles the display for the perform code review page
14# The file contents are taken from the respository and converted to
15# an HTML friendly format.  The line annotator customizes the
16# repository browser's line number to indicate what lines are being
17# reviewed and if there are any comments on a particular line.
[17462]18import os
[18282]19import re
[17460]20from codereview.changeset import get_changeset_data
[17443]21from codereview.model import Comment, ReviewCommentModel, PeerReviewModel, ReviewFileModel
[18284]22from codereview.repo import file_lines_from_node
[18278]23from codereview.util import get_changeset_html, get_review_for_file, not_allowed_to_comment,\
[18279]24    review_is_finished, review_is_locked, to_trac_path
[717]25from trac.core import *
26from trac.mimeview import *
27from trac.mimeview.api import IHTMLPreviewAnnotator
[17461]28from trac.resource import ResourceNotFound
[18248]29from trac.util.datefmt import format_date, to_datetime, user_time
[16616]30from trac.util.html import html as tag
[18048]31from trac.util.translation import _
[17462]32from trac.web.chrome import add_ctxtnav, INavigationContributor, Chrome, \
[18287]33                            add_stylesheet, add_script_data, add_script, web_context
[13497]34from trac.web.main import IRequestHandler
[18248]35from trac.wiki.formatter import format_to_html
[717]36from trac.versioncontrol.web_ui.util import *
[17461]37from trac.versioncontrol.api import InvalidRepository, RepositoryManager
[15204]38from trac.versioncontrol.diff import diff_blocks, get_diff_options
[3452]39
[15518]40
[15383]41class PeerReviewPerform(Component):
42    """Perform a code review.
43    """
[17283]44    implements(INavigationContributor, IRequestHandler, IHTMLPreviewAnnotator)
[717]45
[18282]46    peerreview_file_re = re.compile(r'/peerreviewfile/([0-9]+)$')
[17283]47    # IHTMLPreviewAnnotator methods
[15227]48
[717]49    def get_annotation_type(self):
[13497]50        return 'performCodeReview', 'Line', 'Line numbers'
[717]51
[3452]52    def get_annotation_data(self, context):
[17283]53        return CommentAnnotator(self.env, context, 'chrome/hw/images/thumbtac11x11.gif')
[15312]54
[17283]55    def annotate_row(self, context, row, lineno, line, comment_annotator):
56        """line annotator for Perform Code Review page.
[15312]57
[17283]58        If line has a comment, places an icon to indicate comment.
59        If line is not in the rage of reviewed lines, it makes the color a light gray
60        """
61        comment_annotator.annotate(row, lineno)
[15516]62
[717]63    # INavigationContributor methods
[15242]64
[717]65    def get_active_navigation_item(self, req):
[18261]66        return 'peerreviewmain'
[13497]67
[717]68    def get_navigation_items(self, req):
69        return []
[15227]70
[717]71    # IRequestHandler methods
[17443]72
[717]73    def match_request(self, req):
[18282]74        match = self.peerreview_file_re.match(req.path_info)
75        if match:
76            req.args['fileid'] = match.group(1)
77            return True
78        self.env.log.info("Legacy URL 'peerReviewPerform' or 'peerreviewperform' called from: %s",
79                          req.get_header('Referer'))
[18261]80        if req.path_info == '/peerreviewperform':
[18282]81            self.env.log.info("Legacy URL 'peerreviewperform' called from: %s", req.get_header('Referer'))
[18261]82            return True
83        elif req.path_info == '/peerReviewPerform':
84            self.env.log.info("Legacy URL 'peerReviewPerform' called from: %s", req.get_header('Referer'))
85            return True
86        return False
[13497]87
[717]88    def process_request(self, req):
[18265]89        req.perm.require('CODE_REVIEW_VIEW')
[3780]90
[18282]91        fileid = req.args.get('fileid', req.args.get('IDFile'))
[15288]92        if not fileid:
[15201]93            raise TracError("No file ID given - unable to load page.", "File ID Error")
[717]94
[17283]95        r_file = ReviewFileModel(self.env, fileid)
[17461]96        if not r_file.exists:
97            raise ResourceNotFound("File not found.", "File ID Error")
[15174]98
[15674]99        repos = RepositoryManager(self.env).get_repository(r_file['repo'])
100        if not repos:
[17461]101            raise InvalidRepository("Unable to acquire repository.", "Repository Error")
[15674]102
[17461]103        review = PeerReviewModel(self.env, r_file['review_id'])
104
[18248]105        review.date = user_time(req, format_date, to_datetime(review['created']))
106        review.html_notes = format_to_html(self.env, web_context(req), review['notes'])
107
[17461]108        data = {'file_id': fileid,
109                'review_file': r_file,
110                'review': review,
111                'fullrange': True if int(r_file['line_start']) == 0 else False,
[18278]112                'node': self.get_current_file_node(r_file, repos),
113                'display_rev': repos.display_rev
[17461]114                }
115
116        # Add parent data if any
[18248]117        data.update(self.parent_data(req, review, r_file, repos))
[17461]118
[18279]119        # Handle changeset reviews
[18278]120        reponame, changeset = get_changeset_data(self.env, review['review_id'])
121        data.update({'changeset': changeset,
122                     'repo': reponame,
123                     'changeset_html': get_changeset_html(self.env, req, repos.display_rev(changeset), repos)})
[17462]124        if data['changeset']:
125            # This simulates a parent review by creating some temporary data
126            # not backed by the database. If it's a new file, parent information is omitted
127            data.update(self.changeset_data(data, r_file, repos))
128
[17461]129        if data['parent_review']:
130            # A followup review with diff viewer
131            data.update(create_diff_data(req, data))
132        else:
133            data.update(self.preview_for_file(req, r_file, data['node']))
134
135        # A finished review can't be changed anymore except by a manager
136        data['is_finished'] = review_is_finished(self.env.config, review)
137        # A user can't chnage his voting for a reviewed review
138        data['review_locked'] = review_is_locked(self.env.config, review, req.authname)
139        data['not_allowed'] = not_allowed_to_comment(self.env, review, req.perm, req.authname)
140
141        Chrome(self.env).add_jquery_ui(req)
142
143        add_stylesheet(req, 'common/css/code.css')
144        add_stylesheet(req, 'common/css/diff.css')
145        add_stylesheet(req, 'hw/css/peerreview.css')
146        add_script_data(req, self.create_script_data(req, data))
147        add_script(req, 'common/js/auto_preview.js')
148        add_script(req, "hw/js/peer_review_perform.js")
[17462]149        self.add_ctxt_nav_items(req, review, r_file)
[17461]150
[18242]151        if hasattr(Chrome, 'jenv'):
152            return 'peerreview_perform_jinja.html', data
153        else:
[18246]154            return 'peerreview_perform.html', data, None
[17461]155
[17462]156    def add_ctxt_nav_items(self, req, review, r_file):
157        rev_files = ["%s:%s" % (item['path'], item['file_id'])
158                     for item in ReviewFileModel.select_by_review(self.env, review['review_id'])]
159        idx = rev_files.index("%s:%s" % (r_file['path'], r_file['file_id']))
160        if not idx:
161            add_ctxtnav(req, _("Previous File"))
162        else:
163            path, file_id = rev_files[idx - 1].split(':')
164            path = os.path.basename(path)
[18282]165            add_ctxtnav(req, _("Previous File"), req.href.peerreviewfile(file_id), title=_("File %s") % path)
[17462]166        if idx == len(rev_files)-1:
167            add_ctxtnav(req, _("Next File"))
168        else:
169            path, file_id = rev_files[idx + 1].split(':')
170            path = os.path.basename(path)
[18282]171            add_ctxtnav(req, _("Next File"), req.href.peerreviewfile(file_id), title=_("File %s") % path)
[17462]172
[17461]173    def create_script_data(self, req, data):
[18279]174        """Create a dict with data for javascript. This includes
175        comment information, file id, review id and other.
176
177        :param req: Request object from process_request()
178        :param data: data dictionary holding some review information.
179        :return dict with data for javascript
180
181        Note that 'data' is not changed in place here. A new dict is created for use with
182        add_script_data().
183        """
[17461]184        r_file = data['review_file']
185        scr_data = {'peer_comments': sorted(list(set([c.line_num for c in
186                                                      Comment.select_by_file_id(self.env, r_file['file_id'])]))),
187                    'peer_file_id': r_file['file_id'],
[18279]188                    'peer_file_path': to_trac_path(r_file['path']),
[17461]189                    'peer_review_id': r_file['review_id'],
190                    'auto_preview_timeout': self.env.config.get('trac', 'auto_preview_timeout', '2.0'),
191                    'form_token': req.form_token,
192                    'peer_diff_style': data['style'] if 'style' in data else 'no_diff'}
193        if data['parent_review']:
194            scr_data['peer_parent_file_id'] = data['par_file']['file_id']
195            scr_data['peer_parent_comments'] = sorted(list(set([c.line_num for c in
196                                                                Comment.select_by_file_id(self.env, data['par_file']['file_id'])])))
197        else:
198            scr_data['peer_parent_file_id'] = 0  # Mark that we don't have a parent
199            scr_data['peer_parent_comments'] = []
200        return scr_data
201
202    def preview_for_file(self, req, r_file, node):
203        # Generate HTML preview - this code take from Trac - refer to their documentation
204        mime_type = node.content_type
205        self.env.log.debug("mime_type taken from node.content_type: %s" % (mime_type,))
206        if not mime_type or mime_type == 'application/octet-stream':
207            mime_type = get_mimetype(node.name) or mime_type or 'text/plain'
208
209        mimeview = Mimeview(self.env)
210        content = node.get_content().read(mimeview.max_preview_size)  # We get the raw data without keyword substitution
211
212        context = web_context(req, 'rfile', r_file['file_id'])
213        context.set_hints(reviewfile=r_file)
214
215        self.env.log.debug("Creating preview data for %s with mime_type = %s" % (node.created_path, mime_type))
216        preview_data = mimeview.preview_data(context, content, len(content),
217                                             mime_type, node.created_path,
218                                             None,
219                                             annotations=['performCodeReview'])
220
221        # TODO: use in template 'preview.rendered' instead similar to preview_file.html
222        return {'file_rendered': preview_data['rendered'],
223                'preview': preview_data
224                }
225
226    def get_current_file_node(self, r_file, repos):
[15204]227        # The following may raise an exception if revision can't be found
[17283]228        rev = r_file['changerevision']  # last change for the given file
[15204]229        if rev:
230            rev = repos.normalize_rev(rev)
231        rev_or_latest = rev or repos.youngest_rev
232
[17406]233        if repos.has_node(r_file['path'], rev_or_latest):
234            node = get_existing_node(self.env, repos, r_file['path'], rev_or_latest)
235        else:
236            self.log.info("No Node for file '%s' in revision %s. Using repository revision %s instead.",
237                          r_file['path'], rev_or_latest, repos.youngest_rev)
238            node = get_existing_node(self.env, repos, r_file['path'], repos.youngest_rev)
[17461]239        return node
[17406]240
[18248]241    def parent_data(self, req, review, r_file, repos):
[17461]242        """Create a dictionary with data about parent review.
243
244        The dictionary is used to update the 'data' dict.
245
246        @param review: PeerReviewModel object representing the current review
247        @param r_file: ReviewFileModel object representing the current file
248        @param repos: Trac Repository holding the file
249        @return: dict with additional data for the 'data' dict
250        """
[17405]251        par_review = None
252        par_file = None
[17461]253        par_node = None
[15294]254        if review['parent_id'] != 0:
[17405]255            par_file_id = get_parent_file_id(self.env, r_file, review['parent_id'])
256            # If this is a file added to the review we don't have a parent file
257            if par_file_id:
258                par_review = PeerReviewModel(self.env, review['parent_id'])  # Raises 'ResourceNotFound' on error
[18248]259                par_review.date = user_time(req, format_date, to_datetime(par_review['created']))
[17405]260                par_file = ReviewFileModel(self.env, par_file_id)
261                lines = [c.line_num for c in Comment.select_by_file_id(self.env, par_file['file_id'])]
262                par_file.comments = list(set(lines))  # remove duplicates
263                par_revision = par_file['revision']
264                if par_revision:
265                    par_revision = repos.normalize_rev(par_revision)
266                rev_or_latest = par_revision or repos.youngest_rev
267                par_node = get_existing_node(self.env, repos, par_file['path'], rev_or_latest)
[15204]268
[17461]269        return {'par_file': par_file,
270                'parent_review': par_review,
271                'par_node': par_node
272                }
[3452]273
[17461]274    def changeset_data(self, data, r_file, repos):
275        """Create a dictionary with data about previous file revision for changeset review.
[17460]276
[17461]277        The dictionary is used to update the 'data' dict.
[717]278
[17461]279        @param review: PeerReviewModel object representing the current review
280        @param r_file: ReviewFileModel object representing the current file
281        @param repos: Trac Repository holding the file
282        @return: dict with additional data for the 'data' dict
283        """
284        # Just to keep the template happy. May possibly be removed later
285        par_review = PeerReviewModel(self.env)
[3452]286
[17461]287        prev = data['node'].get_previous()
[3452]288
[17462]289        if not prev:
290            # This file was added
291            return {'par_file': None,
292                    'parent_review': None,
293                    'par_node': None
294                    }
295
[17461]296        # Create a temp file object for holding the data for the template
297        par_file = ReviewFileModel(self.env)
[17462]298        rev = str(prev[1])
299        rev_or_latest = prev[1] or repos.youngest_rev
[17461]300        par_file['path'] = prev[0]
[17462]301        par_file['changerevision'] = rev
302        par_file['revision'] = rev
[17461]303        par_file['line_start'] = 0
[17462]304        par_file['repo'] = r_file['repo']
[17461]305        par_file.comments = []
306        par_node = get_existing_node(self.env, repos, par_file['path'], rev_or_latest)
[15195]307
[17461]308        return {'par_file': par_file,
309                'parent_review': par_review,
310                'par_node': par_node
311                }
[13497]312
[15312]313
[17283]314class CommentAnnotator(object):
315    """Annotator object which handles comments in source view."""
[17402]316    def __init__(self, env, context, imagepath, name=None):
[17283]317        self.env = env
318        self.context = context
319        self.imagepath = imagepath
[17402]320
321        # We use the annotator on the browser page
322        if name == 'prcomment':
323            self.prep_browser(context)
324        else:
325            self. prep_peer(context)
326
327    def prep_peer(self, context):
[17283]328        authname = context.req.authname
329        perm = context.req.perm
[17443]330        fresource = context.resource  # This is an 'peerreviewfile' realm
331        review = get_review_for_file(self.env, fresource.id)
[17283]332        # Is it allowed to comment on the file?
333        if review_is_finished(self.env.config, review):
334            is_locked = True
335        else:
336            is_locked = review_is_locked(self.env.config, review, authname)
337
338        # Don't let users comment who are not part of this review
339        if not_allowed_to_comment(self.env, review, perm, authname):
340            is_locked = True
341
[17443]342        self.data = [[c['line_num'] for c in ReviewCommentModel.select_by_file_id(self.env, fresource.id)], review, is_locked]
[17283]343
[17402]344    def prep_browser(self, context):
345        def comments_for_file(env, path, rev):
[18049]346            with env.db_query as db:
347                cursor = db.cursor()
348                cursor.execute("""SELECT c.line_num, c.comment_id, f.file_id,
349                f.review_id
350                FROM peerreviewfile AS f
351                JOIN peerreviewcomment as c ON c.file_id = f.file_id
352                WHERE f.path = %s
353                AND f.changerevision = %s
354                """, (path, rev))
[17402]355
[18049]356                d = {}
357                file_id = 0
358                for row in cursor:
359                    d[row[0]] = row[2]
360                    file_id = row[2]
361                return d, file_id
[17402]362
363        self.path = '/' + context.resource.id
364        self.rev = context.resource.version
365        self.data, fileid = comments_for_file(self.env, self.path, self.rev)
366
367        scr_data = {'peer_comments': [],  # sorted(list(set([c.line_num for c in
368                    #                                  Comment.select_by_file_id(self.env, r_file['file_id'])]))),
369                    'peer_file_id': fileid,
370                    #'peer_review_id': r_file['review_id'],
371                    'auto_preview_timeout': self.env.config.get('trac', 'auto_preview_timeout', '2.0'),
372                    'form_token': context.req.form_token,
373                    'baseUrl': context.req.href.peerReviewCommentCallback(),
374                    'peer_diff_style': 'no_diff'}  # data['style'] if 'style' in data else 'no_diff'}
375
376        scr_data['peer_parent_file_id'] = 0  # Mark that we don't have a parent
377        scr_data['peer_parent_comments'] = []
378
379        add_script_data(context.req, scr_data)
380        #add_stylesheet(context.req, 'common/css/code.css')
381        #add_stylesheet(context.req, 'common/css/diff.css')
382        chrome = Chrome(self.env)
383        chrome.add_auto_preview(context.req)
384        chrome.add_jquery_ui(context.req)
385        add_stylesheet(context.req, 'hw/css/peerreview.css')
386        add_script(context.req, "hw/js/peer_review_perform.js")
387
[17283]388    def annotate(self, row, lineno):
389        """line annotator for Perform Code Review page.
390
391        If line has a comment, places an icon to indicate comment.
392        If line is not in the rage of reviewed lines, it makes the color a light gray
393        """
394        r_file = self.context.get_hint('reviewfile')
395        file_id = self.context.resource.id
396        data = self.data
397        if (lineno <= int(r_file['line_end']) and lineno >= int(r_file['line_start'])) or int(r_file['line_start']) == 0:
398            # If there is a comment on this line
399            lines = data[0]
400            # review = data[1]
401            if lineno in lines:
402                return row.append(tag.th(id='L%s' % lineno)(tag.a(tag.img(src='%s' % self.imagepath) + ' ' + str(lineno),
403                                                                  href='javascript:getComments(%s, %s)' %
404                                                                       (lineno, file_id))))
405            if not data[2]:
406                return row.append(tag.th(id='L%s' % lineno)(tag.a(lineno, href='javascript:addComment(%s, %s, -1)'
407                                                                               % (lineno, file_id))))
408            else:
409                return row.append(tag.th(str(lineno), id='L%s' % lineno))
410
[17443]411        # color line numbers outside range light gray
[17283]412        row.append(tag.th(id='L%s' % lineno)(tag.font(lineno, color='#CCCCCC')))
413
[17402]414    def annotate_browser(self, row, lineno):
415        if lineno in self.data:
416            row.append(tag.th(id='C%s' % lineno)(tag.a(tag.img(src=self.context.req.href(self.imagepath)),
[17443]417                                                               href='javascript:getComments(%s, %s)' %
418                                                                    (lineno, self.data[lineno]))))
[17402]419        else:
420            comment_col = tag.th(class_='prcomment')
421            row.append(comment_col)
422
423
[15513]424def get_parent_file_id(env, r_file, par_review_id):
[15204]425
[15513]426    fid = u"%s%s%s" % (r_file['path'], r_file['line_start'], r_file['line_end'])
[15204]427
[15513]428    rfiles = ReviewFileModel.select_by_review(env, par_review_id)
[15204]429    for f in rfiles:
[15513]430        tmp = u"%s%s%s" % (f['path'], f['line_start'], f['line_end'])
[15204]431        if tmp == fid:
[15513]432            return f['file_id']
[15204]433    return 0
434
435
[17461]436def create_diff_data(req, data):
[15204]437    style, options, diff_data = get_diff_options(req)
438
[17461]439    node = data['node']
440    par_node = data['par_node']
441
[18284]442    old = file_lines_from_node(par_node)
443    new = file_lines_from_node(node)
[15204]444
445    if diff_data['options']['contextall']:
446        context = None
447    else:
448        context = diff_data['options']['contextlines']
449
450    diff = diff_blocks(old, new, context=context,
451                       ignore_blank_lines=diff_data['options']['ignoreblanklines'],
452                       ignore_case=diff_data['options']['ignorecase'],
453                       ignore_space_changes=diff_data['options']['ignorewhitespace'])
454
455    changes = []
[17462]456    file_href = req.href.browser(data['review_file']['repo'],
457                                 data['review_file']['path'],
458                                 rev=data['review_file']['changerevision'])
459    par_rev = par_node.rev if par_node else ''
460    # This is shown in the header of the table
461    par_rev_info = " (Review #%s)" % data['parent_review']['review_id'] \
462        if data['parent_review'].exists else ''
463    par_href = req.href.browser(data['par_file']['repo'],
464                                data['par_file']['path'],
465                                rev=data['par_file']['changerevision'])
[17443]466    info = {'diffs': diff,
[17461]467            'new': {'path': node.path,
468                    'rev': "%s (Review #%s)" % (node.rev, data['review']['review_id']),
[18278]469                    'shortrev': data['display_rev'](node.rev),
[18287]470                    'href': file_href,
471                    'title': _("Show revision %(rev)s of this file in browser",
472                               rev=data['display_rev'](node.rev))
[17461]473                    },
[17462]474            'old': {'path': par_node.path if par_node else '',
475                    'rev': "%s%s" % (par_rev, par_rev_info),
[18278]476                    'shortrev': data['display_rev'](par_rev),
[18287]477                    'href': par_href,
478                    'title': _("Show revision %(rev)s of this file in browser",
479                               rev=data['display_rev'](par_rev))
[17461]480                    },
[15204]481            'props': []}
482    changes.append(info)
[17461]483    return {'changes': changes,
484            'diff': diff_data,  # {'style': 'inline', 'options': []},
485            'longcol': 'Revision',
486            'shortcol': 'r',
487            'style': style,
488            'nochanges': True if old == new else False
489            }
Note: See TracBrowser for help on using the repository browser.