Source

hgcr / filereview.py

#!/usr/bin/env python

# Patch Code Review extension for Mercurial
#
# Copyright 2009  FIRSTNAME LASTNAME <email>
#
# This software may be used and distributed according to the terms
# of the GNU General Public License, incorporated herein by reference.

'''CodeReview management tool

This extension allows you to manage reviews for your code .

Code review database is stored in .code-review file in your repository
root directory as a map of file and revision when review was done.

This extension allows you to add, remove, view status and perform code review.
''' 

from __future__ import with_statement

import os
import re
import sys
import os.path

from mercurial import ui, cmdutil, node

################################################################################                             
class CodeReview(object):

    # database
    DB_FILE = '.code-review'

    def __init__(self, ui, repo, rev):
        self.ui = ui 
        self.repo = repo
        self.db_path = os.path.join(self.repo.root, self.DB_FILE)
                
    def _status(self, base, *files, **opts):
        # NOTE: files must have absolute paths
        node1, node2 = None, None
        if base:
            node1 = self.repo.lookup(base)
        # Prefix all files with repo's location
        files = [self.repo.wjoin(f) for f in files]
        return self.repo.status(node1=node1, node2=node2, match=cmdutil.match(self.repo, files), **opts)

    def revno(self, rev):
        '''Return short revision id'''
        ctx = self.repo.changectx(rev) # get by revision id
        num = ctx.rev() # numeric id (not SHA-1)
        if num < 0:
            return 'null'
        return num

    def _open_db(self):
        '''Open Database'''
        if not os.path.exists(self.db_path):
            # create the file if it does not exist
            open(self.db_path, 'wt').close()
        
        # parse database file
        db = (line.rstrip('\n') for line in open(self.db_path, 'rt'))
        db = (line.split('#', 1)[0] for line in db) # remove comments (from # to EOL)
        db = (line.strip() for line in db) # remove whitespace from both sides of the line
        
        regex = re.compile('\s+')
        db = (regex.split(line) for line in db if line)
        
        # make dictionary from database file
        try:
            db = dict((f, (round, rev)) for (rev, round, f) in db)
        except ValueError:
            self.ui.warn('Error in Database file %s! Truncating...\n' % self.db_path)
            db = dict()
            
        return db

    def _save_db(self, db):
        ''' Save Database file'''
        db = sorted(db.iteritems())
        db = ''.join(['%s %s %s\n' % (rev, round, f) for (f, (round, rev)) in db])
        with open(self.db_path, 'wt') as f:
            f.write(db)

    def done_files(self, files, rev=None):
        '''Mark files as code-review completed'''
        if not files:
            self.ui.warn('No files were selected for COMPLETE command\n')
            return
        
        # There must be one ONLY parent for working directory (cannot CR during merge)
        parent, = self.repo[rev].parents()
        parent_rev = node.hex(parent.node())
        num = self.revno(parent_rev)
        self.ui.note('current revision: %s (#%s)\n' % (parent_rev, num))
        
        # open db
        db = self._open_db()
        
        # only checked files
        for filename in files:
            # normpath to support unix/linux style
            filename = os.path.normpath(filename)
            
            if filename not in db:
                self.ui.warn('%s is not reviewed!\n' % (filename,))
                continue
                
            if any(self._status(None, filename)):
                self.ui.warn('%s was modified since last commit!\n' % (filename,))
                continue
            
            round, old_rev = db[filename]
            db[filename] = (int(round) + 1, parent_rev)
            self.ui.status('%s review is done at #%s\n' % (filename, num))

        # save db
        self._save_db(db)

    def remove_files(self, files):
        '''REmove files from code-review'''
        if not files:
            self.ui.warn('No files were selected for REMOVE\n')
            return
        
        # open db
        db = self._open_db()
        
        for filename in files:
            # normpath to support unix/linux style
            filename = os.path.normpath(filename)

            if filename not in db:
                self.ui.warn('%s is not reviewed!\n' % (filename,))
                continue
                
            self.ui.status('%s removed from review list\n' % (filename,))
            db.pop(filename)
            
        # save db
        self._save_db(db)

    def add_files(self, files):
        '''Add files to code review'''
        if not files:
            self.ui.warn('No files were selected for ADD\n')
            return
            
        # open db
        db = self._open_db()

        for filename in files:
        
            # abspath to avoid windows/linux and relative paths issues
            filename = os.path.abspath(os.path.basename(filename))
            
            if self.repo.root not in filename:
                self.ui.warn('%s is not under source control!\n' % (filename,))
                continue
                
            #DEV: create better solution to find relative path
            filename = filename.replace(self.repo.root, '')[1:]
            
            if filename in db:
                self.ui.warn('%s is already in the review list!\n' % (filename,))
                continue
                
            if os.path.isdir(filename):
                self.ui.warn('cannot review directory \'%s\' !\n' % (filename,))
                continue
                
            if ' ' in filename:
                self.ui.warn('file path cannot contain spaces \'%s\' !\n' % (filename,))
                continue
            
            # mark as not-review-yet
            db[filename] = (0, node.hex(node.nullid))
            self.ui.status('%s added to review list\n' % (filename,))

        # save db
        self._save_db(db)

    def list_files(self):
        '''List files that are managed in code-review'''
        # make db
        db = self._open_db()
        files = sorted(db.keys())
        
        result = []
        for f in files:
            round, base = db.get(f)

            if not base: # file is not CRed
                self.ui.warn('%s is not reviewed!\n' % (f,))
                continue
                
            # run "hg status" to find out if `f` was changed since its last CR
            res = self._status(base, f, clean=True)
            (modified, added, removed, deleted, unknown, ignored, clean) = res
            
            base = self.revno(base)
            result.append((f, base, round, clean))
        
        return result

def main(ui, repo, *pats, **opts):
    """Code Review Plugin (requires Mercurial 1.1.x!)"""
    
    if sum(map(int, opts.values())) < 1:
        ui.warn('At most one action must be specified! See "hg cr --help" for details')
        return
   
    code_review = CodeReview(ui, repo, None)

    files = []    
    if pats:
        # Match all files using given patterns
        m = cmdutil.match(repo, pats, opts)
        files = m.files()
        for f in files:
            ui.note('matched: %s\n' % (f,))
    
    if opts['list']:
        format = '%5s %10s %s %s\n'
        ui.status(format % ('round', 'revision', 'status', 'filename' ) )
        ui.status(format % ('-----', '--------', '------', '--------' ) )
        for f, base , round, clean in code_review.list_files():
            res = 'completed' if clean else 'changed since last review '
            ui.status(format % (round, code_review.revno(base), res, f) )
        
    elif opts['complete']:
        code_review.done_files(files)
        
    elif opts['add']:
        code_review.add_files(files)
        
    elif opts['remove']:
        code_review.remove_files(files)
        
    else:
        ui.warn('At most one action must be specified! See "hg cr --help" for details')
        
cmdtable = {
    'cr':           (main,
                     [('c', 'complete', False, 'Mark CR as complete'),
                      ('a', 'add', False, 'Add files to CR list'),
                      ('r', 'remove', False, 'Remove files from CR list'),
                      ('l', 'list', False, 'Print files in CR list'),
                      ],
                     'hg cr [OPTIONS] [FILES]')
} 
Tip: Filter by directory path e.g. /media app.js to search for public/media/app.js.
Tip: Use camelCasing e.g. ProjME to search for ProjectModifiedEvent.java.
Tip: Filter by extension type e.g. /repo .js to search for all .js files in the /repo directory.
Tip: Separate your search with spaces e.g. /ssh pom.xml to search for src/ssh/pom.xml.
Tip: Use ↑ and ↓ arrow keys to navigate and return to view the file.
Tip: You can also navigate files with Ctrl+j (next) and Ctrl+k (previous) and view the file with Ctrl+o.
Tip: You can also navigate files with Alt+j (next) and Alt+k (previous) and view the file with Alt+o.