review/extension_ui.py @ 85b4ae69ded2 webpy-sucks

Use the new annotated diff functionality to fix the changeset web UI.
author Steve Losh <steve@stevelosh.com>
date Wed, 03 Mar 2010 19:32:03 -0500
parents ddea94e8e138
children b1448ebe2909
"""The review extension's command-line UI.

This module is imported in __init__.py so that Mercurial will add the
review command to its own UI when you add the extension in ~/.hgrc.

"""

import operator, os
import re
import messages
from api import *
from mercurial import help, templatefilters, util
from mercurial.node import short
from mercurial import commands, extensions

def _web_command(ui, repo, **opts):
    ui.note(messages.WEB_START)
    
    import web_ui
    web_ui.load_interface(ui, repo, open=True)

def _init_command(ui, repo, **opts):
    ui.note(messages.INIT_START)
    
    try:
        ReviewDatastore(ui, repo, rpath=opts.pop('remote_path'), create=True)
        if '.hgreview' not in repo['tip'].files():
            ui.status(messages.INIT_SUCCESS_UNCOMMITTED)
        else:
            ui.status(messages.INIT_SUCCESS_CLONED)
    except RelativeRemotePath:
        raise util.Abort(messages.INIT_UNSUPPORTED_RELATIVE_RPATH)
    except DatastoreRequiresRemotePath:
        raise util.Abort(messages.INIT_REQUIRES_REMOTE_PATH)
    except PreexistingDatastore, e:
        if e.committed:
            ui.note(messages.INIT_EXISTS)
        else:
            raise util.Abort(messages.INIT_EXISTS_UNCOMMITTED)

def _comment_command(ui, repo, *fnames, **opts):
    rev = opts.pop('rev')
    message = opts.pop('message')
    lines = opts.pop('lines')
    
    rd = ReviewDatastore(ui, repo)
    rcset = rd[rev]
    
    if lines and not len(fnames) == 1:
        raise util.Abort(messages.COMMENT_LINES_REQUIRE_FILE)
    
    if not message:
        raise util.Abort(messages.COMMENT_REQUIRES_MESSAGE)
    
    if lines:
        lines=lines.split(',')
    
    if fnames:
        fnames = map(lambda f: sanitize_path(f, repo), fnames)
    else:
        fnames = ['']
    
    for fn in fnames:
        try:
            rcset.add_comment(message=message, filename=fn, lines=lines)
        except FileNotInChangeset:
            raise util.Abort(
                messages.COMMENT_FILE_DOES_NOT_EXIST % (fn, repo[rev].rev())
            )

def _signoff_command(ui, repo, **opts):
    rd = ReviewDatastore(ui, repo)
    rcset = rd[opts.pop('rev')]
    message = opts.pop('message')
    
    if not message:
        raise util.Abort(messages.SIGNOFF_REQUIRES_MESSAGE)
    
    yes, no = opts.pop('yes'), opts.pop('no')
    if yes and no:
        raise util.Abort(messages.SIGNOFF_OPINION_CONFLICT)
    opinion = 'yes' if yes else ('no' if no else '')
    
    try:
        rcset.add_signoff(message=message, opinion=opinion,
            force=opts.pop('force'))
    except SignoffExists:
        raise util.Abort(messages.SIGNOFF_EXISTS)

def _review_command(ui, repo, *fnames, **opts):
    rev = opts.pop('rev')
    context = int(opts.pop('unified'))
    
    rd = ReviewDatastore(ui, repo)
    cset = repo[rev]
    rcset = rd[rev]
    
    comment_count = len(rcset.comments)
    author_count = len(set(comment.author for comment in rcset.comments))
    
    ui.write(messages.REVIEW_LOG_CSET % (cset.rev(), short(cset.node())))
    ui.write(messages.REVIEW_LOG_AUTHOR % cset.user())
    ui.write(messages.REVIEW_LOG_SUMMARY % cset.description().split('\n')[0])
    
    signoffs = rcset.signoffs
    signoffs_yes = filter(lambda s: s.opinion == 'yes', signoffs)
    signoffs_no = filter(lambda s: s.opinion == 'no', signoffs)
    signoffs_neutral = set(signoffs).difference(signoffs_yes + signoffs_no)
    
    ui.write(messages.REVIEW_LOG_SIGNOFFS % (
        len(signoffs), len(signoffs_yes), len(signoffs_no), len(signoffs_neutral))
    )
    ui.write(messages.REVIEW_LOG_COMMENTS % (comment_count, author_count))
    
    def _print_comment(comment, before='', after=''):
        ui.write(before)
        
        author = templatefilters.person(comment.author)
        author_part = messages.REVIEW_LOG_COMMENT_AUTHOR % author
        
        age = templatefilters.age(comment.hgdate)
        age_part = messages.REVIEW_LOG_AGE % age 
        
        spacing = ' ' * (80 - (len(author_part) + len(age_part)))
        
        ui.write(author_part + spacing + age_part + '\n')
        for line in comment.message.splitlines():
            ui.write(messages.REVIEW_LOG_COMMENT_LINE % line)
        
        ui.write(after)
    
    def _print_signoff(signoff, before='', after=''):
        ui.write(before)
        
        author = templatefilters.person(signoff.author)
        opinion = signoff.opinion or 'neutral'
        author_part = messages.REVIEW_LOG_SIGNOFF_AUTHOR % (author, opinion)
        
        age = templatefilters.age(signoff.hgdate)
        age_part = messages.REVIEW_LOG_AGE % age
        
        spacing = ' ' * (80 - (len(author_part) + len(age_part)))
        
        ui.write(author_part + spacing + age_part + '\n')
        for line in signoff.message.splitlines():
            ui.write(messages.REVIEW_LOG_SIGNOFF_LINE % line)
        
        ui.write(after)
    
    
    if rcset.signoffs:
        ui.write('\n')
    for signoff in rcset.signoffs:
        _print_signoff(signoff, before='\n')
        
    review_level_comments = rcset.review_level_comments()
    if review_level_comments:
        ui.write('\n')
    for comment in review_level_comments:
        _print_comment(comment, before='\n')
    
    if ui.quiet:
        return
    
    if not fnames:
        fnames = rcset.files()
    fnames = [sanitize_path(fname, repo) for fname in fnames]
    fnames = [fname for fname in fnames if rcset.has_diff(fname)]
    
    for filename in fnames:
        header = messages.REVIEW_LOG_FILE_HEADER % filename
        print '\n\n%s %s' % (header, '-'*(80-(len(header)+1)))
        
        for comment in rcset.file_level_comments(filename):
            _print_comment(comment)
        
        annotated_diff = rcset.annotated_diff(filename, context)
        prefix = '%%%dd: ' % len(str(annotated_diff.next()))
        
        for line in annotated_diff:
            if line['skipped']:
                ui.write(messages.REVIEW_LOG_SKIPPED % line['skipped'])
                for comment in line['comments']:
                    _print_comment(comment)
                continue
            
            ui.write('%s %s\n' % (prefix % line['number'], line['content']))
            
            for comment in line['comments']:
                _print_comment(comment)


_review_effects = {
    'deleted': ['red'],
    'inserted': ['green'],
    'comments': ['cyan'],
}
_review_re = [
    (re.compile(r'^(?P<rest> *\d+:  )(?P<colorized>[-].*)'), 'deleted'),
    (re.compile(r'^(?P<rest> *\d+:  )(?P<colorized>[+].*)'), 'inserted'),
    (re.compile(r'^(?P<colorized>#.*)'), 'comments'),
]

def colorwrap(orig, *args):
    '''wrap ui.write for colored diff output'''
    def _colorize(s):
        lines = s.split('\n')
        for i, line in enumerate(lines):
            if not line:
                continue
            else:
                for r, style in _review_re:
                    m = r.match(line)
                    if m:
                        lines[i] = "%s%s" % (m.groupdict().get('rest', ''),
                                             render_effects(m.group('colorized'), _review_effects[style]))
                        break
        return '\n'.join(lines)
    
    orig(*[_colorize(s) for s in args])

def colorreview(orig, ui, repo, *fnames, **opts):
    '''colorize review command output'''
    oldwrite = extensions.wrapfunction(ui, 'write', colorwrap)
    try:
        orig(ui, repo, *fnames, **opts)
    finally:
        ui.write = oldwrite

_ui = None
def uisetup(ui):
    global _ui
    _ui = ui

def extsetup():
    try:
        color = extensions.find('color')
        color._setupcmd(_ui, 'review', cmdtable, colorreview,
                       _review_effects)
        global render_effects
        render_effects = color.render_effects
    except KeyError:
        pass


def review(ui, repo, *fnames, **opts):
    """code review changesets in the current repository
    
    To start using the review extension with a repository, you need to
    initialize the code review data:
    
        hg help review-init
    
    Once you've initialized it (and cloned the review data repo to a
    place where others can get to it), you can start reviewing changesets.
    
    See the following help topics if you want to use the command-line
    interface:
    
        hg help review-review
        hg help review-comment
        hg help review-signoff
    
    Once you've reviewed some changesets, don't forget to push your
    comments and signoffs so other people can view them.
    
    """
    if opts.pop('web'):
        return _web_command(ui, repo, **opts)
    elif opts.pop('init'):
        return _init_command(ui, repo, **opts)
    elif opts.pop('comment'):
        return _comment_command(ui, repo, *fnames, **opts)
    elif opts.pop('signoff'):
        return _signoff_command(ui, repo, **opts)
    else:
        return _review_command(ui, repo, *fnames, **opts)


cmdtable = {
    'review': (review, [
        ('i', 'init',        False, 'start code reviewing this repository'),
        ('',  'remote-path', '',    'the remote path to code review data'),
        ('c', 'comment',     False, 'add a comment'),
        ('s', 'signoff',     False, 'sign off'),
        ('',  'yes',         False, 'sign off as stating the changeset is good'),
        ('',  'no',          False, 'sign off as stating the changeset is bad'),
        ('m', 'message',     '',    'use <text> as the comment or signoff message'),
        ('',  'force',       False, 'overwrite an existing signoff'),
        ('r', 'rev',         '.',   'the revision to review'),
        ('l', 'lines',       '',    'the line(s) of the file to comment on'),
        ('U', 'unified',     '5',   'number of lines of context to show'),
        ('w', 'web',         False, 'launch the web interface'),
    ],
    'hg review')
}

help.helptable += (
    (['review-init'],
     ('Initializing code review for a repository'),
     (r"""
hg review --init --remote-path PATH
    
    Initialize code review for the current repository.
    
    When run for the first time in a project, it will do two things:
    
        * Create a new Mercurial repository to hold the review data at .hg/review/
        
        * Create and 'hg add' a .hgreview file in the current repository. You
          will need to commit this file yourself with:
          hg commmit .hgreview -m 'Initialize code review data.'
    
    This repository contains code review data such as comments and signoffs.
    It is a normal Mercurial repository, so you can push and pull review data
    to and from other clones of it to share your comments and signoffs.
    
    The --remote-path option is required, and specifies the path where the
    canonical code review data for this project will live.  This is the path
    that will be cloned when someone else runs 'hg review --init' on the
    project.
    
    Examples:
    
        hg review --init --remote-path 'http://bitbucket.org/u/project-review'
        hg review --init --remote-path '../project-review'
    
    """)),
    (['review-review'],
     ('Viewing code review data for changesets'),
     (r"""
hg review [-r REV] [-U CONTEXT] [--quiet] [FILE]
    
    Show code review information about a specific revision. Diffs of all
    changed files will be shown, and the line numbers printed are the ones
    that should be used with 'hg review --comment --lines LINES FILE'.
    
    If no revision is given, the current parent of the working directory
    will be shown.
    
    The number of lines of context in diffs can be changed with the -U option.
    If any FILEs are given, only those diffs will be shown. If --quiet is used
    no diffs will be shown.
    
    """)),
    (['review-comment'],
     ('Adding code review comments for changesets'),
     (r"""
hg review --comment -m MESSAGE [-r REV] [-l LINES] [FILE]
    
    If no revision is given, the current parent of the working directory
    will be used.
    
    If no FILEs are given, the comment will be attached to the changeset
    as a whole.
    
    If one or more FILEs are given but no LINES are given, the comment will
    be attached to the each file as a whole.
    
    If a FILE is given and LINES is given the comment will be attached to
    those specific lines.  LINES should be a comma-separated list of line
    numbers (as numbered in the output of 'hg review'), such as '3' or '2,3'
    
    Examples:
    
        hg review --comment -m 'This changeset needs to go in branch X.'
        hg review --comment -m 'This file should just be deleted.' script.py
        hg review --comment -m 'Trailing whitespace!' --lines 1,2,30 utils.py
    
    """)),
    (['review-signoff'],
     ('Adding code review signoffs for changesets'),
     (r"""
hg review --signoff -m MESSAGE [--yes | --no] [-r REV] [--force]
    
    If no revision is given, the current parent of the working directory
    will be used.
    
    The --yes and --no options can be used to indicate whether you think
    the changeset is "good" or "bad".  It's up to the collaborators of each
    individual project to decide exactly what that means.  If neither option
    is given the signoff will be marked as "neutral".
    
    If you've already signed off on a changeset, you can use --force to
    overwrite your previous signoff with a new one.
    
    Examples:
    
        hg review --signoff -m 'I do not work on this part of the code.'
        hg review --signoff --yes -m 'Thanks, this change looks good.'
        hg review --signoff --no -m 'This would break backwards compatibility!'
        hg review --signoff --yes --force -m 'Nevermind, this is fine.'
    
    """)),
)