review/api.py @ e91ac244e5ad

Add the ability to overwrite signoffs.
author Steve Losh <steve@stevelosh.com>
date Sun, 04 Oct 2009 22:08:06 -0400
parents 808aaa1eef26
children 7e437c5261bb
from __future__ import with_statement

'''The review data structures.
'''

import os
import messages, templates
from datetime import datetime
from mercurial import cmdutil, hg
from mercurial.node import hex
from mercurial.util import sha1


class PreexistingDatastore(Exception):
    """Raised when trying to initialize a datastore when one seems to exist."""
    def __init__(self, committed):
        super(PreexistingDatastore, self).__init__()
        self.committed = committed
    

class SignoffExists(Exception):
    '''Raised when trying to signoff twice without forcing.'''
    pass

class CannotDeleteObject(Exception):
    '''Raised when trying to delete an object that does not support deletion.'''
    pass


def _parse_hgrf(repo):
    """Parse the .hgreview file and return the data inside."""
    
    data = {}
    hgrd = repo['tip']['.hgreview'].data().split('\n')
    lines = [line for line in hgrd if line.strip()]
    for line in lines:
        label, _, path = [i.strip() for i in line.partition('=')]
        if label == 'local':
            data['lpath'] = path
        elif label == 'remote':
            data['rpath'] = path
    
    return data

def _commitfunc(ui, repo, message, match, opts):
    return repo.commit(message, opts.get('user'), opts.get('date'), match)

def _match(start):
    return lambda fn: fn.startswith(start)

def _parse_data(data):
    meta, _, message = data.partition('\n\n')
    
    data = {}
    for m in meta.split('\n'):
        label, _, val = m.partition(':')
        data[label] = val
    data['message'] = message
    return data


class ReviewDatastore(object):
    '''The data store for all the reviews so far.'''
    def __init__(self, ui, repo, lpath=None, rpath=None, create=False):
        self.ui = ui
        
        if not create:
            data = _parse_hgrf(repo)
            self.lpath = data['lpath']
            self.rpath = data['rpath']
        else:
            if '.hgreview' in repo['tip']:
                raise PreexistingDatastore(True)
            if os.path.exists(os.path.join(repo.root, '.hgreview')):
                raise PreexistingDatastore(False)
            self.lpath = lpath or '.review'
            self.rpath = rpath or ('../%s-review' % os.path.basename(repo.root))
        
        root = os.path.join(repo.root, self.lpath)
        self.target = repo
        self.repo = hg.repository(ui, root, create)
        
        if create:
            hgrpath = os.path.join(repo.root, '.hgreview')
            with open(hgrpath, 'w') as hgrf:
                hgrf.write('local = %s\n' % self.lpath)
                hgrf.write('remote = %s\n' % self.rpath)
            repo.add(['.hgreview'])
    
    def __getitem__(self, rev):
        '''Return a ReviewChangeset for the given revision.'''
        node = hex(self.target[rev].node())
        return ReviewChangeset(self.ui, self.repo, node)
    

class ReviewChangeset(object):
    '''The review data about one changeset in the target repository.'''
    def __init__(self, ui, repo, node):
        self.repo = repo
        self.ui = ui
        self.node = node
        
        if '%s/.exists' % self.node in self.repo['tip']:
            relevant = filter(_match(node), self.repo['tip'])
            commentfns = filter(_match('%s/comments' % node), relevant)
            signofffns = filter(_match('%s/signoffs' % node), relevant)
            
            self.comments = [ 
                ReviewComment(**_parse_data(self.repo['tip'][fn].data()))
                              for fn in commentfns
            ]
            self.signoffs = [ 
                ReviewSignoff(**_parse_data(self.repo['tip'][fn].data()))
                              for fn in signofffns
            ]
        else:
            self.comments = []
            self.signoffs = []
            
            path = os.path.join(self.repo.root, self.node)
            os.mkdir(path)
            with open(os.path.join(path, '.exists'), 'w') as e:
                pass
            
            cmdutil.commit(ui, self.repo, _commitfunc,
                [os.path.join(path, '.exists')],
                { 'message': 'Initialize review data for changeset %s' % self.node,
                  'addremove': True, })
    
    def add_signoff(self, message, opinion='', force=False):
        '''Add (and commit) a signoff for the given revision.'''
        existing = filter(lambda s: s.author == self.ui.username(), self.signoffs)
        
        if existing:
            if not force:
                raise SignoffExists
            existing[0].delete(self.ui, self.repo)
        
        signoff = ReviewSignoff(self.ui.username(), datetime.utcnow(),
            self.node, opinion, message)
        signoff.commit(self.ui, self.repo)
    
    def add_comment(self, message, filename='', lines=[]):
        '''Add (and commit) a comment for the given file and lines.'''
        comment = ReviewComment(self.ui.username(), datetime.utcnow(),
            self.node, filename, lines, message)
        comment.commit(self.ui, self.repo)
    

class _ReviewObject(object):
    '''Some kind of object.'''
    def __init__(self, container, commit_message, delete_message=None):
        self.container = container
        self.commit_message = commit_message
        self.delete_message = delete_message
    
    def commit(self, ui, repo):
        '''Write and commit this object to the given repo.'''
        
        path = os.path.join(repo.root, self.node, self.container)
        if not os.path.exists(path):
            os.mkdir(path)
        
        data = self.render_data()
        filename = sha1(data).hexdigest()
        objectpath = os.path.join(path, filename)
        
        with open(objectpath, 'w') as objectfile:
            objectfile.write(data)
        
        cmdutil.commit(ui, repo, _commitfunc, [objectpath],
            { 'message': self.commit_message % self.node, 'addremove': True, })
    
    def delete(self, ui, repo):
        '''Delete and commit this object in the given repo.'''
        
        if not self.delete_message:
            raise CannotDeleteObject
        
        data = self.render_data()
        filename = sha1(data).hexdigest()
        objectpath = os.path.join(repo.root, self.node, self.container, filename)
        
        os.remove(objectpath)
        
        cmdutil.commit(ui, repo, _commitfunc, [objectpath],
            { 'message': self.delete_message % self.node, 'addremove': True, })
    

class ReviewComment(_ReviewObject):
    '''A single review comment.'''
    def __init__(self, author, datetime, node, filename, lines, message, **extra):
        super(ReviewComment, self).__init__(
            container='comments', commit_message=messages.COMMIT_COMMENT,
        )
        self.author = author
        self.datetime = datetime
        self.node = node
        self.filename = filename
        self.lines = lines
        self.message = message
    
    def render_data(self):
        datetime = str(self.datetime)
        lines = ','.join(self.lines)
        return templates.COMMENT_FILE_TEMPLATE % ( self.author, datetime,
            self.node, self.filename, lines, self.message )
    

class ReviewSignoff(_ReviewObject):
    '''A single review signoff.'''
    def __init__(self, author, datetime, node, opinion, message, **extra):
        super(ReviewSignoff, self).__init__(
            container='signoffs', commit_message=messages.COMMIT_SIGNOFF,
            delete_message=messages.DELETE_SIGNOFF,
        )
        self.author = author
        self.datetime = datetime
        self.node = node
        self.opinion = opinion
        self.message = message
    
    def render_data(self):
        datetime = str(self.datetime)
        return templates.SIGNOFF_FILE_TEMPLATE % ( self.author, datetime,
            self.node, self.opinion, self.message )