commit 95477c9dc15c16df1e64164eb57d71d98971888e Author: Correl Roush Date: Fri Nov 19 00:12:29 2010 -0500 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2956c7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.py[oc] diff --git a/__init__.py b/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/browser/__init__.py b/browser/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/browser/models.py b/browser/models.py new file mode 100755 index 0000000..71a8362 --- /dev/null +++ b/browser/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/browser/templatetags/__init__.py b/browser/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/browser/templatetags/gravatar.py b/browser/templatetags/gravatar.py new file mode 100644 index 0000000..0ff1340 --- /dev/null +++ b/browser/templatetags/gravatar.py @@ -0,0 +1,16 @@ +import hashlib +from django.template import Library +from django.template.defaultfilters import stringfilter + +register = Library() + +@register.filter +@stringfilter +def gravatar(value, size): + email = value.strip().lower() + hash = hashlib.md5(email).hexdigest() + url = "http://www.gravatar.com/avatar/{hash}?s={size}&r={rating}".format( + hash=hash, + size=size, + rating='g') + return url diff --git a/browser/templatetags/vcs.py b/browser/templatetags/vcs.py new file mode 100644 index 0000000..6f738c3 --- /dev/null +++ b/browser/templatetags/vcs.py @@ -0,0 +1,10 @@ +from django.template import Library +from django.template.defaultfilters import stringfilter + +register = Library() + +@register.filter +@stringfilter +def oneline(value): + line = value.split('\n')[0].strip() + return line diff --git a/browser/tests.py b/browser/tests.py new file mode 100755 index 0000000..2247054 --- /dev/null +++ b/browser/tests.py @@ -0,0 +1,23 @@ +""" +This file demonstrates two different styles of tests (one doctest and one +unittest). These will both pass when you run "manage.py test". + +Replace these with more appropriate tests for your application. +""" + +from django.test import TestCase + +class SimpleTest(TestCase): + def test_basic_addition(self): + """ + Tests that 1 + 1 always equals 2. + """ + self.failUnlessEqual(1 + 1, 2) + +__test__ = {"doctest": """ +Another way to test that 1 + 1 is equal to 2. + +>>> 1 + 1 == 2 +True +"""} + diff --git a/browser/urls.py b/browser/urls.py new file mode 100644 index 0000000..8e2468a --- /dev/null +++ b/browser/urls.py @@ -0,0 +1,6 @@ +from django.conf.urls.defaults import * + +urlpatterns = patterns('', + (r'^(?P.*?)/log/$', 'codereview.browser.views.log'), + (r'^(?P.*?)/view/(?P.*?)/$', 'codereview.browser.views.view'), +) diff --git a/browser/views.py b/browser/views.py new file mode 100755 index 0000000..05210b4 --- /dev/null +++ b/browser/views.py @@ -0,0 +1,48 @@ +from django.http import Http404 +from django.shortcuts import render_to_response +from codereview.dashboard.models import Repository + +def log(request,repository): + try: + repo = Repository.objects.get(name=repository) + except: + raise Http404 + vcs = repo.get_vcs() + ref = request.GET['c'] if 'c' in request.GET else vcs.ref() + offset = int(request.GET['o']) if 'o' in request.GET else 0 + limit = 20 + log = vcs.log(ref, max=limit, offset=offset) + + newer = offset - limit if offset > limit else 0 + # Inspect the last commit. If it has no parents, we can't go any further + # back. + last = log[-1] + older = offset + limit if last.parents else 0 + + return render_to_response('browser/log.html', + { + 'repository': repository, + 'vcs': vcs, + 'log': log, + 'ref': ref, + 'offset': offset, + 'newer': newer, + 'older': older, + }) +def view(request, repository, ref): + try: + repo = Repository.objects.get(name=repository) + except: + raise Http404 + vcs = repo.get_vcs() + commit = vcs.commit(ref) + diffs = vcs.diff(ref) + + return render_to_response('browser/view.html', + { + 'repository': repository, + 'vcs': vcs, + 'ref': ref, + 'commit': commit, + 'diffs': diffs, + }) diff --git a/dashboard/__init__.py b/dashboard/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/dashboard/fixtures/localrepos.json b/dashboard/fixtures/localrepos.json new file mode 100644 index 0000000..f8a43ef --- /dev/null +++ b/dashboard/fixtures/localrepos.json @@ -0,0 +1,20 @@ +[ + { + "pk": 1, + "model": "dashboard.repository", + "fields": { + "path": "/home/correlr/code/voiceaxis", + "type": 0, + "name": "VoiceAxis" + } + }, + { + "pk": 2, + "model": "dashboard.repository", + "fields": { + "path": "/srv/git/mtg.git", + "type": 0, + "name": "MTG" + } + } +] diff --git a/dashboard/models.py b/dashboard/models.py new file mode 100755 index 0000000..5af557f --- /dev/null +++ b/dashboard/models.py @@ -0,0 +1,16 @@ +from django.db import models +from codereview.lib import vcs + +class Repository(models.Model): + Types = { + 'Git': 0, + } + name = models.CharField(max_length=200, unique=True) + path = models.CharField(max_length=255) + type = models.IntegerField(default=0) + + def get_vcs(self): + if self.type == 0: + return vcs.Git(self.path) + else: + raise Exception('Invalid VCS type') diff --git a/dashboard/tests.py b/dashboard/tests.py new file mode 100755 index 0000000..2247054 --- /dev/null +++ b/dashboard/tests.py @@ -0,0 +1,23 @@ +""" +This file demonstrates two different styles of tests (one doctest and one +unittest). These will both pass when you run "manage.py test". + +Replace these with more appropriate tests for your application. +""" + +from django.test import TestCase + +class SimpleTest(TestCase): + def test_basic_addition(self): + """ + Tests that 1 + 1 always equals 2. + """ + self.failUnlessEqual(1 + 1, 2) + +__test__ = {"doctest": """ +Another way to test that 1 + 1 is equal to 2. + +>>> 1 + 1 == 2 +True +"""} + diff --git a/dashboard/views.py b/dashboard/views.py new file mode 100755 index 0000000..36c16d6 --- /dev/null +++ b/dashboard/views.py @@ -0,0 +1,9 @@ +from django.shortcuts import render_to_response +from codereview.dashboard.models import Repository + +def index(request): + """ List available repositories + """ + repositories = Repository.objects.all() + return render_to_response('dashboard/index.html', + {'repositories': repositories}) diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/vcs.py b/lib/vcs.py new file mode 100644 index 0000000..aea469d --- /dev/null +++ b/lib/vcs.py @@ -0,0 +1,183 @@ +import difflib +from datetime import datetime + +class VCS(object): + def __init__(self, path): + self._path = path + self._ref = None + def branches(self): + return [] + def tags(self): + return [] + def ref(self): + return self._ref + def log(self, commit=None, path=None, max=50, offset=0): + return [] + +class Blob(object): + def __init__(self, path, data): + self.path = path + self.data = data + +class Diff(object): + Types = { + 'Add': 0, + 'Delete': 1, + 'Rename': 2, + 'Modify': 3, + } + def __init__(self): + self.a = None + self.b = None + self.type = 0 + def unified(self): + a = self.a.data.split('\n') if self.a else [] + b = self.b.data.split('\n') if self.b else [] + diff = difflib.unified_diff( + a, + b, + fromfile=self.a.path, + tofile=self.b.path) + return '\n'.join(diff) + def changes(self, context=3): + a = self.a.data.split('\n') if self.a else [] + b = self.b.data.split('\n') if self.b else [] + differ = difflib.Differ() + + # Locate changes so we can mark context lines + i = 0 + changes = [] + for change in differ.compare(a, b): + if change[0] in ['+', '-']: + changes.append(i) + i += 1 + + i = 0 + line_a = 0 + line_b = 0 + for change in differ.compare(a, b): + type = change[:2].strip() + text = change[2:] + if type == '?': + # Change information. Discard it for now. + i += 1 + print 'skip ?' + continue + if type == '+': + line_b += 1 + elif type == '-': + line_a += 1 + else: + line_a += 1 + line_b += 1 + if context and not type: + # Check to see if we're in range of a change + nearby = [c for c in changes if abs(i - c) <= context + 1] + if not nearby: + i += 1 + print 'skip nc' + continue + result = { + 'type': type, + 'line_a': line_a, + 'line_b': line_b, + 'text': text, + } + yield result + i += 1 + def html(self): + a = self.a.data.split('\n') if self.a.data else [] + b = self.b.data.split('\n') if self.b.data else [] + h = difflib.HtmlDiff() + diff = h.make_table( + a, + b, + fromdesc=self.a.path, + todesc=self.b.path, + context=True) + return diff + +class Commit(object): + def __init__(self): + self.id = None + self.tree = None + self.message = None + self.author = None + self.author_email = None + self.committer = None + self.committer_email = None + self.authored_date = None + self.committed_date = None + self.parents = [] + +import git +class Git(VCS): + def __init__(self, path): + super(Git, self).__init__(path); + self._repo = git.Repo(self._path) + self._branches = None + self._tags = None + + # Set default branch ref + if 'master' in self.branches(): + self._ref = 'master' + else: + self.ref = self.branches()[0] + def branches(self): + if not self._branches: + self._branches = dict([(head.name, self.commit(head.commit)) for head in self._repo.heads]) + return self._branches + def tags(self): + if not self._tags: + self._tags = dict([(tag.name, self.commit(tag.commit)) for tag in self._repo.tags]) + return self._tags + def commit(self, commit): + if type(commit) in [str, unicode]: + commit = self._repo.commit(commit) + c = Commit() + c.id = commit.hexsha + c.tree = commit.tree.hexsha + c.message = commit.message + c.author = commit.author.name + c.author_email = commit.author.email + c.committer = commit.committer.name + c.committer_email = commit.committer.email + c.authored_date = datetime.fromtimestamp(commit.authored_date) + c.committed_date = datetime.fromtimestamp(commit.committed_date) + c.parents = [parent.hexsha for parent in commit.parents] + return c + def log(self, commit=None, path=None, max=50, offset=0): + commit = commit if commit else self._ref + result = [] + for c in self._repo.iter_commits(commit, path, max_count=max, + skip=offset): + result.append(self.commit(c)) + return result + def diff(self, b, a=None): + """Get the diff for ref b from ref a + + If ref a is not provided, b's parent refs will be used. + + *** Note: The parameter order is backwards, since we default the origin + ref to the target's parents. + """ + result = [] + b = self._repo.commit(b) + if not a: + a = b.parents + else: + a = self._repo.commit(a) + for diff in b.diff(a): + # b and a are swapped so the parent diff will work as a list of + # parents. Therefore, we'll swap them back when we put them into our + # Diff object. + d = Diff() + if diff.a_blob: d.b = Blob(diff.a_blob.path, + diff.a_blob.data_stream.read()) + if diff.b_blob: d.a = Blob(diff.b_blob.path, + diff.b_blob.data_stream.read()) + result.append(d) + return result + +if __name__ == '__main__': + g = Git('/home/correlr/code/voiceaxis') diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..5e78ea9 --- /dev/null +++ b/manage.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python +from django.core.management import execute_manager +try: + import settings # Assumed to be in the same directory. +except ImportError: + import sys + sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__) + sys.exit(1) + +if __name__ == "__main__": + execute_manager(settings) diff --git a/media/default.css b/media/default.css new file mode 100644 index 0000000..69eba47 --- /dev/null +++ b/media/default.css @@ -0,0 +1,72 @@ +body, th, td { + font-family: sans-serif; + font-size: 10pt; +} +a { + color: black; +} +span.marker { + display: block; + float: left; + border: 1px solid black; + padding-right: 0.2em; + padding-left: 0.2em; + margin-right: 0.2em; +} +span.marker a { + text-decoration: none; + color: black; +} +span.marker a:hover { + text-decoration: underline; +} +span.marker.branch { + background-color: #88ff88; +} +span.marker.tag { + background-color: #ffff88; +} +table.vcs-log { + width: 100%; +} +table.vcs-log th { + text-align: left; +} +table.vcs-log .date, +table.vcs-log .author { + width: 15%; +} + +div.vcs-nav { + text-align: center; +} + +table.diff { + width: 100%; + border: 1px solid black; + border-collapse: collapse; +} +table.diff td { + padding-left: .3em; + padding-right: .3em; +} +table.diff .number { + width: 3em; +} +table.diff .add .number { + background-color: #baeeba; +} +table.diff .add .text { + border-left: 1px solid green; + background-color: #e0ffe0; +} +table.diff .del .number { + background-color: #eebaba; +} +table.diff .del .text { + border-left: 1px solid red; + background-color: #ffe0e0; +} +table.diff td { + font-family: monospace; +} diff --git a/settings.py b/settings.py new file mode 100755 index 0000000..4079568 --- /dev/null +++ b/settings.py @@ -0,0 +1,98 @@ +# Django settings for codereview project. + +DEBUG = True +TEMPLATE_DEBUG = DEBUG + +ADMINS = ( + # ('Your Name', 'your_email@domain.com'), +) + +MANAGERS = ADMINS + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', # Add 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'. + 'NAME': 'codereview', # Or path to database file if using sqlite3. + 'USER': 'codereview', # Not used with sqlite3. + 'PASSWORD': 'codereview', # Not used with sqlite3. + 'HOST': '', # Set to empty string for localhost. Not used with sqlite3. + 'PORT': '', # Set to empty string for default. Not used with sqlite3. + } +} + +# Local time zone for this installation. Choices can be found here: +# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name +# although not all choices may be available on all operating systems. +# On Unix systems, a value of None will cause Django to use the same +# timezone as the operating system. +# If running in a Windows environment this must be set to the same as your +# system time zone. +TIME_ZONE = 'America/New_York' + +# Language code for this installation. All choices can be found here: +# http://www.i18nguy.com/unicode/language-identifiers.html +LANGUAGE_CODE = 'en-us' + +SITE_ID = 1 + +# If you set this to False, Django will make some optimizations so as not +# to load the internationalization machinery. +USE_I18N = True + +# If you set this to False, Django will not format dates, numbers and +# calendars according to the current locale +USE_L10N = True + +# Absolute path to the directory that holds media. +# Example: "/home/media/media.lawrence.com/" +MEDIA_ROOT = '' + +# URL that handles the media served from MEDIA_ROOT. Make sure to use a +# trailing slash if there is a path component (optional in other cases). +# Examples: "http://media.lawrence.com", "http://example.com/media/" +MEDIA_URL = '' + +# URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a +# trailing slash. +# Examples: "http://foo.com/media/", "/media/". +ADMIN_MEDIA_PREFIX = '/media/' + +# Make this unique, and don't share it with anybody. +SECRET_KEY = 'w*cepy#)iiaion7n!^_zwyc3cy_s89!qz!ey5%avrgg($8o_*y' + +# List of callables that know how to import templates from various sources. +TEMPLATE_LOADERS = ( + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', +# 'django.template.loaders.eggs.Loader', +) + +MIDDLEWARE_CLASSES = ( + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', +) + +ROOT_URLCONF = 'codereview.urls' + +TEMPLATE_DIRS = ( + # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". + # Always use forward slashes, even on Windows. + # Don't forget to use absolute paths, not relative paths. + "/home/correlr/code/codereview/templates", +) + +INSTALLED_APPS = ( + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.sites', + 'django.contrib.messages', + # Uncomment the next line to enable the admin: + # 'django.contrib.admin', + + 'codereview.dashboard', + 'codereview.browser', +) diff --git a/templates/browser/log.html b/templates/browser/log.html new file mode 100644 index 0000000..41d9baf --- /dev/null +++ b/templates/browser/log.html @@ -0,0 +1,53 @@ +{% extends "layouts/default.html" %} +{% load gravatar %} + +{% load vcs %} +{% block content %} +

Commit Log

+Branches: +
    +{% for branch, commit in vcs.branches.items %} +
  • {{ branch }} ({{commit.id}})
  • +{% endfor %} +
+ + + + + + +{% for commit in log %} + + + + + +{% endfor %} +
Commit DateAuthorCommit Message
{{ commit.authored_date }} + + {{ commit.author }} + + {% for branch, c in vcs.branches.items %} + {% if commit.id == c.id %} + {{ branch }} + {% endif %} + {% endfor %} + {% for tag, c in vcs.tags.items %} + {% if commit.id == c.id %} + {{ tag }} + {% endif %} + {% endfor %} + {{ commit.message|oneline }} +
+
+ {% if older %} + Older + {% endif %} + {% if offset and older %} + · + {% endif %} + {% if offset %} + Newer + {% endif %} +
+{% endblock %} diff --git a/templates/browser/view.html b/templates/browser/view.html new file mode 100644 index 0000000..22f8027 --- /dev/null +++ b/templates/browser/view.html @@ -0,0 +1,50 @@ +{% extends "layouts/default.html" %} +{% load gravatar %} +{% load vcs %} + +{% block content %} +

{{ commit.message|oneline }}

+ +
+
Committer
+
+ + {{ commit.committer }} +
+
Committed
+
{{ commit.committed_date }}
+
Author
+
+ + {{ commit.author }} +
+
Written
+
{{ commit.authored_date }}
+
Message
+
+ {{ commit.message }} +
+
+ +
+
+ {% for diff in diffs %} + + + + + + + {% endfor %} +
+ {% if diff.a %}{{ diff.a.path }}{% else %}(New File){% endif %} + {% if diff.a.path != diff.b.path %} + => + {% if diff.b %}{{ diff.b.path }}{% else %}(Deleted){% endif %} + {% endif %} + {% for change in diff.changes %} +
{% if change.type != '+' %}{{ change.line_a }}{% endif %}{% if change.type != '-' %}{{ change.line_b }}{% endif %}{{ change.text }}
+ {% endfor %} +
+
+{% endblock %} diff --git a/templates/dashboard/index.html b/templates/dashboard/index.html new file mode 100644 index 0000000..9076031 --- /dev/null +++ b/templates/dashboard/index.html @@ -0,0 +1,11 @@ +{% extends "layouts/default.html" %} + +{% block content %} +

Repositories

+ + +{% endblock %} diff --git a/templates/layouts/default.html b/templates/layouts/default.html new file mode 100644 index 0000000..6f8b759 --- /dev/null +++ b/templates/layouts/default.html @@ -0,0 +1,22 @@ + + + + + + {% block title %}CodeReview{% endblock %} + + +
+ + +
+ {% block content %} + This space intentionally left blank. + {% endblock %} +
+
+ + diff --git a/urls.py b/urls.py new file mode 100755 index 0000000..febaf47 --- /dev/null +++ b/urls.py @@ -0,0 +1,19 @@ +from django.conf.urls.defaults import * + +# Uncomment the next two lines to enable the admin: +# from django.contrib import admin +# admin.autodiscover() + +urlpatterns = patterns('', + # Example: + # (r'^codereview/', include('codereview.foo.urls')), + (r'^$', 'codereview.dashboard.views.index'), + (r'^browser/', include('codereview.browser.urls')), + + # Uncomment the admin/doc line below and add 'django.contrib.admindocs' + # to INSTALLED_APPS to enable admin documentation: + # (r'^admin/doc/', include('django.contrib.admindocs.urls')), + + # Uncomment the next line to enable the admin: + # (r'^admin/', include(admin.site.urls)), +)