Murd●ch's Blog

„Skide godt!“

Filter Commits and group them by Category and File

For reviewing the referenced commits of a story or task, you have to include the issue ID as a hashtag in the commit message, I needed a better overview than just a flat list of commits.

I want to group the commits by file. In case of Rails apps I also want to group files with commits by the following categories: library, model, controller, javascript, view, tests, configuration and misc.

Therefor I implemented the Ruby gem commit_filter for Rails apps which renders filter results e.g. about commits referencing issue #56 like this (some inputs overlap because of the small screen for displaying it here):

Each commit is linked to a blame page and you get code diffs in a modal.

Installation

As a standalone Application

git clone git@github.com:rails-info/rails_info_application.git
cd rails_info_application
bundle install

Start the Rails server and then you'll find the filter under this path: /rails/info/commit/filters/new

As a plugin

Add it to a development group in the Gemfile of your Rails 4 App:

group :development do
  gem 'commit_filter', '~> 0.1.1'
end

Mount it to your desired path or just root through the routes file:

mount CommitFilter::Engine => '/'

Have I mentioned that the Gem requires Twitter Bootstrap 3 ;-) But it's easy to write a new frontend framework adapter through inheritance.
So if your Gemfile does not include Twitter Bootstrap 3 then add it through the development group: gem 'bootstrap-sass', '~> 3.3.4.1' (How to setup CSS and JS manifest files can be found >> here <<)

If your application manifest files does not include Twitter Bootstrap 3 then you should point the gem to the CSS and JS manifest files which do through an initializer file:

CommitFilter.configure do |config|
  config.manifest_file = 'twitter_bootstrap'
end

Start the Rails server and then you'll find the filter under this path: /commit/filters/new

Configuration

If you like to set default values for the filter form then you can set them through an initializer file:

CommitFilter.configure do |config|
  config.attribute_name = 'value'
end

All available configuration options can be found >> here <<.

Remote Repositories

All repositories and branches have to be checked out locally before filtering. I would also like to access remote GitHub repositories without local checkout but GitHub has no API for commit search and paginating over commits like in local environment is too expensive - rate limit.

P.S.: I recommend to limit the result by a time span for faster queries.

DRY Conventional API Provider Host Setting by Environment Fallback and Alias Mapping

For my content management system gem I wanted to support different APIs and use the right host for the current environment by a fallback chain.
Therefor I need to handle aliases of user environment names and environments not supported by an API provider.

Given these settings from a Rails initializer powered by rails-settings-cached (you have to use development, test, staging or production as key):

Setting.defaults[
  'apis.providers.volontariat.hosts.development'
] = 'http://localhost:3001'
Setting.defaults[
  'apis.providers.volontariat.hosts.production'
] = 'http://Volontari.at' # currently down :-(

I wanted something like this:

host = ApiProviderHost.new('volontariat', Rails.env).to_s

I expect it to behave like this:

describe ApiProviderHost do
  describe '#to_s' do
    it 'behaves like this' do
      provider = 'name_of_host'
      Setting.defaults[
        'apis.providers.name_of_host.hosts.development'
      ] = 'http://localhost:3001'

      # no fallback or alias mapping needed
      expect(
        described_class.new(provider, 'development').to_s
      ).to be == 'http://localhost:3001'

      # fallback to development
      expect(
        described_class.new(provider, 'test').to_s
      ).to be == 'http://localhost:3001'

      # alias mapping needed
      expect(
        described_class.new(provider, 'dev').to_s
      ).to be == 'http://localhost:3001'

      # alias not found
      expect{
        described_class.new(provider, 'unknown_environment').to_s
      }.to raise_error(
        NotImplementedError, 
        'Your environment is unknown. Please update alias mapping!'
      )

      # environment not supported by provider
      expect{
        described_class.new(provider, 'staging').to_s
      }.to raise_error(
        NotImplementedError, 
        'The API provider does not support your environment!'
      )
    end
  end
end

And I implemented this class:

class ApiProviderHost
  ENVIRONMENTS = [:development, :test, :staging, :production]
  ALIASES = { 
    dev: :development, testing: :test, stage: :staging, show: :staging, 
    live: :production, prod: :production 
  }
  FALLBACKS = { 
    development: [:development, :test, :staging, :production], 
    test: [:test, :development, :staging, :production],
    staging: [:staging, :production],
    production: [:production]
  }

  def initialize(provider, working_environment)
    @provider = provider
    @environment = working_environment.to_s.to_sym
  end

  def setting_namespace
    "apis.providers.#{@provider}.hosts"
  end

  def environment
    if ENVIRONMENTS.include?(@environment)
      @environment
    else
      ALIASES[@environment] || raise(
        NotImplementedError, 
        'Your environment is unknown. Please update alias mapping!'
      )
    end
  end

  def to_s
    host = nil

    FALLBACKS[environment].each do |provider_environment|
      host = Setting["#{setting_namespace}.#{provider_environment}"]

      break if host
    end

    unless host
      raise(
        NotImplementedError, 
        'The API provider does not support your environment!'
      )
    end

    host
  end
end

P.S.: In the past I lived with always setting a host for each environment and without environment alias mapping but this was not DRY:

Setting.defaults[
  'apis.providers.volontariat.hosts.development'
] = 'http://localhost:3001'
Setting.defaults[
  'apis.providers.volontariat.hosts.test'
] = 'http://localhost:3001'
Setting.defaults[
  'apis.providers.volontariat.hosts.staging'
] = 'http://Volontari.at'
Setting.defaults[
  'apis.providers.volontariat.hosts.production'
] = 'http://Volontari.at'

# optional: I also put the little envionment alias mapping code here if you can't live without it
environment = { 
    dev: :development, testing: :test, stage: :staging, show: :staging, 
    live: :production, prod: :production 
  }[Rails.env.to_s.to_sym] || Rails.env
host = Setting[
  "apis.providers.volontariat.hosts.#{environment}"
]

Multi Step Form / Wizard Responses for Ajax (Modals) and HTML Requests

Therefor you only have to call this new method at the end of your controller action:

render_or_redirect_by_request_type

Then you can either set @path to redirect per Ajax for Ajax requests (@method is :get and @data is {} by default) or per redirect_to for other requests or set @template to render a template to a DOM element with selector @target for Ajax requests or render a template to the response body for other requests.

If you set @path for an Ajax request the targeted action should call "renderOrRedirectByRequestType" or respond with a JavaScript view to be evaluated.

Some conventions for Ajax requests if @path is present:

  • The response should return a JavaScript to be evaluated: in this action you should not use render_or_redirect_or_set_template but just use the default render behaviour and render action_name.js(.erb).
  • @target is "#bootstrap_modal" by default.
  • "@target _ is _ modal" is set to true by default and will wrap the template by a Twitter Bootstrap Modal layout with title set to @modal_title which is I18n.t("#{controller_name}.#{action_name}.title") by default. You can disable it.
  • @template_format is "html" by default and you can also set it to "js" and the response will be evaluated.

Further conventions for Ajax requests:

  • @path will be set to #action_name unless @path is present.
  • Flash messages with key :notice or :alert will be alerted.

application_controller.rb

private

def render_or_redirect_by_request_type
    if request.xhr? || request.env['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'
      render_javascript_response
    elsif @template.present?
      render @template
    elsif @path.present?
      redirect_to @path
    end
  end

def render_javascript_response
  @method ||= :get
  @data ||= {}
  @template ||= action_name unless @path.present?
  @template_format ||= 'html'
  @target ||= "#bootstrap_modal"
  @target_is_modal = @target_is_modal.nil? ? true : @target_is_modal
  @modal_title ||= I18n.t("#{controller_name}.#{action_name}.title")

  render partial: 'shared/javascript_response.js', layout: false
end

shared/ _ javascript _ response.js.erb

<% message = flash[:notice] || flash[:alert] %>
<% flash.delete(:notice); flash.delete(:alert) %>
<% alert = message.present? ? "alert('#{message}');" : '' %>

<% if @path.present? %>
  $.ajax({ url: "<%= @path %>", data: <%= raw @data.to_json %>, type: "<%= @method.to_s.upcase %>", dataType: "script"}).done(function(data) {
    eval(data);
    <%= raw alert %>
  })
  .fail(function(data) {
    <%= raw alert %>
    alert("Failed to load <%= @path %>!");
  });
<% elsif @template.present? %>
  <% if @template_format == 'html' %>
    <% if @target_is_modal %>
      $(@target).html("<%= escape_javascript(
        render(
          partial: 'shared/layouts/twitter_bootstrap/modal', 
          locals: { title: @modal_title, body: render(template: "#{controller_name}/#{@template}.html") }
        ) 
      ) %>");
    <% else %>
      $("<%= @target %>").html("<%= escape_javascript render(template: "#{controller_name}/#{@template}.html") %>");
    <% end %>
  <% elsif @template_format == 'js' %>
    <%= render template: "#{controller_name}/#{@template}.js" %>
  <% end %>
  <%= raw alert %>
<% elsif message.present? %>
  <%= raw alert %>
<% end %>

shared/layouts/ twitter_ bootstrap /_modal.html.erb

<% body ||= nil %>
<% footer ||= nil %>
<div class="modal-header">
  <button type="button" id="close_bootstrap_modal_button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
  <h3><%= title %></h3>
</div>
<div class="modal-body" style="overflow-y:none;">
  <%= body || yield(:modal_body) %>
</div>
<div class="modal-footer">
  <%= footer || yield(:modal_footer) %>
</div>

RSpec: compare rendered text like HTML or XML in view specs

I created some helper methods for RSpec which help you compare rendered text without checking each DOM element by xpath or css selectors.

You can use them like this:

describe 'example.html.erb' do
  it 'renders HTML like this' do
    render

    # looks for spec/fixtures/example.html
    compare_texts rendered, 'example.html'
  end
end

Fixtures can be initialized by passing preview option with true:

# will create or override file spec/fixtures/example.html
compare_texts rendered, 'example.html', true

This is the helper code which you can put in a .rb file under spec/support:

def compare_texts(got_string, expected_fixture_path, preview = false)
  if preview
    absolute_path = File.join(File.dirname(__FILE__), "../fixtures/#{expected_fixture_path.split('/')[0..-2].join('/')}")
    FileUtils::mkdir_p absolute_path
    File.open("#{absolute_path}/#{expected_fixture_path.split('/')[-1]}", 'w') { |file| file.write(got_string) }
    puts "#{expected_fixture_path} created."
  else
    expect(strip_text(got_string)).to be == strip_text(load_fixture(expected_fixture_path))
  end
end

def load_fixture(path)
  path = File.join(File.dirname(__FILE__), "../fixtures/#{path}")
  File.open(path).read
end

def strip_text(text, remove_empty_lines = true)
  text = text.strip.split("\n").map(&:strip)

  text.delete_if{|line| line == '' } if remove_empty_lines

  text.join("")
end

P.S.: You can use e.g. for comparison of expected and got text if example fails.

Convention: Tag RubyGems Tweet about your Gem Release and "tiny link" Changelog Diff

@rubygems is tweeting the description of the gemspec for each release.

So you can add hashtags to your description and I also recommend to add a link to a Diff of the CHANGELOG.md at the end if present.

voluntary.gemspec

Gem::Specification.new do |s|
  ...
  s.summary     = 'Crowdsourcing management system for Rails'
  s.description = '#Crowdsourcing management system for #Ruby on #Rails changes: http://bit.ly/voluntary-0-2-3'
  ...

The tweet looks like this

P.S.: How to get a "rich diff" of a CHANGELOG.md

Visit the GitHub show page of the commit and then copy the address of the link "Display the rich diff" shown in the image:

You now need a short customized URL e.g. from bitly (you need to be signed in).
Then you can create a new short URL by pasting the address in the field at the top with the placeholder "Paste a long URL here to shorten...", press enter, input the gem name plus semantic version like this "#{name}-#{major}-#{minor}-#{patch}" in the keyword-field with the placeholder "customize" located in the right sidebar and press "Save".