source: peerreviewplugin/trunk/codereview/changeset.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: 17.2 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2019-2021 Cinc
4#
5# All rights reserved.
6#
7# This software is licensed as described in the file COPYING.txt, which
8# you should have received as part of this distribution.
9import itertools
10
11from codereview.model import get_users, PeerReviewModel, PeerReviewerModel, \
12    ReviewDataModel, ReviewFileModel
13from codereview.peerReviewCommentCallback import writeJSONResponse, writeResponse
14from codereview.repo import file_lines_from_node, hash_from_file_node
15from codereview.repobrowser import get_node_from_repo
16from codereview.util import get_files_for_review_id, to_trac_path
17from functools import partial
18from trac.core import Component, implements
19from trac.resource import get_resource_url, Resource
20from trac.util.translation import _
21from trac.versioncontrol.api import Changeset, Node, RepositoryManager
22from trac.versioncontrol.diff import diff_blocks
23from trac.versioncontrol.web_ui.util import get_existing_node
24from trac.web.chrome import Chrome
25from trac.web.api import IRequestFilter, IRequestHandler
26from trac.web.chrome import add_script, add_script_data, add_stylesheet, web_context
27from trac.wiki.formatter import format_to_oneliner
28
29
30class PeerChangeset(Component):
31    """Create review for a changeset from the changeset page.
32
33    The created review holds the files from the changeset.
34
35    '''Note''': This plugin may be disabled without side effects.
36    """
37    implements(IRequestFilter, IRequestHandler)
38
39    # IRequestFilter methods
40
41    def pre_process_request(self, req, handler):
42        """Always returns the request handler, even if unchanged."""
43        return handler
44
45    def post_process_request(self, req, template, data, content_type):
46        """Do any post-processing the request might need;
47        `data` may be updated in place.
48
49        Always returns a tuple of (template, data, content_type), even if
50        unchanged.
51
52        Note that `template`, `data`, `content_type` will be `None` if:
53         - called when processing an error page
54         - the default request handler did not return any result
55        """
56        # Note that data is already filled with information about the source file, repo and what not
57        # We only handle the changeset page
58        if req.path_info.startswith('/changeset/'):
59            if data and 'changes' in data and data['changes']:
60                cset = data.get('new_rev', '')
61                reponame = data.get('reponame', '')
62                f_data = {}
63                review = get_review_for_changeset(self.env, cset, reponame)
64                if 'CODE_REVIEW_VIEW' in req.perm and cset:
65                    f_data = self.file_dict_from_changeset(req, cset, reponame, review)
66
67                add_stylesheet(req, 'hw/css/peerreview.css')
68                jdata = {'peer_repo': reponame,
69                         'peer_rev': cset,
70                         'peer_changeset_url': req.href.peerreviewchangeset(),
71                         'peer_comment_url': req.href.peercomment(),
72                         'tacUrl': req.href.chrome('/hw/images/thumbtac11x11.gif'),
73                         'peer_perm_dev': 0,
74                         'peer_new_file': 0,  # for deciding if we need to reload the page.
75                         'peer_pin_icon': req.href.chrome('/hw/images/thumbtac11x11.gif'),
76                         }
77                if f_data:
78                    jdata['peer_file_comments'] = f_data
79                if 'CODE_REVIEW_DEV' in req.perm:
80                    jdata['peer_perm_dev'] = 1
81
82                # These are files which were copied, moved or added. Trac normally doesn't
83                # have a diff view for them so add the data here.
84                for change in data['changes']:
85                    if change['change'] in ('add', 'copy', 'move') and not change['diffs']:
86                        jdata['peer_new_file'] = 1  # We reload the page after review creation
87                        if review:
88                            node = get_existing_node(self.env, data['repos'],
89                                                     change['new']['path'], change['new']['rev'])
90                            lines = file_lines_from_node(node)
91                            if lines:
92                                change['diffs'] = diff_blocks([], lines)
93                                # Don't add 'href' to this dict otherwise the header with revision
94                                # info will become a link. A value of None or '' won't prevent it.
95                                change['old'] = {'rev': ' ---',
96                                                 'shortrev': ' ---',
97                                                 }
98
99                add_script_data(req, jdata)
100                add_script(req, "hw/js/peer_trac_changeset.js")
101                add_script(req, "hw/js/peer_user_list.js")
102                Chrome(self.env).add_jquery_ui(req)
103                add_script(req, 'common/js/folding.js')
104
105        return template, data, content_type
106
107    def file_dict_from_changeset(self, req, cset, reponame, review=None):
108        """Get a dict with files belonging to this changeset when a review exists.
109
110        :param req: Request object. Needed internally to getz the authname
111        :param cset: this changeset revision. Not a changeset object of any kind.
112        :param reponame: name of the repository this changest belongs to.
113        :param review: Review object associated with this changeset. If this is None it will
114                       be queried here.
115        :return: a dict with key: file path, val: list [fileid, [line #, line #, ...]]
116                 note: the list contains line numbers.
117                 If there is no review already created for this changeset the dict is
118                 empty.
119        """
120        f_data = {}
121        review = review or get_review_for_changeset(self.env, cset, reponame)
122        if review:
123            # We ask for include comment inforamtion here
124            rfiles = get_files_for_review_id(self.env, req, review['review_id'], True)
125            for rfile in rfiles:
126                path = to_trac_path(rfile['path'])  # Trac path doesn't start with '/'. Db path does.
127                lines = set([comment['line_num'] for comment in rfile.comment_data])
128                if lines:
129                    f_data[path] = [rfile['file_id'], list(lines)]
130                else:
131                    f_data[path] = [rfile['file_id'], []]
132        return f_data
133
134    # IRequestHandler methods
135
136    def create_review_form(self, req):
137        _form = """
138<form id="create-peerreview-form" action="">
139  <input type="hidden" name="peer_repo" value="{reponame}" />
140  <input type="hidden" name="peer_rev" value="{rev}" />
141  <input type="hidden" name="Name" value="Changeset {rev} in repository '{reponame}'" />
142  <input type="hidden" name="__FORM_TOKEN" value="{form-token}" />
143  <fieldset>
144    <legend>Select reviewers for your review.</legend>
145    {userselect}
146    <div class="buttons">
147      <input id="create-review-submit" type="submit" name="create" value="Create Code Review"/>
148    </div>
149    <div><p class="help">{reload}</p></div>
150  </fieldset>
151</form>
152<div id="user-rem-confirm" title="Remove user?">
153    <p>
154    <span class="ui-icon ui-icon-alert" style="float:left; margin:0 7px 20px 0;"></span>
155    Really remove the user <strong id="user-rem-name"></strong> from the list?</p>
156</div>
157"""
158        tmpl_permission = """
159        <div class="collapsed">
160          <h3 class="foldable">{title}</h3>
161          <div id="peer-codereview">
162             %s
163          </div>
164        </div>
165        """.format(title=_('Codereview'))
166
167        if 'CODE_REVIEW_DEV' not in req.perm:
168            res = '<div id="peer-msg" class="system-message warning">%s</div>' % \
169                  _("You don't have permission to create a code review.")
170            return tmpl_permission % res
171
172        users = get_users(self.env)
173        chrome = Chrome(self.env)
174        data = {
175            'form-token': req.form_token,
176            'reponame': req.args.get('peer_repo', ''),
177            'rev': req.args.get('peer_rev', ''),
178            'new': 'yes',
179            'users': users,
180            'cycle': itertools.cycle,
181            'authorinfo': partial(chrome.authorinfo, req),
182            'reload': _("The page will be reloaded.") if req.args.getint('peer_new_file') == 1 else ""
183        }
184        if hasattr(Chrome, 'jenv'):
185            template = chrome.load_template('peerreviewuser_jinja.html')
186            data['userselect'] = chrome.render_template_string(template, data)
187        else:
188            template = chrome.load_template('user_list.html', None)
189            data['userselect'] = template.generate(**data).render()
190
191        peerreview_div = '<div class="collapsed"><h3 class="foldable">%s</h3>' \
192                         '<div id="peer-codereview">%s</div>' \
193                         '</div><div><p class="help">%s</p></div>' % \
194                         (_('Codereview'), _form.format(**data),
195                          _("You need to create a review if you want to comment on files."))
196        return peerreview_div
197
198    def create_review_info(self, req, review, create=False):
199        """Create html with inforamtion about the current changeset review.
200        :param req: Request object
201        :param review: Review object associated with this review
202        :param create: True if the review was just created and the page is updated,
203                       False if the page is normally loaded. There may be some
204                       presentation differences.
205        """
206        _rev_info = u"""
207            <dt class="property review">{review_id_label}</dt>
208            <dd class="review">{review_wiki}
209              <small><em>{reviewer_id_help}</em></small>
210            </dd>
211            <dt class="property">{status_label}</dt>
212            <dd>{status}</dd>
213            <dt class="property">{reviewers_label}</dt>
214            <dd>{user_list}</dd>
215        """
216        review_tmpl = """
217        <div>
218            <dl id="peer-review-info">
219              %s
220            </dl>
221        </div>
222        """.format(title=_('Codereview'))
223
224        def create_user_list(reviewer):
225            chrome = Chrome(self.env)
226            if reviewer:
227                usr = [u'<tr><td class="user-icon"><span class="ui-icon ui-icon-person"></span></td><td>%s</td></tr>' % chrome.authorinfo(req,
228                                                                                                      item['reviewer'])
229                       for item in reviewer]
230                li = u"".join(usr)
231            else:
232                li = '<tr><td>{msg}</td></tr>'.format(msg=_("There are no users included in this code review."))
233            return u'<table id="userlist">{li}</table>'.format(li=li)
234
235        if 'CODE_REVIEW_VIEW' not in req.perm:
236            no_perm_tmpl = """<dt class="property" style="margin-top: 1em">Review:</dt>
237            <dd style="margin-top: 1em"><span>{msg}</span></dd>"""
238            # TODO: this kind of information disclosure (you learn that a review exists) should be
239            #       removed. Same for the permission msg when creating reviews
240            return no_perm_tmpl.format(msg=_("You don't have permission to view code review information."))
241
242        res = Resource('peerreview', review['review_id'])
243        data = {
244            'status': review['status'],
245            'user_list': create_user_list(list(PeerReviewerModel.select_by_review_id(self.env, review['review_id']))),
246            'review_url': get_resource_url(self.env, res, req.href),
247            'review_id': review['review_id'],
248            'status_label': _("Status:"),
249            'review_id_label': _("Review:"),
250            'reviewers_label': _("Reviewers:"),
251            'reviewer_id_help': _("(click to open review page)"),
252            'review_wiki': format_to_oneliner(self.env, web_context(req),
253                                              "[review:{r_id} Review {r_id}]".format(r_id=review['review_id']))
254        }
255
256        if create:
257            return review_tmpl % _rev_info.format(**data)
258        else:
259            return _rev_info.format(**data)
260
261    def match_request(self, req):
262        return req.path_info == '/peerreviewchangeset'
263
264    def process_request(self, req):
265
266        if req.method == 'POST':
267            req.perm.require('CODE_REVIEW_DEV')
268
269            review = create_changeset_review(self, req)
270            if not review:
271                data = {'html': '<div id="peer-msg" class="system-message warning">%s</div>' %
272                                _('Error while creating Review.'),
273                        'success': 0}
274                writeJSONResponse(req, data)
275            else:
276                f_data = self.file_dict_from_changeset(req, req.args.get('peer_rev'), req.args.get('peer_repo'))
277
278                data = {'html': self.create_review_info(req, review, True),
279                        'filedata': f_data,
280                        'success': 1}
281                writeJSONResponse(req, data)
282            return
283
284        dm = ReviewDataModel(self.env)
285        dm['type'] = 'changeset'
286        dm['data'] = "%s:%s" % (req.args.get('peer_repo', ''), req.args.get('peer_rev', ''))
287        rev_data = list(dm.list_matching_objects())
288
289        # Permission handling is done in the called methods so the proper message is created there.
290        if not rev_data:
291            data = {'action': 'create',
292                    'html': self.create_review_form(req)}
293            writeJSONResponse(req, data)
294        else:
295            review = PeerReviewModel(self.env, rev_data[-1]['review_id'])
296            data = {'action': 'info',
297                    'html': self.create_review_info(req, review, False)}
298            writeJSONResponse(req, data)
299
300
301def create_changeset_review(self, req):
302    """Create a new code review from the data in the request object req.
303
304    Takes the information given when the page is posted and creates a
305    new code review in the database and populates it with the
306    information. Also creates new reviewer and file data for
307    the review.
308    """
309    rev = req.args.get('peer_rev')
310    reponame = req.args.get('peer_repo')
311    repo = RepositoryManager(self.env).get_repository(reponame)
312    if not repo:
313        return None
314
315    changeset = repo.get_changeset(rev)
316    if not changeset:
317        return None
318
319    review = PeerReviewModel(self.env)
320    review['owner'] = req.authname
321    review['name'] = req.args.get('Name')
322    review['notes'] = req.args.get('Notes')
323    if req.args.get('project'):
324        review['project'] = req.args.get('project')
325    review.insert()
326    id_ = review['review_id']
327
328    # Create reviewer entries
329    user = req.args.getlist('user')
330    if not type(user) is list:
331        user = [user]
332    for name in user:
333        if name != "":
334            reviewer = PeerReviewerModel(self.env)
335            reviewer['review_id'] = id_
336            reviewer['reviewer'] = name
337            reviewer['vote'] = -1
338            reviewer.insert()
339
340    # Create file entries
341    path, kind, change, base_path, base_rev = range(0, 5)
342    for item in changeset.get_changes():
343        if item[change] != Changeset.DELETE:
344            rfile = ReviewFileModel(self.env)
345            rfile['review_id'] = id_
346            # Path must have leading '/' in the database for historical reasons.
347            rfile['path'] = u'/' + item[path].lstrip('/')
348            rfile['revision'] = rev
349            rfile['line_start'] = 0
350            rfile['line_end'] = 0
351            rfile['repo'] = reponame
352            node, display_rev, context = get_node_from_repo(req, repo, rfile['path'], rfile['revision'])
353            if node and node.kind == Node.FILE:
354                rfile['changerevision'] = rev
355                rfile['hash'] = hash_from_file_node(node)
356                rfile.insert()
357
358    # Mark that this is a changeset review
359    dm = ReviewDataModel(self.env)
360    dm['review_id'] = id_
361    dm['type'] = 'changeset'
362    dm['data'] = "%s:%s" % (reponame, rev)
363    dm.insert()
364    return review
365
366
367def get_review_for_changeset(env, cs_num, repo_name):
368    """Get a PeerReview from the given repo:changset combination.
369     Return None if not found."""
370    dm = ReviewDataModel(env)
371    dm['type'] = 'changeset'
372    dm['data'] = "%s:%s" % (repo_name, cs_num)  # this will automatically skip closed reviews
373    rev_data = list(dm.list_matching_objects())
374
375    # Permission handling is done in the called methods so the proper message is created there.
376    if rev_data:
377        return PeerReviewModel(env, rev_data[-1]['review_id'])
378    else:
379        return None
380
381
382def get_changeset_data(env, review_id):
383    """Return changeset information for the given review id if any.
384
385    Checks the table 'peerreviewdata' for a changeset entry for this
386    review id.
387
388    @param review_id: numeric id of a review
389    @return: list [reponame, changeset] or ['', ''] if no changeset review
390    """
391    dm = ReviewDataModel(env)
392    dm['type'] = 'changeset'
393    dm['review_id'] = review_id
394    rev_data = list(dm.list_matching_objects())
395    if not rev_data:
396        return ['', '']
397    return rev_data[-1]['data'].split(':')[:2]
Note: See TracBrowser for help on using the repository browser.