[Bps-public-commit] smokingit branch, master, created. a943873435d125f4f2ceeb6f9f0342271e9fd41c

Alex Vandiver alexmv at bestpractical.com
Wed Jan 26 04:00:08 EST 2011


The branch, master has been created
        at  a943873435d125f4f2ceeb6f9f0342271e9fd41c (commit)

- Log -----------------------------------------------------------------
commit c5109df4d3f263b3f63a16bf117fb49ceb25001e
Author: Alex Vandiver <alexmv at bestpractical.com>
Date:   Sun Jan 23 23:15:45 2011 -0500

    Initial import

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..711b172
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,16 @@
+MANIFEST.bak
+Makefile
+Makefile.old
+blib/
+inc/
+pm_to_blib
+t/*/mason*/
+var/
+mason*/
+*.swp
+*.swo
+log/
+etc/site_config.yml
+.prove
+META.yml
+t/mailbox_*
diff --git a/Makefile.PL b/Makefile.PL
new file mode 100644
index 0000000..39552e1
--- /dev/null
+++ b/Makefile.PL
@@ -0,0 +1,8 @@
+use inc::Module::Install;
+
+name        'Smokingit';
+version     '0.01';
+requires    'Jifty' => '1.01209';
+requires    'Gearman::Client';
+
+WriteAll;
diff --git a/app.psgi b/app.psgi
new file mode 100644
index 0000000..ff05fb1
--- /dev/null
+++ b/app.psgi
@@ -0,0 +1,3 @@
+use Jifty;
+Jifty->new;
+Jifty->handler->psgi_app;
diff --git a/bin/jifty b/bin/jifty
new file mode 100755
index 0000000..118d895
--- /dev/null
+++ b/bin/jifty
@@ -0,0 +1,16 @@
+#!/usr/bin/env perl
+use warnings;
+use strict;
+use UNIVERSAL::require;
+
+BEGIN {
+    Jifty::Util->require or die $UNIVERSAL::require::ERROR;
+    my $root = Jifty::Util->app_root(quiet => 1);
+    unshift @INC, "$root/lib" if ($root);
+}
+
+use Jifty;
+use Jifty::Script;
+
+local $SIG{INT} = sub { warn "Stopped\n"; exit; };
+Jifty::Script->dispatch();
diff --git a/bin/local_updates b/bin/local_updates
new file mode 100755
index 0000000..cf65dac
--- /dev/null
+++ b/bin/local_updates
@@ -0,0 +1,130 @@
+#!/usr/bin/perl
+
+use strict;
+use warnings;
+use lib 'lib';
+
+use Jifty;
+BEGIN { Jifty->new; }
+use Storable qw/thaw/;
+use Benchmark qw/timestr/;
+
+use Gearman::Worker;
+use TAP::Parser::Aggregator;
+
+my $worker = Gearman::Worker->new(
+    job_servers => Jifty->config->app('job_servers'),
+);
+$worker->register_function(
+    post_results => sub {
+        my $job = shift;
+        my %result = %{ thaw( $job->arg ) };
+
+        # Properties to extract from the aggregator
+        my @props =
+            qw/failed
+               parse_errors
+               passed
+               planned
+               skipped
+               todo
+               todo_passed
+               wait
+               exit/;
+
+        # Aggregator might not exist if we had a configure failure
+        my $a = $result{aggregator};
+        if ($a) {
+            $result{$_} = $a->$_ for @props;
+            $result{is_ok}      = not($a->has_problems);
+            $result{elapsed}    = $a->elapsed->[0];
+            $result{error}      = undef;
+        } else {
+            # Unset the existing data if there was a fail
+            $result{$_} = undef for @props, "is_ok", "elapsed";
+        }
+        $result{submitted_at} = Jifty::DateTime->now;
+
+        # Find the smoke
+        my $smoke = Smokingit::Model::SmokeResult->new;
+        $smoke->load( delete $result{smoke_id} );
+        if (not $smoke->id) {
+            warn "Invalid smoke ID\n";
+            return 0;
+        } elsif (not $smoke->gearman_process) {
+            warn "Smoke report on something that wasn't being smoked?\n";
+            return 0;
+        }
+
+        # Lock on the project
+        Jifty->handle->begin_transaction;
+        my $project = Smokingit::Model::Project->new;
+        $project->row_lock(1);
+        $project->load( $smoke->project->id );
+
+        # Update with the new data
+        for my $key (keys %result) {
+            my $method = "set_$key";
+            $smoke->$method($result{$key});
+        }
+        # Mark as no longer smoking
+        $smoke->set_gearman_process(undef);
+
+        # And commit all of that
+        Jifty->handle->commit;
+
+        # Pull and re-dispatch any new commits
+        Smokingit->gearman->dispatch_background(
+            sync_project => $project->name,
+        );
+
+        return 1;
+    },
+);
+$worker->register_function(
+    sync_project => sub {
+        my $job = shift;
+        my $project_name = $job->arg;
+
+        my $project = Smokingit::Model::Project->new;
+        $project->load_by_cols( name => $project_name );
+        return 0 unless $project->id;
+
+        # Update or clone, as need be
+        if (-d $project->repository_path) {
+            warn "Updating $project_name\n";
+            $project->update_repository;
+        } else {
+            warn "Cloning $project_name\n";
+            system("git", "clone", "--quiet", "--mirror",
+                   $project->repository_url,
+                   $project->repository_path);
+        }
+
+        # Sync up the branches, but acquire a lock on the project first
+        Jifty->handle->begin_transaction;
+        $project->row_lock(1);
+        $project->load( $project->id );
+        $project->sync_branches;
+        Jifty->handle->commit;
+
+        return 1;
+    },
+);
+
+$worker->register_function(
+    retest => sub {
+        my $job = shift;
+        my $sha = $job->arg;
+
+        my $commits = Smokingit::Model::CommitCollection->new;
+        $commits->limit( column => "sha", operator => "like", value => "$sha%" );
+        return 0 unless $commits->count == 1;
+
+        my $commit = $commits->next;
+        warn "Retesting @{[$commit->short_sha]}\n";
+        return $commit->run_smoke;
+    },
+);
+
+$worker->work while 1;
diff --git a/etc/config.yml b/etc/config.yml
new file mode 100644
index 0000000..7f2290f
--- /dev/null
+++ b/etc/config.yml
@@ -0,0 +1,38 @@
+# See perldoc Jifty::Config for more information about config files
+--- 
+framework: 
+  AdminMode: 0
+  ApplicationName: Smokingit
+  ApplicationUUID: 422F3F12-2482-11E0-AFC9-AC305CCACE6C
+  ConfigFileVersion: 5
+  Database: 
+    Database: smokingit
+    Driver: Pg
+    Host: localhost
+    Password: ''
+    User: postgres
+    Version: 0.0.1
+  DevelMode: 1
+  LogLevel: INFO
+  Mailer: Sendmail
+  MailerArgs: []
+  Plugins: 
+    - AdminUI: {}
+    - CompressedCSSandJS: {}
+    - ErrorTemplates: {}
+    - LetMe: {}
+    - OnlineDocs: {}
+    - REST: {}
+    - SkeletonApp: {}
+  SkipAccessControl: 1
+  Web: 
+    BaseURL: http://localhost
+    DataDir: var/mason
+    Globals: []
+    PSGIStatic: 1
+    Port: 8888
+    StaticRoot: share/web/static
+    TemplateRoot: share/web/templates
+application:
+  job_servers:
+    - 127.0.0.1:4730
diff --git a/lib/Smokingit.pm b/lib/Smokingit.pm
new file mode 100644
index 0000000..8fbe9dc
--- /dev/null
+++ b/lib/Smokingit.pm
@@ -0,0 +1,16 @@
+use strict;
+use warnings;
+
+package Smokingit;
+use Gearman::Client;
+
+our $GEARMAN;
+
+sub start {
+    $GEARMAN = Gearman::Client->new;
+    $GEARMAN->job_servers( Jifty->config->app('job_servers') );
+}
+
+sub gearman { $GEARMAN }
+
+1;
diff --git a/lib/Smokingit/Action/UpdateBranch.pm b/lib/Smokingit/Action/UpdateBranch.pm
new file mode 100644
index 0000000..a54f728
--- /dev/null
+++ b/lib/Smokingit/Action/UpdateBranch.pm
@@ -0,0 +1,104 @@
+use strict;
+use warnings;
+
+=head1 NAME
+
+Smokingit::Action::UpdateBranch
+
+=cut
+
+package Smokingit::Action::UpdateBranch;
+use base qw/Smokingit::Action Smokingit::Action::Record::Update/;
+
+sub arguments {
+    my $self = shift;
+    return $self->{__cached_arguments}
+        if ( exists $self->{__cached_arguments} );
+
+    my $args = $self->SUPER::arguments;
+
+    my $branches = Smokingit::Model::BranchCollection->new;
+    $branches->limit( column => 'project_id', value => $self->record->project->id );
+    $branches->limit( column => 'status',     value => 'master' );
+    $branches->order_by( column => "name" );
+
+    $args->{to_merge_into}{valid_values} = [
+        {
+            display => 'None',
+            value   => '',
+        },
+        {
+            display_from => 'name',
+            value_from   => 'id',
+            collection   => $branches,
+        },
+    ];
+
+    $args->{owner}{ajax_autocompletes} = 1;
+    $args->{owner}{autocompleter} = $self->autocompleter("owner");
+    $args->{review_by}{ajax_autocompletes} = 1;
+    $args->{review_by}{autocompleter} = $self->autocompleter("review_by");
+
+    if ($self->record->status eq "ignore" and not $self->record->to_merge_into->id) {
+        my @trunks;
+        while (my $b = $branches->next) {
+            push @trunks, [$b->id, $b->current_commit->sha, $b->name];
+        }
+        local $ENV{GIT_DIR} = $self->record->project->repository_path;
+        my $topic = $self->record->current_commit->sha;
+        my @revlist = map {chomp; $_} `git rev-list $topic @{[map {"^".$_->[1]} @trunks]}`;
+        my $branchpoint;
+        if (@revlist) {
+            $branchpoint = `git rev-parse $revlist[-1]~`;
+            chomp $branchpoint;
+        } else {
+            $branchpoint = $topic;
+        }
+        for my $t (@trunks) {
+            next if `git rev-list --max-count=1 $branchpoint ^$t->[1]` =~ /\S/;
+            $args->{to_merge_into}{default_value} = $t->[0];
+            last;
+        }
+    }
+    return $self->{__cached_arguments} = $args;
+}
+
+sub autocompleter {
+    my $self = shift;
+    my $skip = shift eq "owner" ? "review_by" : "owner";
+    return sub {
+        my $self = shift;
+        my $current = shift;
+        my %results;
+
+        my $commits = Smokingit::Model::CommitCollection->new;
+        $commits->limit( column => 'project_id', value => $self->record->project->id );
+        $commits->limit( column => 'author', operator => 'MATCHES', value => $current );
+        $results{$_}++ for $commits->distinct_column_values("author");
+
+        for my $column (qw/owner review_by/) {
+            my $branches = Smokingit::Model::BranchCollection->new;
+            $branches->limit(
+                column => $column,
+                operator => 'MATCHES',
+                value => $current,
+            );
+            $results{$_}++ for $branches->distinct_column_values($column);
+        }
+        delete $results{$self->record->$skip};
+
+        my @results = sort keys %results;
+        return if @results == 1 and $results[0] eq $current;
+        return sort @results;
+    };
+}
+
+
+sub report_success {
+    my $self = shift;
+    $self->record->set_last_status_update($self->record->current_commit->id);
+    $self->SUPER::report_success;
+}
+
+1;
+
diff --git a/lib/Smokingit/Dispatcher.pm b/lib/Smokingit/Dispatcher.pm
new file mode 100644
index 0000000..34f8789
--- /dev/null
+++ b/lib/Smokingit/Dispatcher.pm
@@ -0,0 +1,100 @@
+use strict;
+use warnings;
+
+package Smokingit::Dispatcher;
+use Jifty::Dispatcher -base;
+
+under '/project/*' => [
+    run {
+        my $project = Smokingit::Model::Project->new;
+        $project->load_by_cols( name => $1 );
+        show '/errors/404' unless $project->id;
+        set project => $project;
+    },
+
+    on '' => run {
+        my $project = get('project');
+        if ($project->configurations->count) {
+            show '/project';
+        } else {
+            show '/new-configuration';
+        }
+    },
+
+    on 'branch/**' => run {
+        my $name = $1;
+        $name =~ s{/+}{/}g;
+        $name =~ s{/$}{};
+        my $branch = Smokingit::Model::Branch->new;
+        $branch->load_by_cols(
+            name => $name,
+            project_id => get('project')->id,
+        );
+        show '/errors/404' unless $branch->id;
+        set branch => $branch;
+        show '/branch';
+    },
+
+    on 'config/**' => run {
+        my $name = $1;
+        $name =~ s{/+}{/}g;
+        $name =~ s{/$}{};
+        my $config = Smokingit::Model::Configuration->new;
+        $config->load_by_cols(
+            name => $name,
+            project_id => get('project')->id,
+        );
+        show '/errors/404' unless $config->id;
+        set config => $config;
+        show '/config';
+    },
+
+    on 'new-configuration' => run {
+        show '/new-configuration';
+    },
+];
+
+on '/*/**' => run {
+    # Shortcut URLs, of /projectname/branchname
+    my ($pname, $bname) = ($1, $2);
+    my $project = Smokingit::Model::Project->new;
+    $project->load_by_cols( name => $pname );
+    return unless $project->id;
+
+    my $branch = Smokingit::Model::Branch->new;
+    $bname =~ s{/+}{/}g;
+    $bname =~ s{/$}{};
+    $branch->load_by_cols( name => $bname, project_id => $project->id );
+    return unless $branch->id;
+
+    redirect '/project/' . $project->name . '/branch/' . $branch->name;
+};
+
+on '' => run {
+    my $res = Jifty->web->response->result('create-project');
+    return unless $res and $res->content('id');
+    my $project = Smokingit::Model::Project->new;
+    $project->load( $res->content('id') );
+    redirect '/project/' . $project->name . "/";
+};
+
+# GitHub post-receive-hook support
+use Jifty::JSON qw/decode_json/;
+on '/github' => run {
+    show '/github/error' unless Jifty->web->request->method eq "POST";
+    show '/github/error' unless get('payload');
+    my $json = eval { decode_json(get('payload')) }
+        or show '/github/error';
+    my $name = $json->{repository}{name}
+        or show '/github/error';
+
+    my $project = Smokingit::Model::Project->new;
+    $project->load_by_cols( name => $name );
+    Smokingit->gearman->dispatch_background(
+        sync_project => $project->name,
+    ) if $project->id;
+
+    show '/github';
+};
+
+1;
diff --git a/lib/Smokingit/Model/Branch.pm b/lib/Smokingit/Model/Branch.pm
new file mode 100644
index 0000000..0520438
--- /dev/null
+++ b/lib/Smokingit/Model/Branch.pm
@@ -0,0 +1,130 @@
+use strict;
+use warnings;
+
+package Smokingit::Model::Branch;
+use Jifty::DBI::Schema;
+
+use Smokingit::Record schema {
+    column project_id =>
+        references Smokingit::Model::Project;
+
+    column name =>
+        type is 'text',
+        is mandatory,
+        label is _("Branch name");
+
+    column first_commit_id =>
+        references Smokingit::Model::Commit;
+
+    column current_commit_id =>
+        references Smokingit::Model::Commit;
+
+    column last_status_update =>
+        references Smokingit::Model::Commit;
+
+    column status =>
+        type is 'text',
+        is mandatory,
+        valid_values are qw(ignore hacking needs-tests needs-review awaiting-merge merged master releng);
+
+    column long_status =>
+        type is 'text',
+        render_as "Textarea";
+
+    column owner =>
+        type is 'text';
+
+    column review_by =>
+        type is 'text';
+
+    column to_merge_into =>
+        references Smokingit::Model::Branch;
+};
+
+sub create {
+    my $self = shift;
+    my %args = (
+        sha => undef,
+        @_,
+    );
+
+    # Ensure that we have a tip commit
+    my $tip = Smokingit::Model::Commit->new;
+    $tip->load_or_create( project_id => $args{project_id}, sha => delete $args{sha} );
+    $args{current_commit_id} = $tip->id;
+    $args{first_commit_id} = $tip->id;
+    $args{owner} = $tip->committer;
+
+    my ($ok, $msg) = $self->SUPER::create(%args);
+    unless ($ok) {
+        Jifty->handle->rollback;
+        return ($ok, $msg);
+    }
+
+    # For the tip, add skips for all configurations
+    warn "Current head @{[$self->name]} is @{[$self->current_commit->short_sha]}\n";
+    my $configs = $self->project->configurations;
+    while (my $config = $configs->next) {
+        my $head = Smokingit::Model::TestedHead->new;
+        $head->load_or_create(
+            project_id       => $self->project->id,
+            configuration_id => $config->id,
+            commit_id        => $tip->id,
+        );
+    }
+
+    return ($ok, $msg);
+}
+
+sub long_status_html {
+    my $self = shift;
+    my $html = Jifty->web->escape($self->long_status);
+    $html =~ s{( {2,})}{" " x length($1)}eg;
+    $html =~ s{\n}{<br />}g;
+    return $html;
+}
+
+sub is_tested {
+    my $self = shift;
+    return $self->status ne "ignore";
+}
+
+sub commit_list {
+    my $self = shift;
+    local $ENV{GIT_DIR} = $self->project->repository_path;
+
+    my $first = $self->first_commit->sha;
+    my $last = $self->current_commit->sha;
+    my @revs = map {chomp; $_} `git rev-list ^$first $last`;
+    push @revs, map {chomp; $_} `git rev-list $first --max-count=11`;
+
+    my @commits;
+    for my $sha (@revs) {
+        my $c = Smokingit::Model::Commit->new;
+        $c->load_or_create( project_id => $self->project->id, sha => $sha );
+        push @commits, $c;
+    }
+    return @commits;
+}
+
+sub branchpoint {
+    my $self = shift;
+    my $max = shift || 100;
+    return undef if $self->status eq "master";
+    return undef unless $self->to_merge_into->id;
+
+    my $trunk = $self->to_merge_into->current_commit->sha;
+    my $tip   = $self->current_commit->sha;
+
+    local $ENV{GIT_DIR} = $self->project->repository_path;
+    my @branch = map {chomp; $_} `git rev-list $tip ^$trunk --max-count=$max`;
+    return unless @branch;
+
+    my $commit = Smokingit::Model::Commit->new;
+    $commit->load_by_cols( sha => $branch[-1] );
+    return undef unless $commit->id;
+    return $commit;
+}
+
+1;
+
diff --git a/lib/Smokingit/Model/Commit.pm b/lib/Smokingit/Model/Commit.pm
new file mode 100644
index 0000000..6da6f50
--- /dev/null
+++ b/lib/Smokingit/Model/Commit.pm
@@ -0,0 +1,168 @@
+use strict;
+use warnings;
+
+package Smokingit::Model::Commit;
+use Jifty::DBI::Schema;
+
+use Smokingit::Record schema {
+    column project_id =>
+        references Smokingit::Model::Project;
+
+    column sha =>
+        type is 'text',
+        is mandatory,
+        is unique,
+        is indexed;
+
+    column author =>
+        type is 'text';
+
+    column authored_time =>
+        is timestamp;
+
+    column committer =>
+        type is 'text';
+
+    column committed_time =>
+        is timestamp;
+
+    column parents =>
+        type is 'text';
+
+    column subject =>
+        type is 'text';
+
+    column body =>
+        type is 'text';
+};
+
+sub create {
+    my $self = shift;
+    my %args = (
+        @_,
+    );
+    my $str = `git rev-list --format=format:"%aN <%aE>%n%at%n%cN <%cE>%n%ct%n%P%n%s%n%b" $args{sha} -n1`;
+    (undef, @args{qw/author authored_time committer committed_time parents subject body/})
+        = split /\n/, $str, 8;
+    $args{$_} = Jifty::DateTime->from_epoch( $args{$_} )
+        for qw/authored_time committed_time/;
+
+    my ($ok, $msg) = $self->SUPER::create(%args);
+    return ($ok. $msg) unless $ok;
+}
+
+sub short_sha {
+    my $self = shift;
+    return substr($self->sha,0,7);
+}
+
+sub is_smoked {
+    my $self = shift;
+    return $self->smoked->count > 0;
+}
+
+sub run_smoke {
+    my $self = shift;
+    my $config = shift;
+
+    if ($config) {
+        my $smoke = Smokingit::Model::SmokeResult->new;
+        $smoke->load_or_create(
+            project_id       => $self->project->id,
+            configuration_id => $config->id,
+            commit_id        => $self->id,
+        );
+        $smoke->run_smoke;
+        return 1;
+    } else {
+        my $configs = $self->project->configurations;
+        my $smoke = Smokingit::Model::SmokeResult->new;
+        while ($config = $configs->next) {
+            $smoke->load_or_create(
+                project_id       => $self->project->id,
+                configuration_id => $config->id,
+                commit_id        => $self->id,
+            );
+            $smoke->run_smoke;
+        }
+        return $configs->count;
+    }
+}
+
+sub status {
+    my $self = shift;
+    my $config = shift;
+
+    if ($config) {
+        my $result = Smokingit::Model::SmokeResult->new;
+        $result->load_by_cols(
+            project_id => $self->project->id,
+            configuration_id => $config->id,
+            commit_id => $self->id,
+        );
+        if (not $result->id) {
+            return ("untested", "");
+        } elsif ($result->gearman_process) {
+            my $status = $result->gearman_status;
+            if ($status->running) {
+                my $msg = defined $status->percent
+                    ? int($status->percent*100)."% complete"
+                        : "Configuring";
+                return ("testing", $msg);
+            } else {
+                return ("queued", "Queued to test");
+            }
+        } elsif ($result->error) {
+            return ("errors", $result->short_error);
+        } elsif ($result->is_ok) {
+            return ("passing", $result->passed . " OK")
+        } elsif ($result->todo_passed) {
+            return ("failing", $result->todo_passed . " TODO passed");
+        } elsif ($result->failed) {
+            return ("failing", $result->failed . " failed");
+        } elsif ($result->parse_errors) {
+            return ("failing", $result->parse_errors . " parse errors");
+        } elsif ($result->exit) {
+            return ("failing", "Bad exit status (".$result->exit.")");
+        } elsif ($result->wait) {
+            return ("failing", "Bad wait status (".$result->wait.")");
+        } else {
+            return ("failing", "Unknown failure");
+        }
+    } else {
+        my $smoked = $self->smoked;
+        my $passing = 0;
+        if ($smoked->count) {
+            while (my $smoke = $smoked->next) {
+                next if $smoke->gearman_process;
+                return "failing" unless $smoke->is_ok;
+                $passing++;
+            }
+            return $passing ? "passing" : "testing";
+        }
+
+        return "untested";
+    }
+}
+
+sub smoked {
+    my $self = shift;
+    my $smoked = Smokingit::Model::SmokeResultCollection->new;
+    $smoked->limit( column => "commit_id", value => $self->id );
+    $smoked->limit( column => "project_id", value => $self->project->id );
+    return $smoked;
+}
+
+sub parents {
+    my $self = shift;
+    my @parents;
+    for my $sha (split ' ', $self->_value('parents')) {
+        my $c = Smokingit::Model::Commit->new;
+        $c->load_or_create( sha => $sha, project_id => $self->project->id );
+        push @parents, $c;
+    }
+    return @parents;
+}
+
+1;
+
diff --git a/lib/Smokingit/Model/Configuration.pm b/lib/Smokingit/Model/Configuration.pm
new file mode 100644
index 0000000..a759bc1
--- /dev/null
+++ b/lib/Smokingit/Model/Configuration.pm
@@ -0,0 +1,81 @@
+use strict;
+use warnings;
+
+package Smokingit::Model::Configuration;
+use Jifty::DBI::Schema;
+
+use Smokingit::Record schema {
+    column project_id =>
+        references Smokingit::Model::Project;
+
+    column name =>
+        is mandatory,
+        label is "Name",
+        type is "text";
+
+    column configure_cmd =>
+        type is 'text',
+        label is "Configuration commands",
+        default is 'perl Makefile.PL && make',
+        render_as 'Textarea';
+
+    column env =>
+        type is 'text',
+        label is "Environment variables",
+        render_as 'Textarea';
+
+    column test_glob =>
+        type is 'text',
+        label is 'Glob of test files',
+        default is "t/*.t";
+
+    column parallel =>
+        is boolean,
+        label is 'Parallel testing?',
+        default is 't';
+};
+
+sub create {
+    my $self = shift;
+    my %args = (
+        @_,
+    );
+
+    # Lock on the project
+    Jifty->handle->begin_transaction;
+    my $project = Smokingit::Model::Project->new;
+    $project->row_lock(1);
+    $project->load( $args{project_id} );
+
+    my ($ok, $msg) = $self->SUPER::create(%args);
+    unless ($ok) {
+        Jifty->handle->rollback;
+        return ($ok, $msg);
+    }
+
+    # Find the distinct set of branch tips
+    my %commits;
+    my $branches = $project->branches;
+    while (my $b = $branches->next) {
+        warn "Current head @{[$b->name]} is @{[$b->current_commit->short_sha]}\n";
+        $commits{$b->current_commit->id}++;
+    }
+
+    # Add a TestedHead for each of the above
+    for my $commit_id (keys %commits) {
+        my $head = Smokingit::Model::TestedHead->new;
+        $head->create(
+            project_id       => $project->id,
+            configuration_id => $self->id,
+            commit_id        => $commit_id,
+        );
+    }
+
+    # Schedule tests
+    $project->schedule_tests;
+
+    Jifty->handle->commit;
+}
+
+1;
+
diff --git a/lib/Smokingit/Model/Project.pm b/lib/Smokingit/Model/Project.pm
new file mode 100644
index 0000000..26dc0c2
--- /dev/null
+++ b/lib/Smokingit/Model/Project.pm
@@ -0,0 +1,247 @@
+use strict;
+use warnings;
+
+package Smokingit::Model::Project;
+use Jifty::DBI::Schema;
+
+use Smokingit::Record schema {
+    column "name" =>
+        type is 'text',
+        is mandatory,
+        is distinct,
+        is indexed,
+        label is _("Project name");
+
+    column repository_url =>
+        type is 'text',
+        is mandatory,
+        label is _("Repository URL");
+};
+
+use IO::Handle;
+use Git::PurePerl;
+
+sub create {
+    my $self = shift;
+    my %args = (
+        @_,
+    );
+
+    my $repo = eval { Git::PurePerl::Protocol->new(
+        remote => $args{repository_url},
+    ) };
+    return (0, "Repository validation failed") unless $repo;
+
+    my ($ok, $msg) = $self->SUPER::create(%args);
+    return ($ok, $msg) unless $ok;
+
+    # Kick off the clone in the background
+    Smokingit->gearman->dispatch_background(
+        sync_project => $self->name,
+    );
+
+    return ($ok, $msg);
+}
+
+sub repository_path {
+    my $self = shift;
+    return Jifty::Util->app_root . "/var/repos/" . $self->name;
+}
+
+sub repository {
+    my $self = shift;
+    return $self->{repository} ||= Git::PurePerl->new(
+        gitdir => $self->repository_path,
+    );
+}
+
+sub configurations {
+    my $self = shift;
+    my $configs = Smokingit::Model::ConfigurationCollection->new;
+    $configs->limit(
+        column => 'project_id',
+        value => $self->id,
+    );
+    $configs->order_by( column => "id" );
+    return $configs;
+}
+
+sub branches {
+    my $self = shift;
+    my $branches = Smokingit::Model::BranchCollection->new;
+    $branches->limit(
+        column => 'project_id',
+        value => $self->id,
+    );
+    $branches->order_by( column => "name" );
+    return $branches;
+}
+
+sub tested_heads {
+    my $self = shift;
+    my $tested = Smokingit::Model::TestedHeadCollection->new;
+    $tested->limit(
+        column => 'project_id',
+        value => $self->id,
+    );
+    return $tested;
+}
+
+sub planned_tests {
+    my $self = shift;
+    my $tests = Smokingit::Model::SmokeResultCollection->new;
+    $tests->limit(
+        column => "gearman_process",
+        operator => "IS NOT",
+        value => "NULL"
+    );
+    $tests->limit( column => "project_id", value => $self->id );
+    my @tests = @{ $tests->items_array_ref };
+    @tests = sort { $b->gearman_status->running     <=>  $a->gearman_status->running
+                or ($b->gearman_status->percent||0) <=> ($a->gearman_status->percent||0)
+                or  $a->id                          <=>  $b->id} @tests;
+    return @tests;
+}
+
+sub update_repository {
+    my $self = shift;
+    local $ENV{GIT_DIR} = $self->repository_path;
+    `git fetch --all --prune --quiet`;
+}
+
+sub sync_branches {
+    my $self = shift;
+    warn "sync_branches called with no row lock!"
+        unless $self->row_lock;
+
+    local $ENV{GIT_DIR} = $self->repository_path;
+
+    my %branches;
+    for ($self->repository->ref_names) {
+        next unless s{^refs/heads/}{};
+        $branches{$_}++;
+    }
+
+    my $branches = $self->branches;
+    while (my $branch = $branches->next) {
+        if (not $branches{$branch->name}) {
+            $branch->delete;
+            next;
+        }
+        delete $branches{$branch->name};
+        my $new_ref = $self->repository->ref_sha1("refs/heads/" . $branch->name);
+        my $old_ref = $branch->current_commit->sha;
+        next if $new_ref eq $old_ref;
+
+        warn "Update @{[$branch->name]} $old_ref -> $new_ref\n";
+        my @revs = map{chomp; $_} `git rev-list ^$old_ref $new_ref`;
+        my $commit = Smokingit::Model::Commit->new;
+        $commit->load_or_create( project_id => $self->id, sha => $_ ) for reverse @revs;
+        $branch->set_current_commit_id($commit->id);
+    }
+
+    for my $name (keys %branches) {
+        warn "New branch $name\n";
+        my $trunk = ($name eq "master");
+        my $sha = $self->repository->ref_sha1("refs/heads/$name");
+        my $branch = Smokingit::Model::Branch->new;
+        my ($ok, $msg) = $branch->create(
+            project_id    => $self->id,
+            name          => $name,
+            sha           => $sha,
+            status        => $trunk ? "master" : "ignore",
+            long_status   => "",
+            to_merge_into => undef,
+        );
+        warn "Create failed: $msg" unless $ok;
+    }
+    $self->schedule_tests;
+}
+
+sub schedule_tests {
+    my $self = shift;
+    warn "schedule_tests called with no row lock!"
+        unless $self->row_lock;
+
+    local $ENV{GIT_DIR} = $self->repository_path;
+
+    # Determine the possible tips to test
+    my %branches;
+    my $branches = $self->branches;
+    while (my $branch = $branches->next) {
+        $branches{$branch->current_commit->sha}++
+            if $branch->is_tested;
+    }
+
+    # Bail early if there are no testable branches
+    return unless keys %branches;
+
+    my $smokes = 0;
+    my $configs = $self->configurations;
+    while (my $config = $configs->next) {
+        # Find the set of already-covered commits
+        my %tested;
+        my $tested = $self->tested_heads;
+        $tested->limit( column => 'configuration_id', value => $config->id );
+        while (my $head = $tested->next) {
+            $tested{$head->commit->sha} = $head;
+        }
+
+        warn "Looking for possible @{[$config->name]} tests\n";
+        my @filter = (keys(%branches), map "^$_", keys %tested);
+        my @lines = split /\n/, `git rev-list --reverse --parents @filter`;
+        for my $l (@lines) {
+            # We only want to test it if both parents have existing tests
+            my ($commit, @shas) = split ' ', $l;
+            my @tested = grep {defined} map {$tested{$_}} @shas;
+            warn "Looking at $commit (parents @shas)\n";
+            if (@tested < @shas) {
+                warn "  Parents which are not tested\n";
+                next;
+            }
+            my @pending = grep {$_->gearman_process} map {$_->smoke_result} @tested;
+            if (@pending) {
+                warn "  Parents which are still testing\n";
+                next;
+            }
+
+            warn "  Sending to testing.\n";
+
+            my $to_test = Smokingit::Model::Commit->new;
+            $to_test->load_by_cols( project_id => $self->id, sha => $commit );
+
+            $_->delete for @tested;
+            my $head = Smokingit::Model::TestedHead->new;
+            $head->create(
+                project_id       => $self->id,
+                configuration_id => $config->id,
+                commit_id        => $to_test->id,
+            );
+            $smokes += $to_test->run_smoke($config);
+        }
+    }
+
+    # As a fallback, ensure that all testable heads have been tested,
+    # even if they are kids of existing testedhead objects
+    while (my $config = $configs->next) {
+        for my $sha (keys %branches) {
+            my $commit = Smokingit::Model::Commit->new;
+            $commit->load_by_cols( project_id => $self->id, sha => $sha );
+
+            my $existing = Smokingit::Model::SmokeResult->new;
+            $existing->load_by_cols(
+                project_id       => $self->id,
+                configuration_id => $config->id,
+                commit_id        => $commit->id,
+            );
+            next if $existing->id;
+            warn "Smoking untested head ".join(":",$config->name,$commit->short_sha)."\n";
+            $smokes += $commit->run_smoke($config);
+        }
+    }
+
+    return $smokes;
+}
+
+1;
+
diff --git a/lib/Smokingit/Model/SmokeResult.pm b/lib/Smokingit/Model/SmokeResult.pm
new file mode 100644
index 0000000..b548e91
--- /dev/null
+++ b/lib/Smokingit/Model/SmokeResult.pm
@@ -0,0 +1,93 @@
+use strict;
+use warnings;
+
+package Smokingit::Model::SmokeResult;
+use Jifty::DBI::Schema;
+
+use Storable qw/freeze/;
+
+use Smokingit::Record schema {
+    column project_id =>
+        is mandatory,
+        references Smokingit::Model::Project;
+
+    column configuration_id =>
+        is mandatory,
+        references Smokingit::Model::Configuration;
+
+    column commit_id =>
+        is mandatory,
+        is indexed,
+        references Smokingit::Model::Commit;
+
+    column gearman_process =>
+        type is 'text';
+
+    column submitted_at =>
+        is timestamp;
+
+    column error =>
+        type is 'text';
+
+    column aggregator =>
+        type is 'blob',
+        filters are 'Jifty::DBI::Filter::Storable';
+
+    column is_ok        => is boolean;
+
+    column failed       => type is 'integer';
+    column parse_errors => type is 'integer';
+    column passed       => type is 'integer';
+    column planned      => type is 'integer';
+    column skipped      => type is 'integer';
+    column todo         => type is 'integer';
+    column todo_passed  => type is 'integer';
+
+    column wait         => type is 'integer';
+    column exit         => type is 'integer';
+
+    column elapsed      => type is 'integer';
+};
+
+sub short_error {
+    my $self = shift;
+    my $msg = ($self->error || "");
+    $msg =~ s/\n.*//s;
+    return $msg;
+}
+
+sub gearman_status {
+    my $self = shift;
+    return undef unless $self->gearman_process;
+    return $self->{job_status} ||= Smokingit->gearman->get_status($self->gearman_process);
+}
+
+sub run_smoke {
+    my $self = shift;
+
+    warn "Smoking ".
+        join( ":",
+              $self->project->name,
+              $self->configuration->name,
+              $self->commit->sha
+          )."\n";
+
+    my $job_id = Smokingit->gearman->dispatch_background(
+        "run_tests",
+        freeze( {
+            smoke_id       => $self->id,
+
+            project        => $self->project->name,
+            repository_url => $self->project->repository_url,
+            sha            => $self->commit->sha,
+            configure_cmd  => $self->configuration->configure_cmd,
+            env            => $self->configuration->env,
+            parallel       => ($self->configuration->parallel ? 1 : 0),
+            test_glob      => $self->configuration->test_glob,
+        } ),
+    );
+    $self->set_gearman_process($job_id);
+}
+
+1;
+
diff --git a/lib/Smokingit/Model/TestedHead.pm b/lib/Smokingit/Model/TestedHead.pm
new file mode 100644
index 0000000..61dce23
--- /dev/null
+++ b/lib/Smokingit/Model/TestedHead.pm
@@ -0,0 +1,33 @@
+use strict;
+use warnings;
+
+package Smokingit::Model::TestedHead;
+use Jifty::DBI::Schema;
+
+use Smokingit::Record schema {
+    column project_id =>
+        is mandatory,
+        references Smokingit::Model::Project;
+
+    column configuration_id =>
+        is mandatory,
+        references Smokingit::Model::Configuration;
+
+    column commit_id =>
+        is mandatory,
+        references Smokingit::Model::Commit;
+};
+
+sub smoke_result {
+    my $self = shift;
+    my $result = Smokingit::Model::SmokeResult->new;
+    $result->load_by_cols(
+        project_id       => $self->project->id,
+        configuration_id => $self->configuration->id,
+        commit_id        => $self->commit->id,
+    );
+    return $result;
+}
+
+1;
+
diff --git a/lib/Smokingit/View.pm b/lib/Smokingit/View.pm
new file mode 100644
index 0000000..d46fad5
--- /dev/null
+++ b/lib/Smokingit/View.pm
@@ -0,0 +1,52 @@
+use strict;
+use warnings;
+
+package Smokingit::View;
+use Jifty::View::Declare -base;
+
+require Smokingit::View::Project;
+alias Smokingit::View::Project under '/';
+
+require Smokingit::View::Branch;
+alias Smokingit::View::Branch under '/';
+
+require Smokingit::View::Configuration;
+alias Smokingit::View::Configuration under '/';
+
+require Smokingit::View::GitHub;
+alias Smokingit::View::GitHub under '/';
+
+template '/index.html' => page {
+    page_title is 'Projects';
+    my $projects = Smokingit::Model::ProjectCollection->new;
+    $projects->unlimit;
+    if ($projects->count) {
+        h2 { "Existing projects" };
+        ul {
+            while (my $p = $projects->next) {
+                li {
+                    hyperlink(
+                        label => $p->name,
+                        url => "/project/".$p->name."/",
+                    );
+                }
+            }
+        };
+    }
+
+    div {
+        { id is "create-project"; };
+        h2 { "Add a project" };
+        form {
+            my $create = new_action(
+                class => "CreateProject",
+                moniker => "create-project",
+            );
+            render_param( $create => 'name' );
+            render_param( $create => 'repository_url' );
+            form_submit( label => _("Create") );
+        };
+    };
+};
+
+1;
diff --git a/lib/Smokingit/View/Branch.pm b/lib/Smokingit/View/Branch.pm
new file mode 100644
index 0000000..ae40fd5
--- /dev/null
+++ b/lib/Smokingit/View/Branch.pm
@@ -0,0 +1,197 @@
+use strict;
+use warnings;
+
+package Smokingit::View::Branch;
+use Jifty::View::Declare -base;
+
+template '/branch' => page {
+    my $b = get('branch');
+    redirect '/' unless $b;
+    page_title is $b->name;
+    div {{class is "subtitle"} $b->project->name };
+
+    render_region(
+        name => "properties",
+        path => "/fragments/branch/properties",
+        defaults => { branch_id => $b->id },
+    );
+
+    div { { class is "hline" } };
+
+    my $configs = $b->project->configurations;
+    my $x = 0;
+    my @configs;
+    if ($configs->count > 1) {
+        div {
+            { id is "branch-title" }
+            while (my $c = $configs->next) {
+                push @configs, $c;
+                div {
+                    attr { style => "padding-left: ".($x++*32)."px" };
+                    $c->name
+                }
+            }
+        }
+    } else {
+        @configs = ($configs->next);
+    }
+
+    my $project_id = $b->project->id;
+    my @commits = $b->commit_list;
+    my $branchpoint = $b->branchpoint(scalar @commits);
+    div {
+        id is "branch-commits";
+        class is "commitlist biglist";
+        for my $commit (@commits) {
+            div {
+                {class is "commit ".$commit->status};
+                for my $config (@configs) {
+                    my ($status, $msg) = $commit->status($config);
+                    if ($status =~ /^(untested|testing|queued)$/) {
+                        span {
+                            attr { class => "okbox $status", title => $msg };
+                            outs_raw(" ")
+                        };
+                    } else {
+                        hyperlink(
+                            class => "okbox $status",
+                            label => " ",
+                            escape_label => 0,
+                            url   => "/test/".$commit->sha."/".$config->name,
+                            tooltip => $msg,
+                        );
+                    }
+                }
+                if ($commit->status =~ /^(failing|passing)$/) {
+                    hyperlink(
+                        tooltip => $commit->status,
+                        class => "sha",
+                        url => "/test/".$commit->sha."/",
+                        label => $commit->short_sha,
+                    );
+                } else {
+                    span {
+                        { class is "sha" };
+                        $commit->short_sha
+                    }
+                }
+                span {{class is "subject"} $commit->subject };
+            };
+            if ($branchpoint and $branchpoint->id == $commit->id) {
+                div { { class is "branchpoint" } };
+            }
+        }
+    };
+};
+
+template '/fragments/branch/properties' => sub {
+    my $b = Smokingit::Model::Branch->new;
+    $b->load( get('branch_id') );
+    table {
+        { id is "branch-properties" };
+        js_handlers {
+            onclick => {replace_with => "/fragments/branch/edit" }
+        };
+
+        row {
+            th { "Status" };
+            cell {
+                span { {class is "status"} $b->status };
+                if ($b->status ne "master") {
+                    if (not $b->last_status_update->id
+                            or $b->current_commit->id != $b->last_status_update->id) {
+                        span { {class is "updated"} "(Needs update)"}
+                    }
+                }
+            };
+        };
+
+        if ($b->status ne "master") {
+            row {
+                th { "Owner" };
+                cell { $b->owner };
+            };
+            row {
+                th { "Merge into" };
+                cell { $b->to_merge_into->id ? $b->to_merge_into->name : "None" };
+            };
+        }
+
+        if ($b->status =~ /^(needs-review|awaiting-merge|merged)$/ ) {
+            row {
+                th { "Review by" };
+                cell { $b->review_by };
+            };
+        }
+    };
+    div {
+        js_handlers {
+            onclick => {replace_with => "/fragments/branch/edit" }
+        };
+        id is "long-status";
+        class is $b->status;
+        outs_raw( $b->long_status_html );
+    };
+};
+
+template '/fragments/branch/edit' => sub {
+    my $b = Smokingit::Model::Branch->new;
+    $b->load( get('branch_id') );
+    my $status = $b->status;
+    form {
+        my $update = $b->as_update_action( moniker => "update" );
+        render_hidden( $update => last_status_update => $b->current_commit->id );
+        table {
+            { id is "branch-properties"; class is $status };
+
+            row {
+                th { "Status" };
+                cell { render_param(
+                    $update => "status",
+                    label => "",
+                    onchange => "narrow(this)"
+                ) };
+            };
+
+            row { { class is "owner" };
+                th { "Owner" };
+                cell { render_param( $update => "owner", label => "" ) };
+            };
+
+            row { { class is "to_merge_into" };
+                th { "Merge into" };
+                cell { render_param( $update => "to_merge_into", label => "" ) };
+            };
+
+            row { { class is "review_by" };
+                th { "Review by" };
+                cell { render_param( $update => "review_by", label => "") };
+            };
+        };
+        div {
+            { id is "long-status" };
+            render_param( $update => "long_status", label => "")
+        };
+        div {
+            { id is "branch-buttons" };
+            form_submit(
+                class => "branch-save-button",
+                label => "Save",
+                onclick => {
+                    submit => $update,
+                    replace_with => "/fragments/branch/properties",
+                }
+            );
+            form_submit(
+                class => "branch-cancel-button",
+                label => "Cancel",
+                submit => [],
+                onclick => {
+                    replace_with => "/fragments/branch/properties",
+                }
+            );
+        };
+    };
+};
+
+1;
diff --git a/lib/Smokingit/View/Configuration.pm b/lib/Smokingit/View/Configuration.pm
new file mode 100644
index 0000000..ef09f6d
--- /dev/null
+++ b/lib/Smokingit/View/Configuration.pm
@@ -0,0 +1,53 @@
+use strict;
+use warnings;
+
+package Smokingit::View::Configuration;
+use Jifty::View::Declare -base;
+
+template '/config' => page {
+    my $c = get('config');
+    redirect '/' unless $c;
+    page_title is $c->name;
+    div {{class is "subtitle"} $c->project->name };
+    form {
+        my $config = new_action(
+            class => "UpdateConfiguration",
+            moniker => "update-configuration",
+            record => $c,
+        );
+        my $update = new_action(
+            class => "UpdateProject",
+            moniker => "update-project",
+            record => get('project'),
+        );
+        render_param( $config => "name" );
+        render_param( $config => "configure_cmd" );
+        render_param( $config => "env" );
+
+        render_param( $update => "test_glob" );
+        render_param( $update => "parallel" );
+        form_submit( label => _("Update"), url => "/project/". $c->project->name . "/");
+    };
+};
+
+template '/new-configuration' => page {
+    redirect '/' unless get('project');
+    page_title is get('project')->name;
+    div {{class is "subtitle"} "New configuration" };
+    form {
+        my $create = new_action(
+            class => "CreateConfiguration",
+            moniker => "create-configuration",
+        );
+        my $name = get('project')->configurations->count ? "" : "Default";
+        render_hidden( $create => "project_id" => get('project')->id );
+        render_param( $create => "name", default_value => $name );
+        render_param( $create => "configure_cmd" );
+        render_param( $create => "env" );
+        render_param( $create => "test_glob" );
+        render_param( $create => "parallel" );
+        form_submit( label => _("Create"), url => "/project/" . get('project')->name . "/" );
+    };
+};
+
+1;
diff --git a/lib/Smokingit/View/GitHub.pm b/lib/Smokingit/View/GitHub.pm
new file mode 100644
index 0000000..480ee4d
--- /dev/null
+++ b/lib/Smokingit/View/GitHub.pm
@@ -0,0 +1,16 @@
+use strict;
+use warnings;
+
+package Smokingit::View::GitHub;
+use Jifty::View::Declare -base;
+
+
+template '/github' => sub {
+    outs("OK");
+};
+
+template '/github/error' => sub {
+    outs("Error!");
+};
+
+1;
diff --git a/lib/Smokingit/View/Page.pm b/lib/Smokingit/View/Page.pm
new file mode 100644
index 0000000..074c0cc
--- /dev/null
+++ b/lib/Smokingit/View/Page.pm
@@ -0,0 +1,46 @@
+use strict;
+use warnings;
+
+package Smokingit::View::Page;
+
+use base qw(Jifty::Plugin::ViewDeclarePage::Page);
+use Jifty::View::Declare::Helpers;
+
+sub render_page {
+    my $self = shift;
+    my $title = shift;
+    div {
+        { id is 'content' };
+        $self->instrument_content;
+        $self->render_jifty_page_detritus;
+    };
+    div { class is "clear" };
+    div {
+        { id is "footer"};
+        div { {id is "corner"} };
+    };
+}
+
+sub render_title_inhead {
+    my ($self, $title) = @_;
+    my @titles = (Jifty->config->framework('ApplicationName'), $title);
+    title { join " - ", grep { defined and length } reverse @titles };
+    return '';
+}
+
+sub render_title_inpage {
+    my $self  = shift;
+    my $title = shift;
+
+    if ( $title ) {
+        my $url = "/";
+        $url = "/project/".get('project')->name."/" if get('branch');
+        h1 { attr { class => 'header' }; hyperlink( url => $url, label => $title) };
+    }
+
+    Jifty->web->render_messages;
+
+    return '';
+}
+
+1;
diff --git a/lib/Smokingit/View/Project.pm b/lib/Smokingit/View/Project.pm
new file mode 100644
index 0000000..b7334f6
--- /dev/null
+++ b/lib/Smokingit/View/Project.pm
@@ -0,0 +1,170 @@
+use strict;
+use warnings;
+
+package Smokingit::View::Project;
+use Jifty::View::Declare -base;
+
+template '/project' => page {
+    redirect '/' unless get('project');
+    page_title is get('project')->name;
+    div {{class is "subtitle"} get('project')->repository_url };
+
+    render_region(
+        name => "branches",
+        path => "/fragments/project/branch-list",
+        defaults => { project_id => get('project')->id },
+    );
+
+    div {
+        id is "right-bar";
+        div {
+            id is "configuration-list";
+            h2 { "Configurations" };
+            my $configs = get('project')->configurations;
+            ul {
+                while (my $c = $configs->next) {
+                    li {
+                        hyperlink(
+                            label => $c->name,
+                            url => "config/" . $c->name,
+                        );
+                    };
+                }
+            };
+            hyperlink(
+                label => "New configuration",
+                url => "new-configuration",
+            );
+        };
+
+        div {
+            id is "recent-tests";
+            class is "commitlist";
+            h2 { "Recent tests" };
+            my $tests = Smokingit::Model::SmokeResultCollection->new;
+            $tests->limit(
+                column => "gearman_process",
+                operator => "IS",
+                value => "NULL"
+            );
+            $tests->limit( column => "project_id", value => get('project')->id );
+            $tests->order_by( { column => "submitted_at", order  => "desc" },
+                              { column => "id",           order  => "desc" } );
+            $tests->rows_per_page(10);
+            while (my $test = $tests->next) {
+                my ($status, $msg) = $test->commit->status($test->configuration);
+                div {
+                    class is "commit $status";
+                    hyperlink(
+                        class   => "okbox $status",
+                        label   => " ",
+                        escape_label => 0,
+                        url     => "/test/".$test->commit->sha."/".$test->configuration->name,
+                        tooltip => $msg,
+                    );
+                    hyperlink(
+                        tooltip => $msg,
+                        class   => "sha",
+                        url     => "/test/".$test->commit->sha."/".$test->configuration->name,
+                        label   => $test->commit->short_sha,
+                    );
+                    outs( $test->configuration->name );
+                }
+            }
+        };
+
+        my @planned = get('project')->planned_tests;
+        if (@planned) {
+            div {
+                id is "planned-tests";
+                class is "commitlist";
+                h2 { "Planned tests" };
+                for my $test (@planned) {
+                    my ($status, $msg) = $test->commit->status($test->configuration);
+                    div {
+                        class is "commit $status";
+                        span {
+                            attr { class => "okbox $status", title => $msg };
+                            outs_raw(" ")
+                        };
+                        span {
+                            attr { class => "sha", title => $msg };
+                            $test->commit->short_sha
+                        };
+                        outs( $test->configuration->name );
+                    }
+                }
+            }
+        }
+    };
+};
+
+template '/fragments/project/branch-list' => sub {
+    div {
+        id is "branch-list";
+        h2 { "Branches" };
+        my $branches = Smokingit::Model::BranchCollection->new;
+        $branches->limit( column => "project_id", value => get('project_id') );
+        unless ($branches->count) {
+            hyperlink(
+                class => "no-branches",
+                label => "Repository is still loading...",
+                onclick => { refresh_self => 1 },
+            );
+            return;
+        }
+
+        $branches->limit( column => "status", value => "master" );
+        $branches->order_by( column => "name" );
+        ul {
+            while (my $b = $branches->next) {
+                li {
+                    hyperlink(
+                        label => $b->name,
+                        url => "branch/" . $b->name,
+                    );
+                    my $sub = Smokingit::Model::BranchCollection->new;
+                    $sub->limit( column => "project_id", value => get('project_id') );
+                    $sub->limit( column => "status", operator => "!=", value => "ignore", entry_aggregator => "AND");
+                    $sub->limit( column => "status", operator => "!=", value => "master", entry_aggregator => "AND");
+                    $sub->limit( column => "to_merge_into", value => $b->id );
+                    branchlist($sub);
+                };
+            }
+        };
+
+        $branches->unlimit;
+        $branches->limit( column => "project_id", value => get('project_id') );
+        $branches->limit( column => "to_merge_into", operator => "IS", value => "NULL" );
+        $branches->limit( column => "status", operator => "!=", value => "ignore", entry_aggregator => "AND" );
+        $branches->limit( column => "status", operator => "!=", value => "master", entry_aggregator => "AND" );
+        branchlist($branches, 1);
+
+        $branches->unlimit;
+        $branches->limit( column => "project_id", value => get('project_id') );
+        $branches->limit( column => "status", operator => "=", value => "ignore" );
+        branchlist($branches, 1);
+    };
+};
+
+sub branchlist {
+    my ($branches, $hline) = @_;
+    $branches->order_by( column => "name" );
+    if ($branches->count) {
+        if ($hline) {
+            div { { class is "hline" } }
+        }
+        ul {
+            while (my $b = $branches->next) {
+                li {
+                    hyperlink(
+                        label => $b->name,
+                        url => "branch/" . $b->name,
+                    );
+                }
+            }
+        }
+    }
+}
+
+1;
diff --git a/share/smokingit-logo.xcf b/share/smokingit-logo.xcf
new file mode 100644
index 0000000..1b1572c
Binary files /dev/null and b/share/smokingit-logo.xcf differ
diff --git a/share/web/static/css/app-late.css b/share/web/static/css/app-late.css
new file mode 100644
index 0000000..f9de841
--- /dev/null
+++ b/share/web/static/css/app-late.css
@@ -0,0 +1,308 @@
+html {
+  padding: .75em;
+  background: #223;
+  font-size: 87.5%;
+  font-family: "Bitstream Vera Sans", Sans, sans-serif;
+}
+
+body {
+  z-index: 0;
+  position: relative;
+  margin: 0 auto;
+  padding: 1em;
+  -moz-border-radius: 1em;
+  -webkit-border-radius: 1em;
+  border-radius: 1em;
+  border: 1px solid #779;
+  background-color: #ddd;
+  color: #111;
+  max-width: 60em;
+  min-height: 150px;
+  background-image: url('/static/images/smokingit-logo-small.png');
+  background-repeat: no-repeat;
+  background-position: top right;
+}
+
+p, li, blockquote {
+  line-height: 1.3em;
+}
+
+h1.header {
+  border-bottom: 1px dotted #779;
+  position: relative;
+  top: -0.3em;
+  margin: 0;
+  padding: 0;
+  margin-left: -0.5em;
+  margin-right: 1em;
+  padding-left: 1.5em;
+  text-align: center;
+}
+h1.header a {
+  text-decoration: none;
+  font-weight: bold;
+}
+
+.subtitle {
+  text-align: center;
+}
+
+.clear {
+  clear: both;
+}
+
+h2 {
+  display: block;
+  background: #fff;
+  width: 10em;
+  position: relative;
+  left: -1.2em;
+  padding: 0.2em 0.5em;
+  margin-bottom: 0.1em;
+  text-align: center;
+}
+
+h2 + p, h2 + ul {
+  margin-top: 0;
+  padding-top: 0;
+}
+
+a, a:link, a:visited {
+  color: #111;
+  text-decoration: underline;
+  font-weight: normal;
+}
+
+a:hover {
+  color: #000;
+  text-decoration: none;
+  font-weight: normal;
+}
+
+dl { padding: 0; margin: 0;}
+
+ul {
+  margin: 0;
+  padding: 0;
+  margin-left: 1.5em;
+  padding-bottom: 0.5em;
+}
+
+li {
+  margin: 0;
+  padding: 0;
+}
+
+
+
+/* View of a project */
+#branch-list {
+  margin-top: 1em;
+  border-right: 1px dotted #779;
+  position: relative;
+  width: 50%;
+  float: left;
+}
+#branch-list h2 { margin-top: 0 }
+#branch-list .no-branches {
+    display: block;
+    padding-top: 1em;
+    font-style: italic;
+    text-decoration: none;
+}
+#right-bar {
+  margin-top: 1em;
+  border-left: 1px dotted #779;
+  width: 48%;
+  float: left;
+  padding-left: 1em;
+  margin-left: -1px;
+}
+#configuration-list h2 { margin-top: 0 }
+
+/* View of a branch */
+#branch-properties {
+  padding-top: 1em;
+  width: 60%;
+  float: left;
+}
+
+#branch-properties th {
+  font-weight: bold;
+  vertical-align: top;
+  text-align: right;
+  width: 7em;
+  padding-right: 0.5em;
+  padding-bottom: 0.75em;
+}
+
+#branch-properties td {
+  vertical-align: top;
+  padding-bottom: 0.75em;
+}
+
+#long-status {
+  padding-top: 1.3em;
+  width: 35%;
+  float: left;
+  height: 6.5em;
+}
+#long-status.master {
+  height: auto;
+}
+
+/* Update tinkering to make things line up */
+#branch-properties td .form_field .hints{
+  display: none;
+}
+#branch-properties td .form_field, #long-status .form_field {
+  padding-top: 0;
+  padding-bottom: 0;
+  margin-top: -0.3em;
+  margin-left: -0.3em;
+}
+#branch-properties td .form_field input,
+#branch-properties td .form_field select,
+#long-status textarea {
+  font-family: "Bitstream Vera Sans", Sans, sans-serif;
+  font-size: 100%;
+}
+#branch-properties td .form_field input[type=text] {
+  margin-bottom: -0.3em;
+  margin-left: -0.25em;
+  padding-left: 0.4em;
+  padding-bottom: 0;
+  width: 25em;
+}
+#branch-properties td .form_field select {
+  margin-top: 0;
+  margin-left: -0.25em;
+  margin-bottom: -0.3em;
+}
+#long-status textarea {
+  width: 98%;
+  margin-left: 0.1em;
+  margin-top: 0.1em;
+}
+
+/* Branch save, cancel links */
+#branch-buttons {
+  clear: left;
+  float: right;
+  position: relative;
+}
+#branch-buttons .submit_button {
+  display: inline;
+  margin: 0;
+}
+#branch-buttons .submit_button input {
+  margin: 0;
+}
+#branch-buttons .branch-cancel-button {
+  position: relative;
+  left: -5em;
+}
+#branch-buttons .branch-save-button {
+  position: relative;
+  left: 6em;
+}
+
+/* Property hiding for branch status */
+#branch-properties.master tr.owner,
+#branch-properties.master tr.to_merge_into,
+#branch-properties.master tr.review_by {
+  display: none;
+}
+
+#branch-properties.hacking     tr.review_by,
+#branch-properties.needs-tests tr.review_by,
+#branch-properties.ignore      tr.review_by {
+  display: none;
+}
+
+.complete-hline {
+  width: 100%;
+  margin-top: 0.5em;
+  margin-bottom: 0.5em;
+  border-bottom: 1px dotted #779;
+  margin-left: -1em;
+  padding-right: 2em;
+}
+
+.hline {
+  clear: left;
+  width: 60%;
+  margin-left: auto;
+  margin-right: auto;
+  margin-top: 0.5em;
+  margin-bottom: 0.5em;
+  border-bottom: 1px dotted #779;
+}
+
+#branch-title {
+  width: 50%;
+  border-bottom: 1px dotted #779;
+}
+
+.commitlist .sha {
+  font-family: monospace;
+  font-weight: bold;
+  text-decoration: none;
+  padding-right: 0.5em;
+}
+.commitlist.biglist .sha {
+  font-size: 150%;
+}
+
+.commitlist .commit { margin-top: .2em; }
+.commitlist .commit.passing  .sha { color: #1c1; }
+.commitlist .commit.failing  .sha { color: #f11; }
+.commitlist .commit.errors   .sha { color: #f11; }
+.commitlist .commit.testing  .sha { color: #11f; }
+.commitlist .commit.queued   .sha { color: #115; }
+.commitlist .commit.untested .sha { color: #555; }
+
+.commitlist .okbox {
+  float: left;
+  width: 32px;
+  background-repeat: no-repeat;
+  background-position: center bottom;
+  text-decoration: none;
+  margin: 0;
+  padding: 0;
+}
+.commitlist .okbox.passing { background-image: url('/static/images/silk/tick.png');      }
+.commitlist .okbox.failing { background-image: url('/static/images/silk/cross.png');     }
+.commitlist .okbox.errors  { background-image: url('/static/images/silk/error.png');     }
+.commitlist .okbox.testing { background-image: url('/static/images/silk/cog.png');       }
+.commitlist .okbox.queued  { background-image: url('/static/images/silk/hourglass.png'); }
+
+.commitlist .branchpoint {
+  margin-top: 0.5em;
+  margin-bottom: 0.5em;
+  width: 50%;
+  border-bottom: 1px dotted #779;
+}
+
+/* Footer and escaping smoke in the corner */
+#footer {
+  position: absolute;
+  bottom: 0;
+  z-index: -1;
+}
+
+#content {
+  z-index: 1;
+}
+
+#corner {
+  position: relative;
+  left: -150px;
+  top: 150px;
+  width: 300px;
+  height: 300px;
+  margin-left: -1em;
+  background-image: url('/static/images/escaping-smoke.png');
+  background-repeat: no-repeat;
+  background-position: bottom left;
+}
diff --git a/share/web/static/images/escaping-smoke.png b/share/web/static/images/escaping-smoke.png
new file mode 100644
index 0000000..f88c914
Binary files /dev/null and b/share/web/static/images/escaping-smoke.png differ
diff --git a/share/web/static/images/silk/cog.png b/share/web/static/images/silk/cog.png
new file mode 100644
index 0000000..67de2c6
Binary files /dev/null and b/share/web/static/images/silk/cog.png differ
diff --git a/share/web/static/images/silk/cross.png b/share/web/static/images/silk/cross.png
new file mode 100644
index 0000000..1514d51
Binary files /dev/null and b/share/web/static/images/silk/cross.png differ
diff --git a/share/web/static/images/silk/error.png b/share/web/static/images/silk/error.png
new file mode 100644
index 0000000..628cf2d
Binary files /dev/null and b/share/web/static/images/silk/error.png differ
diff --git a/share/web/static/images/silk/hourglass.png b/share/web/static/images/silk/hourglass.png
new file mode 100644
index 0000000..57b03ce
Binary files /dev/null and b/share/web/static/images/silk/hourglass.png differ
diff --git a/share/web/static/images/silk/tick.png b/share/web/static/images/silk/tick.png
new file mode 100644
index 0000000..a9925a0
Binary files /dev/null and b/share/web/static/images/silk/tick.png differ
diff --git a/share/web/static/images/smokingit-logo-small.png b/share/web/static/images/smokingit-logo-small.png
new file mode 100644
index 0000000..70dc5cd
Binary files /dev/null and b/share/web/static/images/smokingit-logo-small.png differ
diff --git a/share/web/static/js/app.js b/share/web/static/js/app.js
new file mode 100644
index 0000000..f400e2a
--- /dev/null
+++ b/share/web/static/js/app.js
@@ -0,0 +1,3 @@
+function narrow(elem) {
+    jQuery("#branch-properties").attr('class',elem.value);
+}

commit 598c62e365f45b85b606fb3f689106813ece6038
Author: Alex Vandiver <alexmv at bestpractical.com>
Date:   Wed Jan 26 02:50:07 2011 -0500

    Only show recent tests if there have been any run

diff --git a/lib/Smokingit/View/Project.pm b/lib/Smokingit/View/Project.pm
index b7334f6..91d85a2 100644
--- a/lib/Smokingit/View/Project.pm
+++ b/lib/Smokingit/View/Project.pm
@@ -37,41 +37,43 @@ template '/project' => page {
             );
         };
 
-        div {
-            id is "recent-tests";
-            class is "commitlist";
-            h2 { "Recent tests" };
-            my $tests = Smokingit::Model::SmokeResultCollection->new;
-            $tests->limit(
-                column => "gearman_process",
-                operator => "IS",
-                value => "NULL"
-            );
-            $tests->limit( column => "project_id", value => get('project')->id );
-            $tests->order_by( { column => "submitted_at", order  => "desc" },
-                              { column => "id",           order  => "desc" } );
-            $tests->rows_per_page(10);
-            while (my $test = $tests->next) {
-                my ($status, $msg) = $test->commit->status($test->configuration);
-                div {
-                    class is "commit $status";
-                    hyperlink(
-                        class   => "okbox $status",
-                        label   => " ",
-                        escape_label => 0,
-                        url     => "/test/".$test->commit->sha."/".$test->configuration->name,
-                        tooltip => $msg,
-                    );
-                    hyperlink(
-                        tooltip => $msg,
-                        class   => "sha",
-                        url     => "/test/".$test->commit->sha."/".$test->configuration->name,
-                        label   => $test->commit->short_sha,
-                    );
-                    outs( $test->configuration->name );
+        my $tests = Smokingit::Model::SmokeResultCollection->new;
+        $tests->limit(
+            column => "gearman_process",
+            operator => "IS",
+            value => "NULL"
+        );
+        $tests->limit( column => "project_id", value => get('project')->id );
+        $tests->order_by( { column => "submitted_at", order  => "desc" },
+                          { column => "id",           order  => "desc" } );
+        $tests->rows_per_page(10);
+        if ($tests->count) {
+            div {
+                id is "recent-tests";
+                class is "commitlist";
+                h2 { "Recent tests" };
+                while (my $test = $tests->next) {
+                    my ($status, $msg) = $test->commit->status($test->configuration);
+                    div {
+                        class is "commit $status";
+                        hyperlink(
+                            class   => "okbox $status",
+                            label   => " ",
+                            escape_label => 0,
+                            url     => "/test/".$test->commit->sha."/".$test->configuration->name,
+                            tooltip => $msg,
+                        );
+                        hyperlink(
+                            tooltip => $msg,
+                            class   => "sha",
+                            url     => "/test/".$test->commit->sha."/".$test->configuration->name,
+                            label   => $test->commit->short_sha,
+                        );
+                        outs( $test->configuration->name );
+                    }
                 }
-            }
-        };
+            };
+        }
 
         my @planned = get('project')->planned_tests;
         if (@planned) {

commit 368e3b928decf7d1b3b620ba657df75a80fbf14c
Author: Alex Vandiver <alexmv at bestpractical.com>
Date:   Wed Jan 26 02:50:52 2011 -0500

    Configurations now have all of these fields; they are no longer split on the project

diff --git a/lib/Smokingit/View/Configuration.pm b/lib/Smokingit/View/Configuration.pm
index ef09f6d..f88e4fd 100644
--- a/lib/Smokingit/View/Configuration.pm
+++ b/lib/Smokingit/View/Configuration.pm
@@ -10,20 +10,14 @@ template '/config' => page {
     page_title is $c->name;
     div {{class is "subtitle"} $c->project->name };
     form {
-        my $config = new_action(
+        my $update = new_action(
             class => "UpdateConfiguration",
             moniker => "update-configuration",
             record => $c,
         );
-        my $update = new_action(
-            class => "UpdateProject",
-            moniker => "update-project",
-            record => get('project'),
-        );
-        render_param( $config => "name" );
-        render_param( $config => "configure_cmd" );
-        render_param( $config => "env" );
-
+        render_param( $update => "name" );
+        render_param( $update => "configure_cmd" );
+        render_param( $update => "env" );
         render_param( $update => "test_glob" );
         render_param( $update => "parallel" );
         form_submit( label => _("Update"), url => "/project/". $c->project->name . "/");

commit e109bae9b8d58e40baada40a9362248b5998e648
Author: Alex Vandiver <alexmv at bestpractical.com>
Date:   Wed Jan 26 02:52:50 2011 -0500

    By looking back n+1, we keep "didn't find branchpoint" from looking like "just barely found it"

diff --git a/lib/Smokingit/View/Branch.pm b/lib/Smokingit/View/Branch.pm
index ae40fd5..de99e7c 100644
--- a/lib/Smokingit/View/Branch.pm
+++ b/lib/Smokingit/View/Branch.pm
@@ -38,7 +38,7 @@ template '/branch' => page {
 
     my $project_id = $b->project->id;
     my @commits = $b->commit_list;
-    my $branchpoint = $b->branchpoint(scalar @commits);
+    my $branchpoint = $b->branchpoint(@commits+1);
     div {
         id is "branch-commits";
         class is "commitlist biglist";

commit 560aebfeefd68445957ca1d61ca3cef3a3c19731
Author: Alex Vandiver <alexmv at bestpractical.com>
Date:   Wed Jan 26 02:53:47 2011 -0500

    Add a generic helper project->sha() function which returns the relevant object

diff --git a/lib/Smokingit/Model/Branch.pm b/lib/Smokingit/Model/Branch.pm
index 0520438..c8c0d55 100644
--- a/lib/Smokingit/Model/Branch.pm
+++ b/lib/Smokingit/Model/Branch.pm
@@ -98,13 +98,7 @@ sub commit_list {
     my @revs = map {chomp; $_} `git rev-list ^$first $last`;
     push @revs, map {chomp; $_} `git rev-list $first --max-count=11`;
 
-    my @commits;
-    for my $sha (@revs) {
-        my $c = Smokingit::Model::Commit->new;
-        $c->load_or_create( project_id => $self->project->id, sha => $sha );
-        push @commits, $c;
-    }
-    return @commits;
+    return map {$self->project->sha($_)} @revs;
 }
 
 sub branchpoint {
@@ -120,10 +114,8 @@ sub branchpoint {
     my @branch = map {chomp; $_} `git rev-list $tip ^$trunk --max-count=$max`;
     return unless @branch;
 
-    my $commit = Smokingit::Model::Commit->new;
-    $commit->load_by_cols( sha => $branch[-1] );
-    return undef unless $commit->id;
-    return $commit;
+    my $commit = $self->project->sha( $branch[-1] );
+    return $commit->id ? $commit : undef;
 }
 
 1;
diff --git a/lib/Smokingit/Model/Commit.pm b/lib/Smokingit/Model/Commit.pm
index 6da6f50..45c51af 100644
--- a/lib/Smokingit/Model/Commit.pm
+++ b/lib/Smokingit/Model/Commit.pm
@@ -155,13 +155,7 @@ sub smoked {
 
 sub parents {
     my $self = shift;
-    my @parents;
-    for my $sha (split ' ', $self->_value('parents')) {
-        my $c = Smokingit::Model::Commit->new;
-        $c->load_or_create( sha => $sha, project_id => $self->project->id );
-        push @parents, $c;
-    }
-    return @parents;
+    return map {$self->project->sha($_)} split ' ', $self->_value('parents');
 }
 
 1;
diff --git a/lib/Smokingit/Model/Project.pm b/lib/Smokingit/Model/Project.pm
index 26dc0c2..f343205 100644
--- a/lib/Smokingit/Model/Project.pm
+++ b/lib/Smokingit/Model/Project.pm
@@ -55,6 +55,15 @@ sub repository {
     );
 }
 
+sub sha {
+    my $self = shift;
+    my $sha = shift;
+    local $ENV{GIT_DIR} = $self->repository_path;
+    my $commit = Smokingit::Model::Commit->new;
+    $commit->load_or_create( project_id => $self->id, sha => $sha );
+    return $commit;
+}
+
 sub configurations {
     my $self = shift;
     my $configs = Smokingit::Model::ConfigurationCollection->new;
@@ -134,10 +143,9 @@ sub sync_branches {
         next if $new_ref eq $old_ref;
 
         warn "Update @{[$branch->name]} $old_ref -> $new_ref\n";
-        my @revs = map{chomp; $_} `git rev-list ^$old_ref $new_ref`;
-        my $commit = Smokingit::Model::Commit->new;
-        $commit->load_or_create( project_id => $self->id, sha => $_ ) for reverse @revs;
-        $branch->set_current_commit_id($commit->id);
+        my @revs = map {chomp; $_} `git rev-list ^$old_ref $new_ref`;
+        $self->sha( $_ ) for reverse @revs;
+        $branch->set_current_commit_id($self->sha($new_ref)->id);
     }
 
     for my $name (keys %branches) {

commit 2f46a0f91e06c2590210f017c996629e386ead8f
Author: Alex Vandiver <alexmv at bestpractical.com>
Date:   Wed Jan 26 02:54:54 2011 -0500

    Catch and report errors in gearman test submission

diff --git a/lib/Smokingit/Model/SmokeResult.pm b/lib/Smokingit/Model/SmokeResult.pm
index b548e91..eb379dd 100644
--- a/lib/Smokingit/Model/SmokeResult.pm
+++ b/lib/Smokingit/Model/SmokeResult.pm
@@ -65,11 +65,20 @@ sub gearman_status {
 sub run_smoke {
     my $self = shift;
 
+    if ($self->gearman_status->known) {
+        warn join( ":",
+              $self->project->name,
+              $self->configuration->name,
+              $self->commit->short_sha
+          )." is already in the queue\n";
+        return 0;
+    }
+
     warn "Smoking ".
         join( ":",
               $self->project->name,
               $self->configuration->name,
-              $self->commit->sha
+              $self->commit->short_sha
           )."\n";
 
     my $job_id = Smokingit->gearman->dispatch_background(
@@ -86,7 +95,12 @@ sub run_smoke {
             test_glob      => $self->configuration->test_glob,
         } ),
     );
+    unless ($job_id) {
+        warn "Unable to insert run_tests job!\n";
+        return 0;
+    }
     $self->set_gearman_process($job_id);
+    return 1;
 }
 
 1;

commit ad0e2935ca6d09ce1e6a7b7e5a7220919b8a3460
Author: Alex Vandiver <alexmv at bestpractical.com>
Date:   Wed Jan 26 02:55:29 2011 -0500

    To be on the safe side, merge outstanding test requests for the same smokerequest id

diff --git a/lib/Smokingit/Model/SmokeResult.pm b/lib/Smokingit/Model/SmokeResult.pm
index eb379dd..ace6927 100644
--- a/lib/Smokingit/Model/SmokeResult.pm
+++ b/lib/Smokingit/Model/SmokeResult.pm
@@ -94,6 +94,7 @@ sub run_smoke {
             parallel       => ($self->configuration->parallel ? 1 : 0),
             test_glob      => $self->configuration->test_glob,
         } ),
+        { uniq => $self->id },
     );
     unless ($job_id) {
         warn "Unable to insert run_tests job!\n";

commit 4128e8fda69dfa9aa4b1c2afe1187a32f16502f5
Author: Alex Vandiver <alexmv at bestpractical.com>
Date:   Wed Jan 26 03:08:56 2011 -0500

    Promote "todo unexpectedly succeeded" to its own icon, but below "tests failed"

diff --git a/lib/Smokingit/Model/Commit.pm b/lib/Smokingit/Model/Commit.pm
index 45c51af..d410b92 100644
--- a/lib/Smokingit/Model/Commit.pm
+++ b/lib/Smokingit/Model/Commit.pm
@@ -116,8 +116,6 @@ sub status {
             return ("errors", $result->short_error);
         } elsif ($result->is_ok) {
             return ("passing", $result->passed . " OK")
-        } elsif ($result->todo_passed) {
-            return ("failing", $result->todo_passed . " TODO passed");
         } elsif ($result->failed) {
             return ("failing", $result->failed . " failed");
         } elsif ($result->parse_errors) {
@@ -126,6 +124,8 @@ sub status {
             return ("failing", "Bad exit status (".$result->exit.")");
         } elsif ($result->wait) {
             return ("failing", "Bad wait status (".$result->wait.")");
+        } elsif ($result->todo_passed) {
+            return ("todo", $result->todo_passed . " TODO passed");
         } else {
             return ("failing", "Unknown failure");
         }
diff --git a/share/web/static/css/app-late.css b/share/web/static/css/app-late.css
index f9de841..e05e98f 100644
--- a/share/web/static/css/app-late.css
+++ b/share/web/static/css/app-late.css
@@ -257,6 +257,7 @@ li {
 .commitlist .commit { margin-top: .2em; }
 .commitlist .commit.passing  .sha { color: #1c1; }
 .commitlist .commit.failing  .sha { color: #f11; }
+.commitlist .commit.todo     .sha { color: #fa0; }
 .commitlist .commit.errors   .sha { color: #f11; }
 .commitlist .commit.testing  .sha { color: #11f; }
 .commitlist .commit.queued   .sha { color: #115; }
@@ -271,11 +272,12 @@ li {
   margin: 0;
   padding: 0;
 }
-.commitlist .okbox.passing { background-image: url('/static/images/silk/tick.png');      }
-.commitlist .okbox.failing { background-image: url('/static/images/silk/cross.png');     }
-.commitlist .okbox.errors  { background-image: url('/static/images/silk/error.png');     }
-.commitlist .okbox.testing { background-image: url('/static/images/silk/cog.png');       }
-.commitlist .okbox.queued  { background-image: url('/static/images/silk/hourglass.png'); }
+.commitlist .okbox.passing { background-image: url('/static/images/silk/tick.png');            }
+.commitlist .okbox.todo    { background-image: url('/static/images/silk/asterisk_orange.png'); }
+.commitlist .okbox.failing { background-image: url('/static/images/silk/cross.png');           }
+.commitlist .okbox.errors  { background-image: url('/static/images/silk/error.png');           }
+.commitlist .okbox.testing { background-image: url('/static/images/silk/cog.png');             }
+.commitlist .okbox.queued  { background-image: url('/static/images/silk/hourglass.png');       }
 
 .commitlist .branchpoint {
   margin-top: 0.5em;
diff --git a/share/web/static/images/silk/asterisk_orange.png b/share/web/static/images/silk/asterisk_orange.png
new file mode 100644
index 0000000..1ebebde
Binary files /dev/null and b/share/web/static/images/silk/asterisk_orange.png differ

commit 3e29198418f2d678ff2dc372e736fa836cacd2a0
Author: Alex Vandiver <alexmv at bestpractical.com>
Date:   Wed Jan 26 03:23:50 2011 -0500

    Detect when a smoke has gone missing, and allow us to re-start it

diff --git a/bin/local_updates b/bin/local_updates
index cf65dac..ad1697e 100755
--- a/bin/local_updates
+++ b/bin/local_updates
@@ -16,6 +16,23 @@ my $worker = Gearman::Worker->new(
     job_servers => Jifty->config->app('job_servers'),
 );
 $worker->register_function(
+    check_queue => sub {
+        warn "In check_queue";
+        my $job = shift;
+        my $queued = Smokingit::Model::SmokeResultCollection->new;
+        $queued->limit(
+            column => "submitted_at",
+            operator => "IS",
+            value => "NULL",
+        );
+        my $restarted = 0;
+        while (my $smoke = $queued->next) {
+            $restarted += $smoke->run_smoke unless $smoke->gearman_status->known;
+        }
+        return $restarted;
+    },
+);
+$worker->register_function(
     post_results => sub {
         my $job = shift;
         my %result = %{ thaw( $job->arg ) };
diff --git a/lib/Smokingit/Model/Commit.pm b/lib/Smokingit/Model/Commit.pm
index d410b92..0264604 100644
--- a/lib/Smokingit/Model/Commit.pm
+++ b/lib/Smokingit/Model/Commit.pm
@@ -104,7 +104,9 @@ sub status {
             return ("untested", "");
         } elsif ($result->gearman_process) {
             my $status = $result->gearman_status;
-            if ($status->running) {
+            if (not $status->known) {
+                return ("broken", "Unknown");
+            } elsif ($status->running) {
                 my $msg = defined $status->percent
                     ? int($status->percent*100)."% complete"
                         : "Configuring";
diff --git a/lib/Smokingit/Model/Project.pm b/lib/Smokingit/Model/Project.pm
index f343205..788d602 100644
--- a/lib/Smokingit/Model/Project.pm
+++ b/lib/Smokingit/Model/Project.pm
@@ -106,7 +106,8 @@ sub planned_tests {
     );
     $tests->limit( column => "project_id", value => $self->id );
     my @tests = @{ $tests->items_array_ref };
-    @tests = sort { $b->gearman_status->running     <=>  $a->gearman_status->running
+    @tests = sort { $a->gearman_status->known       <=>  $b->gearman_status->known
+                or  $b->gearman_status->running     <=>  $a->gearman_status->running
                 or ($b->gearman_status->percent||0) <=> ($a->gearman_status->percent||0)
                 or  $a->id                          <=>  $b->id} @tests;
     return @tests;
diff --git a/lib/Smokingit/Model/SmokeResult.pm b/lib/Smokingit/Model/SmokeResult.pm
index ace6927..04eb0aa 100644
--- a/lib/Smokingit/Model/SmokeResult.pm
+++ b/lib/Smokingit/Model/SmokeResult.pm
@@ -56,10 +56,12 @@ sub short_error {
     return $msg;
 }
 
+use Gearman::JobStatus;
 sub gearman_status {
     my $self = shift;
-    return undef unless $self->gearman_process;
-    return $self->{job_status} ||= Smokingit->gearman->get_status($self->gearman_process);
+    return Gearman::JobStatus->new(0,0) unless $self->gearman_process;
+    return $self->{job_status} ||= Smokingit->gearman->get_status($self->gearman_process)
+        || Gearman::JobStatus->new(0,0);
 }
 
 sub run_smoke {
diff --git a/share/web/static/css/app-late.css b/share/web/static/css/app-late.css
index e05e98f..3c95357 100644
--- a/share/web/static/css/app-late.css
+++ b/share/web/static/css/app-late.css
@@ -261,6 +261,7 @@ li {
 .commitlist .commit.errors   .sha { color: #f11; }
 .commitlist .commit.testing  .sha { color: #11f; }
 .commitlist .commit.queued   .sha { color: #115; }
+.commitlist .commit.broken   .sha { color: #f11; }
 .commitlist .commit.untested .sha { color: #555; }
 
 .commitlist .okbox {
@@ -278,6 +279,7 @@ li {
 .commitlist .okbox.errors  { background-image: url('/static/images/silk/error.png');           }
 .commitlist .okbox.testing { background-image: url('/static/images/silk/cog.png');             }
 .commitlist .okbox.queued  { background-image: url('/static/images/silk/hourglass.png');       }
+.commitlist .okbox.broken  { background-image: url('/static/images/silk/exclamation.png');     }
 
 .commitlist .branchpoint {
   margin-top: 0.5em;
diff --git a/share/web/static/images/silk/exclamation.png b/share/web/static/images/silk/exclamation.png
new file mode 100644
index 0000000..c37bd06
Binary files /dev/null and b/share/web/static/images/silk/exclamation.png differ

commit 1c3d6998ad3c4637c1026394aac6b586617571a1
Author: Alex Vandiver <alexmv at bestpractical.com>
Date:   Wed Jan 26 03:10:27 2011 -0500

    Remove irrelevant display of "merge into" and "review by" on releng branches

diff --git a/lib/Smokingit/View/Branch.pm b/lib/Smokingit/View/Branch.pm
index de99e7c..caf2656 100644
--- a/lib/Smokingit/View/Branch.pm
+++ b/lib/Smokingit/View/Branch.pm
@@ -111,6 +111,9 @@ template '/fragments/branch/properties' => sub {
                 th { "Owner" };
                 cell { $b->owner };
             };
+        }
+
+        if ($b->status ne "master" and $b->status ne "releng") {
             row {
                 th { "Merge into" };
                 cell { $b->to_merge_into->id ? $b->to_merge_into->name : "None" };
diff --git a/share/web/static/css/app-late.css b/share/web/static/css/app-late.css
index 3c95357..dca0daa 100644
--- a/share/web/static/css/app-late.css
+++ b/share/web/static/css/app-late.css
@@ -214,6 +214,11 @@ li {
   display: none;
 }
 
+#branch-properties.releng tr.to_merge_into,
+#branch-properties.releng tr.review_by {
+  display: none;
+}
+
 #branch-properties.hacking     tr.review_by,
 #branch-properties.needs-tests tr.review_by,
 #branch-properties.ignore      tr.review_by {

commit b25aab6b0caee66391b3727bcecfaf7dffd36a85
Author: Alex Vandiver <alexmv at bestpractical.com>
Date:   Wed Jan 26 03:00:10 2011 -0500

    Show %complete atop the gear, writ in small

diff --git a/lib/Smokingit/Model/Commit.pm b/lib/Smokingit/Model/Commit.pm
index 0264604..c5d6bf3 100644
--- a/lib/Smokingit/Model/Commit.pm
+++ b/lib/Smokingit/Model/Commit.pm
@@ -107,10 +107,12 @@ sub status {
             if (not $status->known) {
                 return ("broken", "Unknown");
             } elsif ($status->running) {
-                my $msg = defined $status->percent
-                    ? int($status->percent*100)."% complete"
+                my $percent = defined $status->percent
+                    ? int($status->percent*100)."%" : undef;
+                my $msg = defined $percent
+                    ? "$percent complete"
                         : "Configuring";
-                return ("testing", $msg);
+                return ("testing", $msg, $percent);
             } else {
                 return ("queued", "Queued to test");
             }
diff --git a/lib/Smokingit/View/Branch.pm b/lib/Smokingit/View/Branch.pm
index caf2656..c269969 100644
--- a/lib/Smokingit/View/Branch.pm
+++ b/lib/Smokingit/View/Branch.pm
@@ -46,11 +46,11 @@ template '/branch' => page {
             div {
                 {class is "commit ".$commit->status};
                 for my $config (@configs) {
-                    my ($status, $msg) = $commit->status($config);
+                    my ($status, $msg, $in) = $commit->status($config);
                     if ($status =~ /^(untested|testing|queued)$/) {
                         span {
                             attr { class => "okbox $status", title => $msg };
-                            outs_raw(" ")
+                            outs_raw($in ||" ")
                         };
                     } else {
                         hyperlink(
diff --git a/lib/Smokingit/View/Project.pm b/lib/Smokingit/View/Project.pm
index 91d85a2..e2ade2d 100644
--- a/lib/Smokingit/View/Project.pm
+++ b/lib/Smokingit/View/Project.pm
@@ -82,12 +82,12 @@ template '/project' => page {
                 class is "commitlist";
                 h2 { "Planned tests" };
                 for my $test (@planned) {
-                    my ($status, $msg) = $test->commit->status($test->configuration);
+                    my ($status, $msg, $in) = $test->commit->status($test->configuration);
                     div {
                         class is "commit $status";
                         span {
                             attr { class => "okbox $status", title => $msg };
-                            outs_raw(" ")
+                            outs_raw($in || " ")
                         };
                         span {
                             attr { class => "sha", title => $msg };
diff --git a/share/web/static/css/app-late.css b/share/web/static/css/app-late.css
index dca0daa..7e99a45 100644
--- a/share/web/static/css/app-late.css
+++ b/share/web/static/css/app-late.css
@@ -277,6 +277,9 @@ li {
   text-decoration: none;
   margin: 0;
   padding: 0;
+  font-size: 50%;
+  line-height: 16px;
+  text-align: center;
 }
 .commitlist .okbox.passing { background-image: url('/static/images/silk/tick.png');            }
 .commitlist .okbox.todo    { background-image: url('/static/images/silk/asterisk_orange.png'); }

commit f30ca6bfc48fda197ccd955e2329c9d6ac35ae92
Author: Alex Vandiver <alexmv at bestpractical.com>
Date:   Wed Jan 26 03:01:30 2011 -0500

    Slightly better formatting for multi-line commit summaries

diff --git a/share/web/static/css/app-late.css b/share/web/static/css/app-late.css
index 7e99a45..3975a2a 100644
--- a/share/web/static/css/app-late.css
+++ b/share/web/static/css/app-late.css
@@ -249,17 +249,23 @@ li {
   border-bottom: 1px dotted #779;
 }
 
-.commitlist .sha {
+/* Commit lists, as on branch and project pages */
+.commitlist .commit {
+  margin-top: .2em;
+  clear: both;
+}
+
+.commitlist .commit .sha {
   font-family: monospace;
   font-weight: bold;
   text-decoration: none;
   padding-right: 0.5em;
+  float: left;
 }
-.commitlist.biglist .sha {
-  font-size: 150%;
+.commitlist.biglist .commit .sha {
+  font-size: 145%;
 }
 
-.commitlist .commit { margin-top: .2em; }
 .commitlist .commit.passing  .sha { color: #1c1; }
 .commitlist .commit.failing  .sha { color: #f11; }
 .commitlist .commit.todo     .sha { color: #fa0; }
@@ -290,6 +296,7 @@ li {
 .commitlist .okbox.broken  { background-image: url('/static/images/silk/exclamation.png');     }
 
 .commitlist .branchpoint {
+  clear: both;
   margin-top: 0.5em;
   margin-bottom: 0.5em;
   width: 50%;

commit a361e610699d905b694cfd29e4a2b4651fa47e06
Author: Alex Vandiver <alexmv at bestpractical.com>
Date:   Wed Jan 26 03:03:26 2011 -0500

    Split github functionality out of dispatcher; detect new branches, better error handling

diff --git a/lib/Smokingit/Dispatcher.pm b/lib/Smokingit/Dispatcher.pm
index 34f8789..00ca812 100644
--- a/lib/Smokingit/Dispatcher.pm
+++ b/lib/Smokingit/Dispatcher.pm
@@ -78,22 +78,9 @@ on '' => run {
     redirect '/project/' . $project->name . "/";
 };
 
+
 # GitHub post-receive-hook support
-use Jifty::JSON qw/decode_json/;
 on '/github' => run {
-    show '/github/error' unless Jifty->web->request->method eq "POST";
-    show '/github/error' unless get('payload');
-    my $json = eval { decode_json(get('payload')) }
-        or show '/github/error';
-    my $name = $json->{repository}{name}
-        or show '/github/error';
-
-    my $project = Smokingit::Model::Project->new;
-    $project->load_by_cols( name => $name );
-    Smokingit->gearman->dispatch_background(
-        sync_project => $project->name,
-    ) if $project->id;
-
     show '/github';
 };
 
diff --git a/lib/Smokingit/View/GitHub.pm b/lib/Smokingit/View/GitHub.pm
index 480ee4d..8595f8d 100644
--- a/lib/Smokingit/View/GitHub.pm
+++ b/lib/Smokingit/View/GitHub.pm
@@ -4,13 +4,49 @@ use warnings;
 package Smokingit::View::GitHub;
 use Jifty::View::Declare -base;
 
+use Jifty::JSON qw/decode_json/;
 
 template '/github' => sub {
-    outs("OK");
-};
+    my $ret = eval {
+        die "Wrong method\n" unless Jifty->web->request->method eq "POST";
+        die "No payload\n"   unless get('payload');
+        my $json = eval { decode_json(get('payload')) }
+            or die "Bad JSON: $@\n";
+        warn YAML::Dump($json);
+
+        my $name = $json->{repository}{name}
+            or die "No repository name found\n";
+        my $project = Smokingit::Model::Project->new;
+        $project->load_by_cols( name => $name );
+        $project->id
+            or return "No such project: $name\n";
+
+        my $bname = $json->{ref}
+            or die "No branch ref found\n";
+        $bname =~ s{^refs/heads/}{}
+            or die "Branch $bname not under /ref/heads/\n";
+        my $branch = Smokingit::Model::Branch->new;
+        $branch->load_by_cols( project_id => $project->id, name => $bname );
+
+        if ($json->{before} ne "0"x40) {
+            $branch->id
+                or return "No such branch\n";
+            $branch->is_tested
+                or return "Branch is not currently tested\n";
+        }
 
-template '/github/error' => sub {
-    outs("Error!");
+        Smokingit->gearman->dispatch_background(
+            sync_project => $project->name,
+        );
+        return undef;
+    };
+    if ($@) {
+        outs "ERROR: $@"
+    } elsif ($ret) {
+        outs "Ignored: $ret";
+    } else {
+        outs "OK!\n";
+    }
 };
 
 1;

commit bd915e8fd9748b49b4051d15267dd858626c703a
Author: Alex Vandiver <alexmv at bestpractical.com>
Date:   Wed Jan 26 03:04:40 2011 -0500

    Minor dispatcher cleanup

diff --git a/lib/Smokingit/Dispatcher.pm b/lib/Smokingit/Dispatcher.pm
index 00ca812..e8833e0 100644
--- a/lib/Smokingit/Dispatcher.pm
+++ b/lib/Smokingit/Dispatcher.pm
@@ -4,6 +4,15 @@ use warnings;
 package Smokingit::Dispatcher;
 use Jifty::Dispatcher -base;
 
+# Auto redirect after create, to the project
+on '' => run {
+    my $res = Jifty->web->response->result('create-project');
+    return unless $res and $res->content('id');
+    my $project = Smokingit::Model::Project->new;
+    $project->load( $res->content('id') );
+    redirect '/project/' . $project->name . "/";
+};
+
 under '/project/*' => [
     run {
         my $project = Smokingit::Model::Project->new;
@@ -54,8 +63,8 @@ under '/project/*' => [
     },
 ];
 
+# Shortcut URLs, of /projectname/branchname
 on '/*/**' => run {
-    # Shortcut URLs, of /projectname/branchname
     my ($pname, $bname) = ($1, $2);
     my $project = Smokingit::Model::Project->new;
     $project->load_by_cols( name => $pname );
@@ -70,14 +79,6 @@ on '/*/**' => run {
     redirect '/project/' . $project->name . '/branch/' . $branch->name;
 };
 
-on '' => run {
-    my $res = Jifty->web->response->result('create-project');
-    return unless $res and $res->content('id');
-    my $project = Smokingit::Model::Project->new;
-    $project->load( $res->content('id') );
-    redirect '/project/' . $project->name . "/";
-};
-
 
 # GitHub post-receive-hook support
 on '/github' => run {

commit 71b752973dd6e0411001ce44c6b8a3b57c84bbb1
Author: Alex Vandiver <alexmv at bestpractical.com>
Date:   Wed Jan 26 03:14:05 2011 -0500

    Show prettier descriptions of branch status

diff --git a/lib/Smokingit/Model/Branch.pm b/lib/Smokingit/Model/Branch.pm
index c8c0d55..f52342c 100644
--- a/lib/Smokingit/Model/Branch.pm
+++ b/lib/Smokingit/Model/Branch.pm
@@ -25,7 +25,15 @@ use Smokingit::Record schema {
     column status =>
         type is 'text',
         is mandatory,
-        valid_values are qw(ignore hacking needs-tests needs-review awaiting-merge merged master releng);
+        valid_values are [
+            { value => "ignore",         display => "Ignore" },
+            { value => "hacking",        display => "Being worked on" },
+            { value => "needs-tests",    display => "Needs tests" },
+            { value => "needs-review",   display => "Needs review" },
+            { value => "awaiting-merge", display => "Needs merge" },
+            { value => "merged",         display => "Merged" },
+            { value => "master",         display => "Trunk branch" },
+            { value => "releng",         display => "Release branch" }];
 
     column long_status =>
         type is 'text',
@@ -76,6 +84,13 @@ sub create {
     return ($ok, $msg);
 }
 
+sub display_status {
+    my $self = shift;
+    my @options = @{$self->column("status")->valid_values};
+    my ($match) = grep {$_->{value} eq $self->status} @options;
+    return $match->{display};
+}
+
 sub long_status_html {
     my $self = shift;
     my $html = Jifty->web->escape($self->long_status);
diff --git a/lib/Smokingit/View/Branch.pm b/lib/Smokingit/View/Branch.pm
index c269969..8415354 100644
--- a/lib/Smokingit/View/Branch.pm
+++ b/lib/Smokingit/View/Branch.pm
@@ -96,7 +96,7 @@ template '/fragments/branch/properties' => sub {
         row {
             th { "Status" };
             cell {
-                span { {class is "status"} $b->status };
+                span { {class is "status"} $b->display_status };
                 if ($b->status ne "master") {
                     if (not $b->last_status_update->id
                             or $b->current_commit->id != $b->last_status_update->id) {

commit 54b33a0f48ddadcb0bb611b74f870bc3616388dc
Author: Alex Vandiver <alexmv at bestpractical.com>
Date:   Wed Jan 26 03:19:16 2011 -0500

    Sketches of test result pages; still need to determine how much rope Aggregators give us

diff --git a/lib/Smokingit/Dispatcher.pm b/lib/Smokingit/Dispatcher.pm
index e8833e0..95705dd 100644
--- a/lib/Smokingit/Dispatcher.pm
+++ b/lib/Smokingit/Dispatcher.pm
@@ -79,6 +79,58 @@ on '/*/**' => run {
     redirect '/project/' . $project->name . '/branch/' . $branch->name;
 };
 
+# Commits and test commits
+under '/test/*' => [
+    run {
+        my $sha = $1;
+        if (length($sha) == 40) {
+            my $commit = Smokingit::Model::Commit->new;
+            $commit->load_by_cols( sha => $sha );
+            show '/errors/404' unless $commit->id;
+            set( commit => $commit );
+        } else {
+            my $commits = Smokingit::Model::CommitCollection->new;
+            $commits->limit( column => 'sha', operator => 'like', value => "$sha%" );
+            show '/errors/404' unless $commits->count == 1;
+            set( commit => $commits->first );
+        }
+    },
+    on '' => run {
+        my $configs = Smokingit::Model::ConfigurationCollection->new;
+        $configs->limit( column => "project_id", value => get('commit')->project_id );
+        redirect '/test/' . get('commit')->sha . '/' . $configs->first->name
+            if $configs->count == 1;
+        show '/commit';
+    },
+    on '*' => run {
+        my $cname = $1;
+        my $config = Smokingit::Model::Configuration->new;
+        $config->load_by_cols(
+            project_id => get('commit')->project,
+            name => $cname,
+        );
+        show '/errors/404' unless $config->id;
+
+        my $result = Smokingit::Model::SmokeResult->new;
+        $result->load_by_cols(
+            project_id => get('commit')->project,
+            commit_id => get('commit')->id,
+            configuration_id => $config->id,
+        );
+        set( smoke => $result );
+        show '/smoke';
+    },
+];
+
+# Shortcut URLs, of /sha
+on '/*' => run {
+    my ($sha) = ($1);
+    my $commits = Smokingit::Model::CommitCollection->new;
+    $commits->limit( column => 'sha', operator => 'like', value => "$sha%" );
+    return unless $commits->count == 1;
+    redirect '/test/' . $commits->first->sha;
+};
+
 
 # GitHub post-receive-hook support
 on '/github' => run {
diff --git a/lib/Smokingit/View.pm b/lib/Smokingit/View.pm
index d46fad5..2946d21 100644
--- a/lib/Smokingit/View.pm
+++ b/lib/Smokingit/View.pm
@@ -13,9 +13,13 @@ alias Smokingit::View::Branch under '/';
 require Smokingit::View::Configuration;
 alias Smokingit::View::Configuration under '/';
 
+require Smokingit::View::Commit;
+alias Smokingit::View::Commit under '/';
+
 require Smokingit::View::GitHub;
 alias Smokingit::View::GitHub under '/';
 
+
 template '/index.html' => page {
     page_title is 'Projects';
     my $projects = Smokingit::Model::ProjectCollection->new;
diff --git a/lib/Smokingit/View/Commit.pm b/lib/Smokingit/View/Commit.pm
new file mode 100644
index 0000000..2ab3c23
--- /dev/null
+++ b/lib/Smokingit/View/Commit.pm
@@ -0,0 +1,31 @@
+use strict;
+use warnings;
+
+package Smokingit::View::Commit;
+use Jifty::View::Declare -base;
+
+template '/commit' => page {
+    redirect '/' unless get('commit');
+    page_title is get('commit')->short_sha;
+};
+
+template '/smoke' => page {
+    my $s = get('smoke');
+    redirect '/' unless $s;
+    class is (($s->is_ok ? "passing" : "failing")."test");
+    page_title is $s->commit->short_sha . ", ". $s->configuration->name;
+
+    if ($s->error) {
+        pre { $s->error };
+        return;
+    }
+
+    my $a = $s->aggregator;
+    pre {
+        YAML::Dump($a);
+    };
+
+};
+
+1;
+
diff --git a/lib/Smokingit/View/Page.pm b/lib/Smokingit/View/Page.pm
index 074c0cc..6ecc5fb 100644
--- a/lib/Smokingit/View/Page.pm
+++ b/lib/Smokingit/View/Page.pm
@@ -35,7 +35,11 @@ sub render_title_inpage {
     if ( $title ) {
         my $url = "/";
         $url = "/project/".get('project')->name."/" if get('branch');
-        h1 { attr { class => 'header' }; hyperlink( url => $url, label => $title) };
+        $url = "/project/".get('commit')->project->name."/" if get('commit');
+        h1 {
+            attr { class => 'header' };
+            hyperlink( url => $url, label => $title)
+        };
     }
 
     Jifty->web->render_messages;
diff --git a/share/web/static/css/app-late.css b/share/web/static/css/app-late.css
index 3975a2a..50c7f96 100644
--- a/share/web/static/css/app-late.css
+++ b/share/web/static/css/app-late.css
@@ -303,6 +303,16 @@ li {
   border-bottom: 1px dotted #779;
 }
 
+
+/* Commit pages */
+.failingtest .header {
+  border-bottom: 5px dotted #f11;
+}
+.passingtest .header {
+  border-bottom: 5px dotted #1c1;
+}
+
+
 /* Footer and escaping smoke in the corner */
 #footer {
   position: absolute;

commit 4c456d0f06b77fb3f3d7f60ac4b4edfb53ec6bd0
Author: Alex Vandiver <alexmv at bestpractical.com>
Date:   Wed Jan 26 03:22:21 2011 -0500

    Better diag in local worker

diff --git a/bin/local_updates b/bin/local_updates
index ad1697e..0ddb3d1 100755
--- a/bin/local_updates
+++ b/bin/local_updates
@@ -66,10 +66,10 @@ $worker->register_function(
         my $smoke = Smokingit::Model::SmokeResult->new;
         $smoke->load( delete $result{smoke_id} );
         if (not $smoke->id) {
-            warn "Invalid smoke ID\n";
+            warn "Invalid smoke ID: $result{smoke_id}\n";
             return 0;
         } elsif (not $smoke->gearman_process) {
-            warn "Smoke report on something that wasn't being smoked?\n";
+            warn "Smoke report on $result{smoke_id} which wasn't being smoked?\n";
             return 0;
         }
 
@@ -94,6 +94,11 @@ $worker->register_function(
         Smokingit->gearman->dispatch_background(
             sync_project => $project->name,
         );
+        warn "Test result for "
+            . $smoke->project->name
+            ." ". $smoke->commit->short_sha
+            ." using ". $smoke->configuration->name
+            .": ".($smoke->is_ok ? "OK" : "NOT OK")."\n";
 
         return 1;
     },
@@ -102,10 +107,14 @@ $worker->register_function(
     sync_project => sub {
         my $job = shift;
         my $project_name = $job->arg;
+        warn "Sync $project_name";
 
         my $project = Smokingit::Model::Project->new;
         $project->load_by_cols( name => $project_name );
-        return 0 unless $project->id;
+        unless ($project->id) {
+            warn "No such project: $project_name\n";
+            return 0;
+        }
 
         # Update or clone, as need be
         if (-d $project->repository_path) {

commit 322302be43351fcdc983a9342df284a44561267d
Author: Alex Vandiver <alexmv at bestpractical.com>
Date:   Wed Jan 26 03:24:55 2011 -0500

    Commit->smoked($config) gives you the relevent smoke result

diff --git a/lib/Smokingit/Model/Commit.pm b/lib/Smokingit/Model/Commit.pm
index c5d6bf3..ac809a2 100644
--- a/lib/Smokingit/Model/Commit.pm
+++ b/lib/Smokingit/Model/Commit.pm
@@ -151,10 +151,21 @@ sub status {
 
 sub smoked {
     my $self = shift;
-    my $smoked = Smokingit::Model::SmokeResultCollection->new;
-    $smoked->limit( column => "commit_id", value => $self->id );
-    $smoked->limit( column => "project_id", value => $self->project->id );
-    return $smoked;
+    my $config = shift;
+    if ($config) {
+        my $smoke = Smokingit::Model::SmokeResult->new;
+        $smoke->load_by_cols(
+            project_id => $self->project->id,
+            commit_id => $self->id,
+            configuration_id => $config->id,
+        );
+        return $smoke;
+    } else {
+        my $smoked = Smokingit::Model::SmokeResultCollection->new;
+        $smoked->limit( column => "commit_id", value => $self->id );
+        $smoked->limit( column => "project_id", value => $self->project->id );
+        return $smoked;
+    }
 }
 
 sub parents {

commit 44df53d9629e16aa932e5e97907d5eb8f1962cfa
Author: Alex Vandiver <alexmv at bestpractical.com>
Date:   Wed Jan 26 03:25:33 2011 -0500

    Widen repository url entry box

diff --git a/share/web/static/css/app-late.css b/share/web/static/css/app-late.css
index 50c7f96..7a4b797 100644
--- a/share/web/static/css/app-late.css
+++ b/share/web/static/css/app-late.css
@@ -94,6 +94,11 @@ li {
 }
 
 
+/* Front page */
+#create-project input.argument-repository_url {
+    width: 30em;
+}
+
 
 /* View of a project */
 #branch-list {

commit a37364627fab4e047ae94aa2191e9248be00a4a9
Author: Alex Vandiver <alexmv at bestpractical.com>
Date:   Wed Jan 26 03:32:52 2011 -0500

    Rework commit planning to be simpler and more robust
    
    Unlike Test::Chimps, from which some parts of this project are
    derived, the scheduler is able to store a set of future tests to plan.
    Taking advantage of this by simply adding to Gearman's queue every
    commit between the old ref and the new one, we can remove the need to
    "TestedHead"s, and much of the complicated Project->schedule_tests
    logic.

diff --git a/bin/local_updates b/bin/local_updates
index 0ddb3d1..0bf3dde 100755
--- a/bin/local_updates
+++ b/bin/local_updates
@@ -63,6 +63,7 @@ $worker->register_function(
         $result{submitted_at} = Jifty::DateTime->now;
 
         # Find the smoke
+        Jifty->handle->begin_transaction;
         my $smoke = Smokingit::Model::SmokeResult->new;
         $smoke->load( delete $result{smoke_id} );
         if (not $smoke->id) {
@@ -73,12 +74,6 @@ $worker->register_function(
             return 0;
         }
 
-        # Lock on the project
-        Jifty->handle->begin_transaction;
-        my $project = Smokingit::Model::Project->new;
-        $project->row_lock(1);
-        $project->load( $smoke->project->id );
-
         # Update with the new data
         for my $key (keys %result) {
             my $method = "set_$key";
@@ -127,14 +122,12 @@ $worker->register_function(
                    $project->repository_path);
         }
 
-        # Sync up the branches, but acquire a lock on the project first
+        # Sync up the branches
         Jifty->handle->begin_transaction;
-        $project->row_lock(1);
-        $project->load( $project->id );
-        $project->sync_branches;
+        my $tests = $project->sync_branches;
         Jifty->handle->commit;
 
-        return 1;
+        return $tests;
     },
 );
 
diff --git a/lib/Smokingit/Model/Branch.pm b/lib/Smokingit/Model/Branch.pm
index f52342c..4f5094f 100644
--- a/lib/Smokingit/Model/Branch.pm
+++ b/lib/Smokingit/Model/Branch.pm
@@ -19,6 +19,9 @@ use Smokingit::Record schema {
     column current_commit_id =>
         references Smokingit::Model::Commit;
 
+    column tested_commit_id =>
+        references Smokingit::Model::Commit;
+
     column last_status_update =>
         references Smokingit::Model::Commit;
 
@@ -57,33 +60,25 @@ sub create {
     );
 
     # Ensure that we have a tip commit
-    my $tip = Smokingit::Model::Commit->new;
-    $tip->load_or_create( project_id => $args{project_id}, sha => delete $args{sha} );
+    my $project = Smokingit::Model::Project->new;
+    $project->load( $args{project_id} );
+    my $tip = $project->sha( delete $args{sha} );
     $args{current_commit_id} = $tip->id;
-    $args{first_commit_id} = $tip->id;
+    $args{tested_commit_id}  = $tip->id;
+    $args{first_commit_id}   = $tip->id;
     $args{owner} = $tip->committer;
 
     my ($ok, $msg) = $self->SUPER::create(%args);
-    unless ($ok) {
-        Jifty->handle->rollback;
-        return ($ok, $msg);
-    }
-
-    # For the tip, add skips for all configurations
-    warn "Current head @{[$self->name]} is @{[$self->current_commit->short_sha]}\n";
-    my $configs = $self->project->configurations;
-    while (my $config = $configs->next) {
-        my $head = Smokingit::Model::TestedHead->new;
-        $head->load_or_create(
-            project_id       => $self->project->id,
-            configuration_id => $config->id,
-            commit_id        => $tip->id,
-        );
-    }
+    return ($ok, $msg) unless $ok;
+
+    Smokingit->gearman->dispatch_background(
+        sync_project => $self->project->name,
+    );
 
     return ($ok, $msg);
 }
 
+
 sub display_status {
     my $self = shift;
     my @options = @{$self->column("status")->valid_values};
diff --git a/lib/Smokingit/Model/Commit.pm b/lib/Smokingit/Model/Commit.pm
index ac809a2..0e43846 100644
--- a/lib/Smokingit/Model/Commit.pm
+++ b/lib/Smokingit/Model/Commit.pm
@@ -64,29 +64,22 @@ sub is_smoked {
 sub run_smoke {
     my $self = shift;
     my $config = shift;
+    my $branch = shift;
 
-    if ($config) {
-        my $smoke = Smokingit::Model::SmokeResult->new;
-        $smoke->load_or_create(
-            project_id       => $self->project->id,
-            configuration_id => $config->id,
-            commit_id        => $self->id,
-        );
-        $smoke->run_smoke;
-        return 1;
-    } else {
-        my $configs = $self->project->configurations;
-        my $smoke = Smokingit::Model::SmokeResult->new;
-        while ($config = $configs->next) {
-            $smoke->load_or_create(
-                project_id       => $self->project->id,
-                configuration_id => $config->id,
-                commit_id        => $self->id,
-            );
-            $smoke->run_smoke;
-        }
-        return $configs->count;
-    }
+    my %lookup = (
+        project_id       => $self->project->id,
+        configuration_id => $config->id,
+        commit_id        => $self->id,
+    );
+    my $smoke = Smokingit::Model::SmokeResult->new;
+    $smoke->load_by_cols( %lookup );
+    return 0 if $smoke->id;
+
+    $smoke->create(
+        %lookup,
+        from_branch_id => $branch->id,
+    );
+    return $smoke->run_smoke;
 }
 
 sub status {
diff --git a/lib/Smokingit/Model/Configuration.pm b/lib/Smokingit/Model/Configuration.pm
index a759bc1..4bcc024 100644
--- a/lib/Smokingit/Model/Configuration.pm
+++ b/lib/Smokingit/Model/Configuration.pm
@@ -37,44 +37,14 @@ use Smokingit::Record schema {
 
 sub create {
     my $self = shift;
-    my %args = (
-        @_,
-    );
-
-    # Lock on the project
-    Jifty->handle->begin_transaction;
-    my $project = Smokingit::Model::Project->new;
-    $project->row_lock(1);
-    $project->load( $args{project_id} );
-
-    my ($ok, $msg) = $self->SUPER::create(%args);
-    unless ($ok) {
-        Jifty->handle->rollback;
-        return ($ok, $msg);
-    }
+    my ($ok, $msg) = $self->SUPER::create(@_);
+    return ($ok, $msg) unless $ok;
 
-    # Find the distinct set of branch tips
-    my %commits;
-    my $branches = $project->branches;
-    while (my $b = $branches->next) {
-        warn "Current head @{[$b->name]} is @{[$b->current_commit->short_sha]}\n";
-        $commits{$b->current_commit->id}++;
-    }
-
-    # Add a TestedHead for each of the above
-    for my $commit_id (keys %commits) {
-        my $head = Smokingit::Model::TestedHead->new;
-        $head->create(
-            project_id       => $project->id,
-            configuration_id => $self->id,
-            commit_id        => $commit_id,
-        );
-    }
-
-    # Schedule tests
-    $project->schedule_tests;
+    Smokingit->gearman->dispatch_background(
+        sync_project => $self->project->name,
+    );
 
-    Jifty->handle->commit;
+    return ($ok, $msg);
 }
 
 1;
diff --git a/lib/Smokingit/Model/Project.pm b/lib/Smokingit/Model/Project.pm
index 788d602..9c8e14c 100644
--- a/lib/Smokingit/Model/Project.pm
+++ b/lib/Smokingit/Model/Project.pm
@@ -86,16 +86,6 @@ sub branches {
     return $branches;
 }
 
-sub tested_heads {
-    my $self = shift;
-    my $tested = Smokingit::Model::TestedHeadCollection->new;
-    $tested->limit(
-        column => 'project_id',
-        value => $self->id,
-    );
-    return $tested;
-}
-
 sub planned_tests {
     my $self = shift;
     my $tests = Smokingit::Model::SmokeResultCollection->new;
@@ -121,8 +111,6 @@ sub update_repository {
 
 sub sync_branches {
     my $self = shift;
-    warn "sync_branches called with no row lock!"
-        unless $self->row_lock;
 
     local $ENV{GIT_DIR} = $self->repository_path;
 
@@ -164,89 +152,52 @@ sub sync_branches {
         );
         warn "Create failed: $msg" unless $ok;
     }
-    $self->schedule_tests;
+    return $self->schedule_tests;
 }
 
 sub schedule_tests {
     my $self = shift;
-    warn "schedule_tests called with no row lock!"
-        unless $self->row_lock;
 
     local $ENV{GIT_DIR} = $self->repository_path;
 
-    # Determine the possible tips to test
-    my %branches;
-    my $branches = $self->branches;
-    while (my $branch = $branches->next) {
-        $branches{$branch->current_commit->sha}++
-            if $branch->is_tested;
-    }
-
-    # Bail early if there are no testable branches
-    return unless keys %branches;
-
     my $smokes = 0;
-    my $configs = $self->configurations;
-    while (my $config = $configs->next) {
-        # Find the set of already-covered commits
-        my %tested;
-        my $tested = $self->tested_heads;
-        $tested->limit( column => 'configuration_id', value => $config->id );
-        while (my $head = $tested->next) {
-            $tested{$head->commit->sha} = $head;
-        }
+    warn "Scheduling tests";
 
-        warn "Looking for possible @{[$config->name]} tests\n";
-        my @filter = (keys(%branches), map "^$_", keys %tested);
-        my @lines = split /\n/, `git rev-list --reverse --parents @filter`;
-        for my $l (@lines) {
-            # We only want to test it if both parents have existing tests
-            my ($commit, @shas) = split ' ', $l;
-            my @tested = grep {defined} map {$tested{$_}} @shas;
-            warn "Looking at $commit (parents @shas)\n";
-            if (@tested < @shas) {
-                warn "  Parents which are not tested\n";
-                next;
-            }
-            my @pending = grep {$_->gearman_process} map {$_->smoke_result} @tested;
-            if (@pending) {
-                warn "  Parents which are still testing\n";
-                next;
-            }
+    # Go through branches, masters first
+    my @branches;
+    my $branches = $self->branches;
+    $branches->limit( column => "status", value => "master" );
+    push @branches, @{$branches->items_array_ref};
+    $branches = $self->branches;
+    $branches->limit( column => "status", operator => "!=", value => "master", entry_aggregator => "AND" );
+    $branches->limit( column => "status", operator => "!=", value => "ignore", entry_aggregator => "AND" );
+    push @branches, @{$branches->items_array_ref};
+    warn "Branches: @{[map {$_->name} @branches]}\n";
+    return unless @branches;
+
+    my @configs = @{$self->configurations->items_array_ref};
+
+    for my $branch (@branches) {
+        # If there's nothing else happening, ensure that the tip is tested
+        if ($branch->current_commit->id == $branch->tested_commit->id) {
+            $smokes += $branch->current_commit->run_smoke($_, $branch) for @configs;
+            next;
+        }
 
-            warn "  Sending to testing.\n";
+        # Go looking for other commits to run
+        my @filter = (      $branch->current_commit->sha,
+                      "^" . $branch->tested_commit->sha);
 
-            my $to_test = Smokingit::Model::Commit->new;
-            $to_test->load_by_cols( project_id => $self->id, sha => $commit );
+        my @shas = map {$self->sha($_)} split /\n/, `git rev-list --reverse @filter`;
 
-            $_->delete for @tested;
-            my $head = Smokingit::Model::TestedHead->new;
-            $head->create(
-                project_id       => $self->id,
-                configuration_id => $config->id,
-                commit_id        => $to_test->id,
-            );
-            $smokes += $to_test->run_smoke($config);
+        for my $sha (@shas) {
+            for my $config (@configs) {
+                warn "Testing @{[$sha->short_sha]} on @{[$branch->name]} using @{[$config->name]}\n";
+                $smokes += $sha->run_smoke($config, $branch);
+            }
         }
-    }
 
-    # As a fallback, ensure that all testable heads have been tested,
-    # even if they are kids of existing testedhead objects
-    while (my $config = $configs->next) {
-        for my $sha (keys %branches) {
-            my $commit = Smokingit::Model::Commit->new;
-            $commit->load_by_cols( project_id => $self->id, sha => $sha );
-
-            my $existing = Smokingit::Model::SmokeResult->new;
-            $existing->load_by_cols(
-                project_id       => $self->id,
-                configuration_id => $config->id,
-                commit_id        => $commit->id,
-            );
-            next if $existing->id;
-            warn "Smoking untested head ".join(":",$config->name,$commit->short_sha)."\n";
-            $smokes += $commit->run_smoke($config);
-        }
+        $branch->set_tested_commit_id($branch->current_commit->id);
     }
 
     return $smokes;
diff --git a/lib/Smokingit/Model/SmokeResult.pm b/lib/Smokingit/Model/SmokeResult.pm
index 04eb0aa..2ee227f 100644
--- a/lib/Smokingit/Model/SmokeResult.pm
+++ b/lib/Smokingit/Model/SmokeResult.pm
@@ -20,6 +20,9 @@ use Smokingit::Record schema {
         is indexed,
         references Smokingit::Model::Commit;
 
+    column from_branch_id =>
+        references Smokingit::Model::Branch;
+
     column gearman_process =>
         type is 'text';
 
diff --git a/lib/Smokingit/Model/TestedHead.pm b/lib/Smokingit/Model/TestedHead.pm
deleted file mode 100644
index 61dce23..0000000
--- a/lib/Smokingit/Model/TestedHead.pm
+++ /dev/null
@@ -1,33 +0,0 @@
-use strict;
-use warnings;
-
-package Smokingit::Model::TestedHead;
-use Jifty::DBI::Schema;
-
-use Smokingit::Record schema {
-    column project_id =>
-        is mandatory,
-        references Smokingit::Model::Project;
-
-    column configuration_id =>
-        is mandatory,
-        references Smokingit::Model::Configuration;
-
-    column commit_id =>
-        is mandatory,
-        references Smokingit::Model::Commit;
-};
-
-sub smoke_result {
-    my $self = shift;
-    my $result = Smokingit::Model::SmokeResult->new;
-    $result->load_by_cols(
-        project_id       => $self->project->id,
-        configuration_id => $self->configuration->id,
-        commit_id        => $self->commit->id,
-    );
-    return $result;
-}
-
-1;
-

commit 0a8808913d121b550e9d1f7922fa4718223bb568
Author: Alex Vandiver <alexmv at bestpractical.com>
Date:   Wed Jan 26 03:37:19 2011 -0500

    We no longer need to schedule new tests when we get a result; they are already in the queue

diff --git a/bin/local_updates b/bin/local_updates
index 0bf3dde..e17dbc9 100755
--- a/bin/local_updates
+++ b/bin/local_updates
@@ -85,10 +85,6 @@ $worker->register_function(
         # And commit all of that
         Jifty->handle->commit;
 
-        # Pull and re-dispatch any new commits
-        Smokingit->gearman->dispatch_background(
-            sync_project => $project->name,
-        );
         warn "Test result for "
             . $smoke->project->name
             ." ". $smoke->commit->short_sha

commit c0d40a0c7111c51d232f9631b642b74ee643d8cb
Author: Alex Vandiver <alexmv at bestpractical.com>
Date:   Wed Jan 26 03:38:09 2011 -0500

    Show origin branch in UI

diff --git a/bin/local_updates b/bin/local_updates
index e17dbc9..e69c528 100755
--- a/bin/local_updates
+++ b/bin/local_updates
@@ -89,6 +89,7 @@ $worker->register_function(
             . $smoke->project->name
             ." ". $smoke->commit->short_sha
             ." using ". $smoke->configuration->name
+            ." on ". $smoke->from_branch->name
             .": ".($smoke->is_ok ? "OK" : "NOT OK")."\n";
 
         return 1;
diff --git a/lib/Smokingit/View/Project.pm b/lib/Smokingit/View/Project.pm
index e2ade2d..ce9cb3f 100644
--- a/lib/Smokingit/View/Project.pm
+++ b/lib/Smokingit/View/Project.pm
@@ -69,7 +69,7 @@ template '/project' => page {
                             url     => "/test/".$test->commit->sha."/".$test->configuration->name,
                             label   => $test->commit->short_sha,
                         );
-                        outs( $test->configuration->name );
+                        outs( " on ".$test->from_branch->name . " using ".$test->configuration->name );
                     }
                 }
             };
@@ -93,7 +93,7 @@ template '/project' => page {
                             attr { class => "sha", title => $msg };
                             $test->commit->short_sha
                         };
-                        outs( $test->configuration->name );
+                        outs( " on ".$test->from_branch->name . " using ".$test->configuration->name );
                     }
                 }
             }
diff --git a/share/web/static/css/app-late.css b/share/web/static/css/app-late.css
index 7a4b797..5e4cdeb 100644
--- a/share/web/static/css/app-late.css
+++ b/share/web/static/css/app-late.css
@@ -105,7 +105,7 @@ li {
   margin-top: 1em;
   border-right: 1px dotted #779;
   position: relative;
-  width: 50%;
+  width: 40%;
   float: left;
 }
 #branch-list h2 { margin-top: 0 }
@@ -118,7 +118,7 @@ li {
 #right-bar {
   margin-top: 1em;
   border-left: 1px dotted #779;
-  width: 48%;
+  width: 58%;
   float: left;
   padding-left: 1em;
   margin-left: -1px;

commit b69b946e14545f9b32d61919559a6d488c0a0d8b
Author: Alex Vandiver <alexmv at bestpractical.com>
Date:   Wed Jan 26 03:39:01 2011 -0500

    We can have a lighter-weight scheduling callback, as an alternate to the full sync

diff --git a/bin/local_updates b/bin/local_updates
index e69c528..4b59777 100755
--- a/bin/local_updates
+++ b/bin/local_updates
@@ -127,6 +127,29 @@ $worker->register_function(
         return $tests;
     },
 );
+$worker->register_function(
+    plan_tests => sub {
+        my $job = shift;
+        my $project_name = $job->arg;
+
+        my $projects = Smokingit::Model::ProjectCollection->new;
+        if ($project_name) {
+            $projects->limit( column => "name", value => $project_name );
+        } else {
+            $projects->unlimit;
+        }
+        return 0 unless $projects->count;
+
+        my $tests = 0;
+        while (my $project = $projects->next) {
+            Jifty->handle->begin_transaction;
+            $tests += $project->schedule_tests;
+            Jifty->handle->commit;
+        }
+
+        return $tests;
+    }
+);
 
 $worker->register_function(
     retest => sub {
diff --git a/lib/Smokingit/Model/Branch.pm b/lib/Smokingit/Model/Branch.pm
index 4f5094f..f5edb34 100644
--- a/lib/Smokingit/Model/Branch.pm
+++ b/lib/Smokingit/Model/Branch.pm
@@ -72,7 +72,7 @@ sub create {
     return ($ok, $msg) unless $ok;
 
     Smokingit->gearman->dispatch_background(
-        sync_project => $self->project->name,
+        plan_tests => $self->project->name,
     );
 
     return ($ok, $msg);
diff --git a/lib/Smokingit/Model/Configuration.pm b/lib/Smokingit/Model/Configuration.pm
index 4bcc024..42b5f11 100644
--- a/lib/Smokingit/Model/Configuration.pm
+++ b/lib/Smokingit/Model/Configuration.pm
@@ -41,7 +41,7 @@ sub create {
     return ($ok, $msg) unless $ok;
 
     Smokingit->gearman->dispatch_background(
-        sync_project => $self->project->name,
+        plan_tests => $self->project->name,
     );
 
     return ($ok, $msg);

commit a904d6eae75dfca5a562b03982779d303d01867b
Author: Alex Vandiver <alexmv at bestpractical.com>
Date:   Wed Jan 26 03:39:39 2011 -0500

    Fix retesting to work with the new world order

diff --git a/bin/local_updates b/bin/local_updates
index 4b59777..c453c5a 100755
--- a/bin/local_updates
+++ b/bin/local_updates
@@ -154,7 +154,7 @@ $worker->register_function(
 $worker->register_function(
     retest => sub {
         my $job = shift;
-        my $sha = $job->arg;
+        my ($sha,$configname,$branchname) = $job->arg =~ /^([0-9a-fA-F]+)(?:\s*\[(.*)\])?(?:\s*,\s*(.*))?/;
 
         my $commits = Smokingit::Model::CommitCollection->new;
         $commits->limit( column => "sha", operator => "like", value => "$sha%" );
@@ -162,7 +162,41 @@ $worker->register_function(
 
         my $commit = $commits->next;
         warn "Retesting @{[$commit->short_sha]}\n";
-        return $commit->run_smoke;
+
+        my $branch = Smokingit::Model::Branch->new;
+        $branch->load_by_cols( project_id => $commit->project->id, name => $branchname )
+            if $branchname;
+        warn "Invalid branch name $branchname given\n" if $branchname and not $branch->id;
+
+        my $configs = $commit->project->configurations;
+        $configs->limit( column => "name", operator => "MATCHES", value => $configname )
+            if defined $configname and length $configname;
+        warn "Found @{[$configs->count]} configs\n";
+        while (my $config = $configs->next) {
+            my %lookup = (
+                project_id       => $commit->project->id,
+                configuration_id => $config->id,
+                commit_id        => $commit->id,
+            );
+            my $smoke = Smokingit::Model::SmokeResult->new;
+            $smoke->load_by_cols( %lookup );
+            if ($smoke->id) {
+                warn "Re-testing @{[$smoke->id]}\n";
+                $smoke->set_submitted_at(undef);
+                $smoke->set_gearman_process(undef);
+                $smoke->run_smoke;
+            } elsif ($branch->id) {
+                $smoke->create(
+                    %lookup,
+                    from_branch_id => $branch->id,
+                );
+                warn "Smoking new @{[$smoke->id]}\n";
+                $smoke->run_smoke;
+            } else {
+                warn "No existing smoke for $sha found, and no branch given\n";
+            }
+        }
+        return 1;
     },
 );
 

commit 0eebaa18079628650620d369f3c9bb12f8a00188
Author: Alex Vandiver <alexmv at bestpractical.com>
Date:   Wed Jan 26 03:40:16 2011 -0500

    When a branch moves from ignored -> unignored, only test the most recent commit
    
    We may wish to adjust this to "test from the branchpoint, if it is recent"

diff --git a/lib/Smokingit/Model/Branch.pm b/lib/Smokingit/Model/Branch.pm
index f5edb34..d856308 100644
--- a/lib/Smokingit/Model/Branch.pm
+++ b/lib/Smokingit/Model/Branch.pm
@@ -78,6 +78,24 @@ sub create {
     return ($ok, $msg);
 }
 
+sub set_status {
+    my $self = shift;
+    my $val = shift;
+    my $prev_tested = $self->is_tested;
+
+    my @ret = $self->_set(column =>'status', value => $val);
+
+    if (not $prev_tested and $self->is_tested) {
+        # It's no longer ignored; start testing where the tip is now,
+        # not where it was when we first found it
+        $self->set_tested_commit_id( $self->current_commit->id );
+        Smokingit->gearman->dispatch_background(
+            plan_tests => $self->project->name,
+        );
+    }
+
+    return @ret;
+}
 
 sub display_status {
     my $self = shift;

commit a943873435d125f4f2ceeb6f9f0342271e9fd41c
Author: Alex Vandiver <alexmv at bestpractical.com>
Date:   Wed Jan 26 03:53:01 2011 -0500

    Add a custom gearman starter which writes into our postgres db

diff --git a/bin/start-gearmand b/bin/start-gearmand
new file mode 100755
index 0000000..52a5041
--- /dev/null
+++ b/bin/start-gearmand
@@ -0,0 +1,12 @@
+#!/bin/bash
+
+gearmand \
+    --daemon\
+    --verbose \
+    --verbose \
+    --pid-file=var/gearman.pid \
+    --log-file=log/gearmand.log \
+    --port=4730 \
+    --queue-type=libpq \
+    --libpq-conninfo="user=postgres dbname=smokingit" \
+    --libpq-table="gearman"

-----------------------------------------------------------------------



More information about the Bps-public-commit mailing list