| 1 | # -*- coding: utf-8 -*- |
|---|
| 2 | # |
|---|
| 3 | # Copyright (C) 2012 Rob Guttman <guttman@alum.mit.edu> |
|---|
| 4 | # All rights reserved. |
|---|
| 5 | # |
|---|
| 6 | # This software is licensed as described in the file COPYING, which |
|---|
| 7 | # you should have received as part of this distribution. |
|---|
| 8 | # |
|---|
| 9 | |
|---|
| 10 | import re |
|---|
| 11 | import os |
|---|
| 12 | import json |
|---|
| 13 | import sqlite3 |
|---|
| 14 | from subprocess import Popen, STDOUT, PIPE |
|---|
| 15 | |
|---|
| 16 | from coderev.model import CodeReview |
|---|
| 17 | from trac.env import Environment |
|---|
| 18 | from trac.ticket.model import Ticket |
|---|
| 19 | from trac.resource import ResourceNotFound |
|---|
| 20 | |
|---|
| 21 | class Reviewer(object): |
|---|
| 22 | """Returns the latest changeset in a given repo whose Trac tickets have |
|---|
| 23 | been fully reviewed. Works in conjunction with the Trac CodeReviewer |
|---|
| 24 | plugin and its database tables.""" |
|---|
| 25 | |
|---|
| 26 | def __init__(self, trac_env, repo_dir, target_ref, data_file, verbose=True): |
|---|
| 27 | self.env = Environment(trac_env) |
|---|
| 28 | self.repo_dir = repo_dir.rstrip('/') |
|---|
| 29 | self.reponame = os.path.basename(self.repo_dir).lower() |
|---|
| 30 | self.target_ref = target_ref |
|---|
| 31 | self.data_file = data_file |
|---|
| 32 | self.verbose = verbose |
|---|
| 33 | |
|---|
| 34 | def get_next_changeset(self, save=True): |
|---|
| 35 | """Return the next reviewed changeset and save it as the current |
|---|
| 36 | changeset when save is True.""" |
|---|
| 37 | next = self.get_current_changeset() |
|---|
| 38 | for changeset in self.get_changesets(): |
|---|
| 39 | if self.verbose: |
|---|
| 40 | print '.', |
|---|
| 41 | if not self.is_complete(changeset): |
|---|
| 42 | return self.set_current_changeset(next, save) |
|---|
| 43 | next = changeset |
|---|
| 44 | return self.set_current_changeset(next, save) |
|---|
| 45 | |
|---|
| 46 | def get_blocking_changeset(self, changesets=None): |
|---|
| 47 | """Return the next blocking changeset.""" |
|---|
| 48 | if changesets is None: |
|---|
| 49 | changesets = self.get_changesets() |
|---|
| 50 | for changeset in changesets: |
|---|
| 51 | if not self.is_complete(changeset): |
|---|
| 52 | return changeset |
|---|
| 53 | return None |
|---|
| 54 | |
|---|
| 55 | def get_blocked_tickets(self): |
|---|
| 56 | """Return all tickets of blocking changesets in order of them |
|---|
| 57 | getting unblocked.""" |
|---|
| 58 | tickets = [] |
|---|
| 59 | tickets_visited = set(['']) # merge changesets have empty ticket values |
|---|
| 60 | |
|---|
| 61 | # restrict changesets to only those not completed |
|---|
| 62 | changesets = self.get_changesets() |
|---|
| 63 | blocking_changeset = self.get_blocking_changeset(changesets) |
|---|
| 64 | if blocking_changeset is None: |
|---|
| 65 | return [] |
|---|
| 66 | changesets = changesets[changesets.index(blocking_changeset):] |
|---|
| 67 | |
|---|
| 68 | # only consider changesets that come after the blocking changeset |
|---|
| 69 | review = self.get_review(blocking_changeset) |
|---|
| 70 | blocking_when = review.changeset_when |
|---|
| 71 | |
|---|
| 72 | # find blocked tickets |
|---|
| 73 | for changeset in changesets: |
|---|
| 74 | review = self.get_review(changeset) |
|---|
| 75 | for ticket in review.tickets: |
|---|
| 76 | if ticket in tickets_visited: |
|---|
| 77 | continue |
|---|
| 78 | tickets_visited.add(ticket) |
|---|
| 79 | |
|---|
| 80 | def get_first_remaining_changeset(): |
|---|
| 81 | for review in self.get_reviews(ticket): |
|---|
| 82 | if review.changeset in changesets and \ |
|---|
| 83 | review.changeset_when >= blocking_when: |
|---|
| 84 | return review # changeset exists on path |
|---|
| 85 | raise ResourceNotFound("Not found for #%s" % ticket) |
|---|
| 86 | |
|---|
| 87 | # the ticket's oldest *remaining* changeset determines blockage |
|---|
| 88 | # i.e., if current is already past a changeset, ignore it |
|---|
| 89 | try: |
|---|
| 90 | first = get_first_remaining_changeset() |
|---|
| 91 | tkt = Ticket(self.env, ticket) |
|---|
| 92 | tkt.first_changeset = first.changeset |
|---|
| 93 | tkt.first_changeset_when = first.changeset_when |
|---|
| 94 | tickets.append( tkt ) |
|---|
| 95 | except ResourceNotFound: |
|---|
| 96 | pass # e.g., incorrect ticket reference |
|---|
| 97 | return sorted(tickets, key=lambda t: t.first_changeset_when) |
|---|
| 98 | |
|---|
| 99 | def get_changesets(self): |
|---|
| 100 | """Extract changesets in order from current to target ref.""" |
|---|
| 101 | current_ref = self.get_current_changeset() |
|---|
| 102 | review = self.get_review(current_ref) |
|---|
| 103 | when = int(review.changeset_when / CodeReview.EPOCH_MULTIPLIER) |
|---|
| 104 | cmds = ['cd %s' % self.repo_dir, |
|---|
| 105 | 'git rev-list --reverse --since=%s HEAD' % when] |
|---|
| 106 | changesets = self._execute(' && '.join(cmds)).splitlines() |
|---|
| 107 | if self.verbose: |
|---|
| 108 | print "\n%d changesets from current %s to target %s" % \ |
|---|
| 109 | (len(changesets),current_ref,self.target_ref) |
|---|
| 110 | if current_ref not in changesets: |
|---|
| 111 | changesets.insert(0,current_ref) |
|---|
| 112 | return changesets |
|---|
| 113 | |
|---|
| 114 | def get_reviews(self, ticket): |
|---|
| 115 | return CodeReview.get_reviews(self.env, ticket) |
|---|
| 116 | |
|---|
| 117 | def get_review(self, changeset): |
|---|
| 118 | return CodeReview(self.env, self.reponame, changeset) |
|---|
| 119 | |
|---|
| 120 | def is_complete(self, changeset): |
|---|
| 121 | """Returns True if all of the given changeset's tickets are complete. |
|---|
| 122 | Complete means that the ticket has no pending reviews and the last |
|---|
| 123 | review has passed, -AND- the ticket's completeness criteria (if any) |
|---|
| 124 | is satisfied.""" |
|---|
| 125 | review = self.get_review(changeset) |
|---|
| 126 | for ticket in review.tickets: |
|---|
| 127 | reason = review.is_incomplete(ticket) |
|---|
| 128 | if reason: |
|---|
| 129 | if self.verbose: |
|---|
| 130 | print '\n' + reason |
|---|
| 131 | return False |
|---|
| 132 | return True |
|---|
| 133 | |
|---|
| 134 | def _execute(self, cmd): |
|---|
| 135 | p = Popen(cmd, shell=True, stderr=STDOUT, stdout=PIPE) |
|---|
| 136 | out = p.communicate()[0] |
|---|
| 137 | if p.returncode != 0: |
|---|
| 138 | raise Exception('cmd: %s\n%s' % (cmd,out)) |
|---|
| 139 | return out |
|---|
| 140 | |
|---|
| 141 | def get_current_changeset(self): |
|---|
| 142 | data = self._get_data() |
|---|
| 143 | return data['current'] |
|---|
| 144 | |
|---|
| 145 | def set_current_changeset(self, changeset, save=True): |
|---|
| 146 | if save: |
|---|
| 147 | data = self._get_data() |
|---|
| 148 | if data['current'] != changeset: |
|---|
| 149 | data['current'] = changeset |
|---|
| 150 | self._set_data(data) |
|---|
| 151 | if self.verbose: |
|---|
| 152 | print "setting current changeset to %s" % changeset |
|---|
| 153 | elif self.verbose: |
|---|
| 154 | print "current changeset already is %s" % changeset |
|---|
| 155 | return changeset |
|---|
| 156 | |
|---|
| 157 | def _get_data(self): |
|---|
| 158 | if os.path.exists(self.data_file): |
|---|
| 159 | data = json.loads(open(self.data_file,'r').read()) |
|---|
| 160 | else: |
|---|
| 161 | # grab the latest rev as the changeset |
|---|
| 162 | cmds = ['cd %s' % self.repo_dir, |
|---|
| 163 | 'git log -1 --pretty="format:%H"'] |
|---|
| 164 | changeset = self._execute(' && '.join(cmds)) |
|---|
| 165 | data = {'current': changeset} |
|---|
| 166 | self._set_data(data) |
|---|
| 167 | return data |
|---|
| 168 | |
|---|
| 169 | def _set_data(self, data): |
|---|
| 170 | f = open(self.data_file,'w') |
|---|
| 171 | f.write(json.dumps(data)) |
|---|
| 172 | f.close() |
|---|