Tools for Travis

Travis is an open source and distributed build system for the community. They’re doing continuous integration a lot of great open source projects, including Ruby on Rails recently. Since there’s a bunch of great data in there, this week’s challenge is to do something great with Travis.

You can build anything you like, from CI monitors to command line interfaces showing you data from your projects. You’re probably going to need to fetch data from Travis, so be sure to check out the API documentation. We’ll keep this one open-ended, since the goal is to help the Travis team out a bit. If you’re building a web interface, be sure to use something small, like Sinatra. Remember, your entry should fit into a gist. If you run into any issues along the way, just hop into #travis on Freenode and the team will be happy to help you out.

Remember to keep your entry small, write nice code and include a README explaining what your project does, which problem it solves and how it works. If you’re building a web interface, be sure to include screenshots in there.

We’re still working on a special prize for this week, so you’ll have to wait for some more to get to know what it is.

As always, you have one week to enter, so go build something awesome. Good luck!

Prize

This week’s winner will receive a code for $25 worth of credit for the 6sync hosting platform!

Oh, and this contest description was written by Jeff Kreeftmeijer, on behalf of Sven.

This contest is finished

Congratulations to this week's winners! The entries and the contestant names are shown below.

  • Gemfile
    source 'http://rubygems.org'
    
    gem 'sinatra'
    gem 'nokogiri'
    gem 'multi_xml'
    gem 'slim'
    README.md

    Is it Rubinius? Ask Travis!

    This project was built on Friday, August 5, 2011 as part of the celebration of Rubinius Day!

    It utilizes Travis:

    1. ability test gems against Rubinius
    2. API

    This simple web app borrows graphics and stylesheets from http://isitrubinius.com to create a site with similar functionality, but instead of being crowdsourced, it's powered by Travis: http://isitrbx.herokuapp.com

    The app allows you to enter the name of any gem and will tell you whether the latest Rubinius build is passing on Travis.

    It could be easily modified to test gems for compatibility with JRuby, MRI 1.9.3, or any other Ruby version/implementation supported by RVM and Travis.

    Screenshot

    Screenshot

    Examples

    Credits

    This project uses the following open-source libraries:

    isitrbx.rb
    require 'open-uri'
    
    RVM = "rbx"
    
    get '/' do
      slim ''
    end
    
    get '/search' do
      begin
        @rubygems_url = "http://rubygems.org/gems/#{params[:gem]}"
        rubygems = Nokogiri::HTML(open(@rubygems_url))
        @authors = rubygems.css('.who .authors p').first.content
        @description = rubygems.css('#markup p').first.content
        @github_url = rubygems.css('.meta .links a').map{|link| link['href']}.find{|href| href =~ /github\.com/i}
        github_array = @github_url.split('/')
        # ["https:", "", "github.com", "user", "project"]
        @user, @project = github_array[3], github_array[4]
        @travis_url = "http://travis-ci.org/#{@user}/#{@project}.xml?rvm=#{RVM}"
        @status = MultiXml.parse(open(@travis_url))['repository']['last_build_status']
        slim :show
      rescue OpenURI::HTTPError
        raise Sinatra::NotFound
      end
    end
    
    config.ru
    require 'bundler'
    Bundler.require
    require './isitrbx'
    run Sinatra::Application
    show.slim
    h1 id="gem-name" = @project
    dl
      dt Author(s)
      dd = @authors
      dt Summary
      dd = @description
    div id="status"
      - case @status
      - when "0"
          span class="passing" passing
      - when "1"
          span class="failing" failing
      - when nil
          span class="unknown" unknown
      div Latest Rubinius build status on Travis for #{@project}.
    layout.slim
    doctype html
    html
      head
        title Is it Rubinius?
        link href="/css/application.css" media="screen" rel="stylesheet" type="text/css"
    
      body
        div id="head"
          a href="/" id="branding"
          span id="tagline" Travis-powered gem compatibility for Rubinius
          form action="/search" method="get"
            input type="text" name="gem" value=params[:gem]
            input type="submit" value="Search"
    
        div id="main"
          == yield
    View full entry
    Finished in 1st place with a final score of 4.1/5. (View the Gist)
  • README
    ABOUT:
    
    small build monitor using MacRuby and Growl
    
    RUN:
    
    macruby tci.rb
    
    CONFIG:
    
    have a look at the tci.yml right now.
    user-preferences are coming!
    
    tci.png
    tci.rb
    require "yaml"
    require "json"
    require "net/http"
    
    require 'growl'
    
    framework 'AppKit'
    
    IMAGE = 'tci.png'
    
    @config = File.open("tci.yml") { |file| YAML.load(file) }
    @interval = @config['interval'].to_f
    @repos = @config['repos']
    @results = Hash.new({})
    
    @queue = Dispatch::Queue.new('de.nofail')
    @growl = Growl.new('de.nofail.tci', ['notification'], NSImage.alloc.initWithContentsOfFile(IMAGE))
    @timer = NSTimer.scheduledTimerWithTimeInterval(@interval,
                                                    target: self,
                                                    selector: 'refresh_results:',
                                                    userInfo: nil,
                                                    repeats: true)
    
    def setupMenu
      menu = NSMenu.new
      menu.initWithTitle 'tci'
      @repos.each do |repo|
        mi = NSMenuItem.new
        mi.title = repo
        mi.action = 'showStatus:'
        mi.target = self
        menu.addItem mi
      end
    
      mi = NSMenuItem.new
      mi.title = 'Reload'
      mi.action = 'refresh_results:'
      mi.target = self
      menu.addItem mi
    
      mi = NSMenuItem.new
      mi.title = 'Quit'
      mi.action = 'quit:'
      mi.target = self
      menu.addItem mi
    
      menu
    end
    
    def showStatus(sender)
      alert = NSAlert.new
      alert.messageText = "Build result for #{sender.title}"
      alert.informativeText = @results[sender.title]
      alert.alertStyle = NSInformationalAlertStyle
      alert.addButtonWithTitle("close")
      response = alert.runModal
    end
    
    def quit(sender)
      NSApplication.sharedApplication.terminate(self)
    end
    
    def refresh_results(timer)
      @queue.async do
        @repos.each do |repo|
          url = "#{@config['remote']}#{repo}.json"
          res = Net::HTTP.get_response(URI.parse(url))
          @results[repo] = result = JSON.parse(res.body)
          @growl.notify('notification', repo, result['status'])
        end
      end
    end
    
    
    app = NSApplication.sharedApplication
    
    status_bar = NSStatusBar.systemStatusBar
    status_item = status_bar.statusItemWithLength(NSVariableStatusItemLength)
    status_item.setMenu setupMenu
    img = NSImage.new.initWithContentsOfFile IMAGE
    status_item.setImage(img)
    
    refresh_results(nil)
    
    app.run
    
    growl.rb
    framework 'Cocoa'
    framework 'Foundation'
    
    class Growl
      def initialize(app, notifications, icon = nil)
        @application_name = app
        @application_icon = icon
        @notifications = notifications
        @default_notifications = notifications
        @center = NSDistributedNotificationCenter.defaultCenter
        send_registration!
      end
    
      def notify(notification, title, description, options = {})
        dict = {
          :ApplicationName => @application_name,
          :NotificationName => notification,
          :NotificationTitle => title,
          :NotificationDescription => description,
          :NotificationPriority => options[:priority] || 0,
          :NotificationIcon => @application_icon.TIFFRepresentation,
        }
        dict[:NotificationSticky] = 1 if options[:sticky]
    
        @center.postNotificationName(:GrowlNotification, object:nil, userInfo:dict, deliverImmediately:false)
      end
    
      def send_registration!
        dict = {
          :ApplicationName => @application_name,
          :ApplicationIcon => @application_icon.TIFFRepresentation,
          :AllNotifications => @notifications,
          :DefaultNotifications => @default_notifications
        }
    
        @center.postNotificationName(:GrowlApplicationRegistrationNotification, object:nil, userInfo:dict, deliverImmediately:true)
      end
    end
    
    tci.yml
    interval: 60
    remote: "http://travis-ci.org/"
    repos:
      - "phoet/asin"
    
    View full entry
    Finished in 2nd place with a final score of 3.6/5. (View the Gist)
  • README.md

    Who is to blame?

    Do you want to know who broke you tests?

    http://whoistoblame.heroku.com/

    Type your github name and project name and you will see who is to blame for broken test! (your project must use travis-ci for continuous integration)

    For example: http://whoistoblame.heroku.com/?user=travis-ci&project=travis-ci

    whoistoblame.rb
    require 'sinatra'
    require 'open-uri'
    require 'json'
    require 'nokogiri'
    
    class Github
      def initialize owner, project, sha
        @owner = owner
        @project = project
        @sha = sha
      end
    
      def user_info
        @user_info ||= JSON.parse(open("https://api.github.com/users#{username}").read)
      end
    
      private
        def commit_page
          @commit_page ||= Nokogiri::HTML(open("https://github.com/#{@owner}/#{@project}/commit/#{@sha}").read)
        end
    
        def username
          commit_page.xpath("//div[@class='name']/a/@href").first.value
        end
    end
    
    class Travis
      def initialize owner, project
        @owner = owner
        @project = project
      end
    
      def status
        project_info["last_build_status"]
      end
    
      def id
        project_info["last_build_id"]
      end
    
      def last_commit
        build_info["commit"]
      end
    
      private
        def project_info
          @project_info ||= JSON.parse(open("http://travis-ci.org/#{@owner}/#{@project}.json").read)
        end
    
        def build_info
          @build_info ||= JSON.parse(open("http://travis-ci.org/#{@owner}/#{@project}/builds/#{id}.json").read)
        end
    end
    
    get '/' do
      form = "<div style='text-align:center; margin-top:10%'>
                <form action='/'>
                  <label for='user'>Owner:</label>
                  <input name='user' />
                  <label for='project'>Project:</label>
                  <input name='project' />
                  <input type='submit' value='Blame!' />
                </form>
              </div>"
      if params[:user] && params[:project]
        travis = Travis.new(params[:user], params[:project])
    
        if travis.status == 1
          github = Github.new(params[:user], params[:project], travis.last_commit)
          user = github.user_info
          "<html>
            <head></head>
            <body>
              <header style='text-align:center'>
                <h2>Who is to blame?</h2>
                <h3>Build status of #{params[:project]}: <span style='color:red'>Failed!</span></h3>
              </header>
              <div style='margin-top:10%;margin-left:40%'>
                <span style='margin-left:10%'>
                  <img src='#{user['avatar_url']}' />
                </span>
                <p>Name: #{user['name']}</p>
                <p>Github: <a href='#{user['html_url']}'>#{user['login']}</a></p>
                <p>Email: #{user['email']}</p>
                <p>Location: #{user['location']}</p>
              </div>
              #{form}
            </body>
          </html>"
        else
        "<html>
          <head></head>
          <body>
            <header style='text-align:center'>
              <h2>Who is to blame?</h2>
              <h3>Build status of #{params[:project]}: <span style='color:green'>Passed!</span></h3>
            </header>
            <div style='margin-top:10%;margin-left:40%'>
              <h1>No blame! <3 </h1>
            </div>
            #{form}
          </body>
        </html>"
        end
      else
        "<html>
          <head></head>
          <body>
            <header style='text-align:center'>
              <h2>Who is to blame?</h2>
            </header>
            #{form}
          </body>
        </html>"
      end
    end
    
    View full entry
    Finished in 3rd place with a final score of 3.5/5. (View the Gist)
  • travis-ci.rb
    #TODOS:
    #* Improve cache on relationships
    #* Add raw responses
    #* Add Proxy support
    #* Rename the base class
    #* Handle errors/Raise custom exceptions
    #* RDoc me! 
    
    require 'net/http'
    require 'json'
    #Only json will be used until we add support for a raw response
    #require 'rexml/document'
    
    module TravisCI
    
      # Wrapper for the Travis CI API
      class Client
    
        def initialize(options={})
          @options = {:format=>DEFAULT_FORMAT}.merge(options)
        end
    
    #    Since right now we are not supporting a raw response the format doesn't make any sence 
    #    def format(format)
    #      @options[:format] = format.to_s
    #      self
    #    end
    
        def self.method_missing(method, *args, &block)
          return self.new.send(method, *args, &block) if self.client.respond_to?(method)
          super
        end
    
        def self.respond_to?(method, include_private=false)
          self.client.respond_to?(method, include_private) || super(method, include_private)
        end	
    
      private
       
        API_HOST = 'travis-ci.org'
        DEFAULT_FORMAT = 'json'
        
        def results_for(path)
          @options.each_pair do |key, value|
            path.gsub!(":#{key}", value)
          end
    
          #TODO Add proxy support
          response = Net::HTTP.get_response(API_HOST, path)
    
          return parse(response.body)
        end
    
        def parse(content)
          case @options[:format]
          when 'json' then
            return JSON.parse(content)
          when 'xml'
            return REXML::Document.new(content)
          end
        end
    
        def self.client
          self.new
        end
    
      end
    
      class Repositories < Client
        @@repositories       = nil
        @@repositories_cache = []
        @@builds             = nil
        @@builds_cache       = []
    
        def all
          @@repositories || self.all!
        end    
    
        def all!
          @@repositories = results_for(REPOSITORIES_PATH).map {|repository_data| Repository.new(nil, nil, repository_data)}
        end
    
        def fetch
          fetch_from_cache() || self.fetch!
        end
    
        def fetch!
          repositories_cache().delete_if {|r| r.owner == @options[:owner] && r.name == @options[:name]}
          repository = Repository.new(@options[:owner], @options[:name], results_for(REPOSITORY_PATH))
          repositories_cache() << repository
          repository
        end
    
        def builds
          @@builds || self.builds!
        end
    
        def builds!
          @@builds = results_for(REPOSITORY_BUILDS_PATH).map {|build_data| Build.new(@options[:owner], @options[:name], build_data)}
        end
     
        def build(build_id)
          build_from_cache(build_id) || self.build!(build_id) 
        end
    
        def build!(build_id)
          @options[:build_id] = build_id.to_s
          builds_cache().delete_if {|b| b.id == @options[:build_id]}
          build = Build.new(@options[:owner], @options[:name], results_for(REPOSITORY_BUILD_PATH))
          builds_cache() << build
          build
        end
        
        def owner(owner)
          @options[:owner] = owner
          self
        end    
    
        def name(name)
          @options[:name] = name
          self
        end
    
        def slug(slug)
          @options[:owner], @options[:name] = slug.split('/')
          self
        end
    
        def self.name(name)
          self.new.name(name)
        end
    
      private
    
        REPOSITORY_PATH        = '/:owner/:name.:format'
        REPOSITORIES_PATH      = '/repositories.:format'
        REPOSITORY_BUILD_PATH  = '/:owner/:name/builds/:build_id.:format'
        REPOSITORY_BUILDS_PATH = '/:owner/:name/builds.:format'
    
        def repositories_cache
          @@repositories || @@repositories_cache
        end
    
        def builds_cache
          @@builds || @@builds_cache
        end
    
        def fetch_from_cache
          repositories_cache().find {|repository| repository.owner == @options[:owner] && repository.name == @options[:name]}
        end
    
        def build_from_cache(build_id)
          builds_cache().find {|build| build.id == build_id.to_s} 
        end
    
      end
    
      # I know, it's an awful name, I will rename it, suggestions are welcome! :P
      class Base
    
        def initialize(attributes={})
          @attributes = attributes
        end
    
        def method_missing(method, *args, &block)
          return @attributes[method.to_s] if @attributes.has_key?(method.to_s)
          super
        end
    
        def respond_to?(method, include_private=false)
          @attributes.hash_key?(method.to_s) || super(method, include_private)
        end	
    
      protected 
        
        def attributes
          @attributes
        end
     
      end
    
      class Repository < Base
        
        #@attributes => [:slug, :id, :status, :last_build_id, :last_build_status, :last_build_number, :last_build_finished_at]
    
        attr_reader :name, :owner
    
        def initialize(owner, name, attributes={})
          @owner = owner || attributes[:slug] && attributes[:slug].split('/').first
          @name = name || attributes[:slug] && attributes[:slug].split('/').last
          super(attributes)
        end
    
        def builds
          @builds ||= Repositories.owner(self.owner).name(self.name).builds!
        end
    
        def last_build
          @last_build ||= Repositories.owner(self.owner).name(self.name).build!(self.last_build_id)
        end
    
        def reload!
          @builds = nil
          @last_build = nil
          @attributes = Repositories.owner(self.owner).name(self.name).fetch!.attributes
        end
    
      end
    
      class Build < Base
    
        #@attributes => [:number, :commited_at, :commit, :finished_at, :config, :author_name, :log, :branch, :id, :parent_id, :started_at, :author_email, :status, :repository_id, :message, :compare_url, :matrix]
        
        def initialize(repository_owner, repository_name, attributes={})
          @repository_owner = repository_owner
          @repository_name = repository_name
          super(attributes)
        end
    
        def parent
          @parent ||= Repositories.owner(@repository_owner).name(@repository_name).build!(self.partner_id)
        end
    
        def repository
          @repository ||= Repositories.owner(@repository_owner).name(@repository_name).fetch! 
        end
    
        def matrix
          return nil unless @attributes.has_key?('matrix')
          @matrix ||= @attributes['matrix'].map {|build_data| Build.new(@repository_owner, @repository_name, build_data)}     
        end
    
        def reload!
          @repository = nil
          @parent = nil
          @matrix = nil
          @attributes = Repositories.owner(@repository_owner).name(@repository_name).build!(self.id).attributes
        end
    
      end
    
    end
    
    README.textile

    Travis CI API Wrapper

    Usage

    #Repositories
    TravisCI::Repositories.all #Collection of Repository instances
    TravisCI::Repositories.all! #Collection of Repository instances bypassing cache
    TravisCI::Repositories.owner('owner_name_here').name('name_here').fetch #Repository
    TravisCI::Repositories.owner('owner_name_here').name('name_here').fetch! #Repository bypassing cache
    TravisCI::Repositories.slug('owner_name_here/name_here').fetch #Repository
    TravisCI::Repositories.slug('owner_name_here/name_here').fetch! #Repository bypassing cache
    #Builds
    TravisCI::Repositories.owner('owner_name_here').name('name_here').builds #Collection of Build instances 
    TravisCI::Repositories.owner('owner_name_here').name('name_here').builds! #Collection of Build instances bypassing cache
    TravisCI::Repositories.slug('owner_name_here/name_here').builds #Collection of Build instances
    TravisCI::Repositories.slug('owner_name_here/name_here').builds! #Collection of Build instances bypassing cache
    TravisCI::Repositories.owner('owner_name_here').name('name_here').build('id_here') #Build
    TravisCI::Repositories.owner('owner_name_here').name('name_here').build!('id_here') #Build bypassing cache
    TravisCI::Repositories.slug('owner_name_here/name_here').build('id_here') #Build
    TravisCI::Repositories.slug('owner_name_here/name_here').build!('id_here') #Build bypassing cache

    Repository methods

    The main attributes from the API will be accessible via the following methods:

    [:slug, :id, :status, :last_build_id, :last_build_status, :last_build_number, :last_build_finished_at]

    Some relationships can be retrieved using the following methods:

    repository.builds #=> Collection of Build instances
    repository.last_build #=> Build

    You can also update the repository information from the API usign:

    repository.reload! #=> Fetch the updated repository data from the API and updates its attributes and relationships

    Build methods

    The main attributes from the API will be accessible via the following methods:

    [:number, :commited_at, :commit, :finished_at, :config, :author_name, :log, :branch, :id, :parent_id, :started_at, :author_email, :status, :repository_id, :message, :compare_url, :matrix]

    Some relationships can be retrieved using the following methods:

    build.parent #=> Build or nil
    build.repository #=> Repository
    build.matrix #=> Collection of Build instances or nil

    You can also update the build information from the API usign:

    build.reload! #=> Fetch the updated build data from the API and updates its attributes and relationships
    View full entry
    Finished in 4th place with a final score of 3.3/5. (View the Gist)
  • README.md

    A simple Sinatra app that show (# of builds) / (# of worker) message

    Good Screenshot

    Bad Screenshot

    main.rb
    %w{restclient json erb sinatra}.each {|lib|require lib}
    
    def parse url, opts={}, &block
      opts = {only: //, except: /^$/}.merge(opts)
      JSON.parse(RestClient.get("http://travis-ci.org#{url}", accept: :json)).
      map(&block).count{|s|opts[:only].match(s) and not opts[:except].match(s)}
    end
    
    def workers(opts) parse('/workers', opts) {|h|h['id']} end
    def queue(opts) parse('/jobs?queue=builds', opts) {|h|h['repository']['slug']} end
    
    get '/' do
      @workers = workers only: /^main/
      @queue = queue except: %r{(^rails/rails$)|travis}
      @class, @message = if @workers > @queue
          ['', 'Travis is READY for action!']
        elsif @workers < @queue and @queue < 3 * @workers
          ['minor', 'Travis is in action']
        else
          ['major', 'Travis is chugging along']
        end
      erb :index
    end
    
    __END__
    @@ index
    <!doctype html>
    <html><head><style>
    body,article,p,h2,header,div{margin:0;padding:0;border:0;font-size:100%;vertical-align:baseline;display:block}body{color:#fff;font:1em'Trebuchet MS';overflow: hidden;background:#000;background: -webkit-gradient(radial,50% 0,200,50% -99,900,from(rgba(0,0,0,0)),to(#000)),-webkit-gradient(linear,right top,left top,from(#f14),color-stop(.6,#e1b),to(#0ae));
    <% ['-moz-', '-o-', ''].each do |b| %>background: <%= b %>radial-gradient(50% 0,circle closest-corner,rgba(0,0,0,0),#000),<%= b %>linear-gradient(right center,#f14 30%,#e1b 60%,#0ae 99%);<% end %>}
    article{width:35%;margin: 208px auto 999999px auto;background: rgba(0, 0, 0, .2);padding: 20px 30px;border-radius: 10px;-moz-border-radius: 10px;-webkit-border-radius: 10px;-o-border-radius: 10px}h2{margin:0 1em 1em 24px;font-size:1.5em}#status{box-shadow: 1px 1px 7px #999;width: 15px;height: 15px;border-radius: 10px;background: #64c901;float:left;margin-top:6px}#status.minor{background:#ea7f00}#status.major{background:#c90101}
    </style></head><body><article><header><div id='status' class='<%= @class %>'></div><h2><%= @message %></h2><p><%= @queue %> builds on <%= @workers %> workers</p></header></article></body></html>
    
    View full entry
    Finished in 5th place with a final score of 3.3/5. (View the Gist)