review/cli.py @ cada9aab8b6f

tests: add some tests for non-ascii diffs
author Steve Losh <steve@stevelosh.com>
date Fri, 23 Jul 2010 21:30:38 -0400
parents 53aadb5be2b6
children (none)
"""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 api, helps, messages
from rutil import fromlocal, tolocal
from mercurial import help, templatefilters, util
from mercurial.node import short

def _get_datastore(ui, repo):
    try:
        return api.ReviewDatastore(ui, repo)
    except api.UninitializedDatastore:
        raise util.Abort(messages.NO_DATA_STORE)

def _get_message(ui, rd, initial):
    message = ui.edit(initial, rd.repo.ui.username())
    return '\n'.join(l for l in message.splitlines()
                     if not l.startswith('HG: ')).strip()


def _edit_comment(rd, comment, *fnames, **opts):
    lines = opts.pop('lines')
    message = opts.pop('message').strip()
    mdown = opts.pop('mdown')

    if len(fnames) > 1:
        raise util.Abort(messages.EDIT_REQUIRES_ONE_OR_LESS_FILES)

    if not lines:
        lines = comment.lines
    else:
        lines = [int(l.strip()) for l in lines.split(',')]

    if not message:
        message = comment.message

    if not fnames:
        fnames = [comment.filename]
    else:
        fnames = [api.sanitize_path(fnames[0])]

    style = 'markdown' if mdown or comment.style == 'markdown' else ''

    try:
        rd.edit_comment(comment.identifier, fromlocal(message),
                        filename=fnames[0], lines=lines, style=style)
    except api.FileNotInChangeset:
        raise util.Abort(messages.COMMENT_FILE_DOES_NOT_EXIST % (
                             fnames[0], rd.target[comment.node].rev()))

def _edit_signoff(rd, signoff, **opts):
    mdown = opts.pop('mdown')
    message = opts.pop('message').strip()

    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 signoff.opinion)

    if not message:
        message = signoff.message

    style = 'markdown' if mdown or signoff.style == 'markdown' else ''

    rd.edit_signoff(signoff.identifier, fromlocal(message), opinion, style)


def _web_command(ui, repo, **opts):
    ui.note(messages.WEB_START)
    read_only = opts.pop('read_only')
    allow_anon = opts.pop('allow_anon')
    address = opts.pop('address')
    port = int(opts.pop('port'))

    if api.review_initialized(repo) and not api.local_datastore_exists(repo):
        _init_command(ui, repo, remote_path=None)

    rd = _get_datastore(ui, repo)

    import web
    web.load_interface(ui, repo, read_only=read_only, allow_anon=allow_anon,
                       address=address, port=port, open=False)

def _init_command(ui, repo, **opts):
    ui.note(messages.INIT_START)

    try:
        api.ReviewDatastore(ui, repo, rpath=opts.pop('remote_path'), create=True,
                            clone_message=True)
        if not any('.hgreview' in repo[h].manifest() for h in repo.heads()):
            ui.status(messages.INIT_SUCCESS_UNCOMMITTED)
        else:
            ui.status(messages.INIT_SUCCESS_CLONED)
    except api.RelativeRemotePath:
        raise util.Abort(messages.INIT_UNSUPPORTED_RELATIVE_RPATH)
    except api.DatastoreRequiresRemotePath:
        raise util.Abort(messages.INIT_REQUIRES_REMOTE_PATH)
    except api.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')
    lines = opts.pop('lines')
    message = opts.pop('message').strip()
    mdown = opts.pop('mdown')
    rd = _get_datastore(ui, repo)

    rcset = rd[rev]

    if lines and not len(fnames) == 1:
        raise util.Abort(messages.COMMENT_LINES_REQUIRE_FILE)

    if lines:
        lines = map(int, lines.split(','))

    fnames = map(lambda f: api.sanitize_path(f, repo), fnames) if fnames else ['']

    if not message:
        template = mdown and messages.COMMENT_EDITOR_MDOWN or messages.COMMENT_EDITOR
        message = _get_message(ui, rd, template)
        if not message:
            raise util.Abort(messages.COMMENT_REQUIRES_MESSAGE)

    style = mdown and 'markdown' or ''

    for fn in fnames:
        try:
            rcset.add_comment(message=fromlocal(message), filename=fn, lines=lines, style=style)
        except api.FileNotInChangeset:
            raise util.Abort(messages.COMMENT_FILE_DOES_NOT_EXIST % (
                                     fn, repo[rev].rev()))

def _signoff_command(ui, repo, **opts):
    message = opts.pop('message').strip()
    mdown = opts.pop('mdown')
    rd = _get_datastore(ui, repo)
    rcset = rd[opts.pop('rev')]

    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 '')

    if rcset.signoffs_for_current_user():
        raise util.Abort(messages.SIGNOFF_EXISTS)

    if not message:
        template = mdown and messages.SIGNOFF_EDITOR_MDOWN or messages.SIGNOFF_EDITOR
        message = _get_message(ui, rd, template)
        if not message:
            raise util.Abort(messages.SIGNOFF_REQUIRES_MESSAGE)

    style = mdown and 'markdown' or ''

    rcset.add_signoff(message=fromlocal(message), opinion=opinion, style=style)

def _check_command(ui, repo, **opts):
    rd = _get_datastore(ui, repo)
    rcset = rd[opts.pop('rev')]

    if opts.pop('no_nos'):
        if any(filter(lambda s: s.opinion == "no", rcset.signoffs)):
            raise util.Abort(messages.CHECK_HAS_NOS)

    yes_count = opts.pop('yeses')
    if yes_count:
        yes_count = int(yes_count)
        if len(filter(lambda s: s.opinion == "yes", rcset.signoffs)) < yes_count:
            raise util.Abort(messages.CHECK_TOO_FEW_YESES)

    if opts.pop('seen'):
        if not rcset.signoffs and not rcset.comments:
            raise util.Abort(messages.CHECK_UNSEEN)

    ui.note(messages.CHECK_SUCCESS)

def _review_command(ui, repo, *fnames, **opts):
    rev = opts.pop('rev')
    context = int(opts.pop('unified'))
    rd = _get_datastore(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 _build_item_header(item, author_template, author_extra=None):
        author = templatefilters.person(item.author)
        author_args = (author,)
        if author_extra:
            author_args = author_args + author_extra
        author_part = author_template % author_args

        age = templatefilters.age(item.hgdate)
        age_part = messages.REVIEW_LOG_AGE % age
        if ui.debugflag:
            hash_part = messages.REVIEW_LOG_IDENTIFIER % item.identifier
        elif ui.verbose:
            hash_part = messages.REVIEW_LOG_IDENTIFIER % item.identifier[:12]
        else:
            hash_part = ''
        detail_part = age_part + hash_part

        spacing = 80 - (len(author_part) + len(detail_part))
        if spacing <= 0:
            spacing = 1
        spacing = ' ' * spacing

        return tolocal(author_part) + spacing + detail_part + '\n'


    def _print_comment(comment, before='', after=''):
        ui.write(before)
        ui.write(_build_item_header(comment, messages.REVIEW_LOG_COMMENT_AUTHOR),
                 label='review.comment')

        for line in comment.message.splitlines():
            ui.write(messages.REVIEW_LOG_COMMENT_LINE % tolocal(line), label='review.comment')

        ui.write(after)

    def _print_signoff(signoff, before='', after=''):
        ui.write(before)

        opinion = signoff.opinion or 'neutral'
        label = 'review.signoff.%s' % opinion
        header = _build_item_header(signoff, messages.REVIEW_LOG_SIGNOFF_AUTHOR, (opinion,))
        ui.write(header, label=label)

        for line in signoff.message.splitlines():
            ui.write(messages.REVIEW_LOG_SIGNOFF_LINE % tolocal(line), label=label)

        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 = [api.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
        ui.write('\n\n%s %s\n' % (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 ' % (prefix % line['number']))
            if line['content'].startswith('+'):
                ui.write('%s\n' % line['content'], label='diff.inserted')
            elif line['content'].startswith('-'):
                ui.write('%s\n' % line['content'], label='diff.deleted')
            else:
                ui.write('%s\n' % line['content'])

            for comment in line['comments']:
                _print_comment(comment)

def _delete_command(ui, repo, *identifiers, **opts):
    # TODO: require -f to delete some else's item
    force = opts.pop('force')
    rd = _get_datastore(ui, repo)

    if not identifiers:
        raise util.Abort(messages.REQUIRES_IDS)

    for i in identifiers:
        try:
            rd.remove_item(i)
        except api.UnknownIdentifier:
            raise util.Abort(messages.UNKNOWN_ID % i)
        except api.AmbiguousIdentifier:
            raise util.Abort(messages.AMBIGUOUS_ID % i)

def _edit_command(ui, repo, *args, **opts):
    # TODO: require -f to edit some else's item
    # TODO: support forcing of plain text
    force = opts.pop('force')
    identifier = opts.pop('edit')
    rd = _get_datastore(ui, repo)

    items = rd.get_items(identifier)
    if len(items) == 0:
        raise util.Abort(messages.UNKNOWN_ID % identifier)
    elif len(items) > 1:
        raise util.Abort(messages.AMBIGUOUS_ID % identifier)
    item = items[0]

    if item.itemtype == 'comment':
        _edit_comment(rd, item, *args, **opts)
    elif item.itemtype == 'signoff':
        _edit_signoff(rd, item, **opts)


def review(ui, repo, *args, **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
    - hg help review-check
    - hg help review-web
    - hg help review-edit

    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, *args, **opts)
    elif opts.pop('signoff'):
        return _signoff_command(ui, repo, **opts)
    elif opts.pop('check'):
        return _check_command(ui, repo, **opts)
    elif opts.get('edit'):
        return _edit_command(ui, repo, *args, **opts)
    elif opts.pop('delete'):
        return _delete_command(ui, repo, *args, **opts)
    else:
        return _review_command(ui, repo, *args, **opts)


cmdtable = {
    'review': (review, [
        ('U', 'unified',     '5',   'number of lines of context to show'),
        ('m', 'message',     '',    'use <text> as the comment or signoff message'),
        ('',  'mdown',       False, 'use Markdown to format the comment or signoff message'),
        ('r', 'rev',         '.',   'the revision to review'),

        ('d', 'delete',      False, 'delete a comment or signoff'),
        ('',  'edit',        '',    'edit a comment or signoff'),

        ('',  'check',       False, 'check the review status of the given revision'),
        ('',  'no-nos',      False, 'ensure this revision does NOT have signoffs of "no"'),
        ('',  'yeses',       '',    'ensure this revision has at least NUM signoffs of "yes"'),
        ('',  'seen',        False, 'ensure this revision has a comment or signoff'),

        ('i', 'init',        False, 'start code reviewing this repository'),
        ('',  'remote-path', '',    'the remote path to code review data'),

        ('c', 'comment',     False, 'add a comment'),
        ('l', 'lines',       '',    'the line(s) of the file to comment on'),

        ('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'),
        ('f', 'force',       False, 'force an action'),

        ('w', 'web',         False,       'launch the web interface'),
        ('',  'read-only',   False,       'make the web interface read-only'),
        ('',  'allow-anon',  False,       'allow anonymous comments on the web interface'),
        ('',  'address',     '127.0.0.1', 'run the web interface on the specified address'),
        ('',  'port',        '8080',      'run the web interface on the specified port'),
    ],
    'hg review')
}

help.helptable += (
    (['review-init'], ('Initializing code review for a repository'), (helps.INIT)),
    (['review-review'], ('Viewing code review data for changesets'), (helps.REVIEW)),
    (['review-comment'], ('Adding code review comments for changesets'), (helps.COMMENT)),
    (['review-signoff'], ('Adding code review signoffs for changesets'), (helps.SIGNOFF)),
    (['review-check'], ('Checking the review status of changesets'), (helps.CHECK)),
    (['review-web'], ('Using the web interface to review changesets'), (helps.WEB)),
    (['review-edit'], ('Editing code review comments and signoffs'), (helps.EDIT)),
)

colortable = {
    'review.comment': 'cyan',
    'review.signoff.yes': 'green',
    'review.signoff.neutral': 'cyan',
    'review.signoff.no': 'red',
}