-
# encoding: UTF-8
-
1
require 'json'
-
1
require 'fileutils'
-
1
require 'sinatra/flash'
-
1
require 'sinatra/contrib'
-
1
require 'redis'
-
1
require 'rest-client'
-
1
require 'open-uri'
-
1
require 'addressable/uri'
-
1
require 'fog'
-
1
require 'octokit'
-
1
require 'active_support/core_ext'
-
1
require 'airbrake'
-
1
require 'dotenv'
-
1
require 'sinatra/cross_origin'
-
-
1
DEFAULT_ENV = ENV['RACK_ENV'] || 'development'
-
1
Dotenv.load ".env.#{DEFAULT_ENV}", '.env'
-
-
1
require './lib/rack_catcher'
-
1
require './lib/redis_initializer'
-
1
require './lib/server-files'
-
1
require './models/project'
-
1
require './models/commit'
-
1
require './models/churn_result'
-
-
1
DEFERRED_SERVER_ENDPOINT = "http://git-hook-responder.herokuapp.com/"
-
1
DEFERRED_SERVER_TOKEN = ENV['DEFERRED_ADMIN_TOKEN']
-
1
DEFERRED_CHURN_TOKEN = ENV['DEFERRED_CHURN_TOKEN']
-
1
Octokit.client_id = ENV['DS_GH_Client_ID']
-
1
Octokit.client_secret = ENV['DS_GH_Client_Secret']
-
-
1
set :run, false if defined?(SKIP_SERVER)
-
1
set :public_folder, File.dirname(__FILE__) + '/public'
-
1
set :root, File.dirname(__FILE__)
-
1
enable :logging
-
1
enable :sessions
-
-
1
set :allow_origin, :any
-
1
set :allow_methods, [:get, :post, :options]
-
1
set :allow_credentials, true
-
1
set :max_age, "1728000"
-
1
set :expose_headers, ['Content-Type']
-
-
1
use Rack::Session::Cookie, key: 'churnsite',
-
path: '/',
-
expire_after: 24400,
-
1
secret: (ENV['DS_GH_Client_Secret'] || 'dev')
-
-
1
configure :development do
-
1
require "better_errors"
-
1
use BetterErrors::Middleware
-
1
BetterErrors.application_root = File.dirname(__FILE__)
-
1
enable :cross_origin
-
end
-
-
1
configure :production do
-
require 'newrelic_rpm'
-
Airbrake.configure do |config|
-
config.api_key = ENV['ERRBIT_API_KEY']
-
config.host = ENV['ERRBIT_HOST']
-
config.port = 80
-
config.secure = config.port == 443
-
end
-
use Rack::Catcher
-
use Airbrake::Rack
-
set :raise_errors, true
-
enable :cross_origin
-
end
-
-
1
helpers do
-
-
2
def partial template
-
1
erb template, :layout => false
-
1
end
-
-
end
-
-
1
before /.*/ do
-
1
if request.host.match(/herokuapp.com/)
-
redirect request.url.gsub("churn-site.herokuapp.com",'churn.picoappz.com'), 301
-
end
-
-
1
if request.url.match(/.json$/)
-
1
request.accept.unshift('application/json')
-
1
request.path_info = request.path_info.gsub(/.json$/,'')
-
end
-
-
end
-
-
## swaggerBase = "http://localhost:9292"
-
##~ swaggerBase = "http://churn.picoappz.com"
-
##~ root = source2swagger.namespace("api-docs")
-
##~ root.swaggerVersion = "1.2"
-
##~ root.apiVersion = "1.0"
-
##~ root.info = {title: "Churn API", description: "This api generates code churn reports to find volatile code in your project.", termsOfServiceUrl: "https://raw2.github.com/danmayer/churn-site/master/license.txt", contact: "[email protected]", license: "MIT", licenseUrl: "https://raw2.github.com/danmayer/churn-site/master/license.txt"}
-
##~ root.apis.add :path => "/churn", :description => "A churn code metrics api"
-
-
##~ s = source2swagger.namespace("churn")
-
##~ s.basePath = swaggerBase
-
##~ s.swaggerVersion = "1.2"
-
##~ s.apiVersion = "1.0"
-
##~ s.produces = ["application/json"]
-
##~ s.resourcePath = "/index"
-
-
## models
-
##~ s.models["MinProject"] = {:id => "MinProject", :properties => {:name => {:type => "string"}, :project_url => {:type => "string"}}}
-
##
-
##~ s.models["CommitUrl"] = {:id => "CommitUrl", :properties => {:commit_url => {:type => "string"}}}
-
##
-
##~ s.models["Project"] = {:id => "Project", :properties => {:name => {:type => "string"}, :commits => {:type => "array", :items => {"$ref" => "CommitUrl"}}}}
-
##
-
## s.models["FileChange"] = {:id => "FileChange", :properties => {:id => {:type => "string"}, :file_path => {:type => "string"}, :times_changed => {:type => "integer"}}}
-
##
-
## s.models["Churn"] = {:id => "Churn", :properties => {:id => {:type => "string"}, :changes => {"array" => {:items => { "$ref" => "FileChange"}}}}}
-
##
-
## s.models["ChurnResults"] = {:id => "ChurnResults", :properties => {:id => {:type => "string"}, :churn => {:type => "Churn"}}}
-
##
-
##~ s.models["Commit"] = {:id => "Commit", :properties => {:name => {:type => "string"}, :project_name => {:type => "string"}, :churn_results => {:type => "string"}}}
-
-
# redict to documentation index file
-
1
get '/docs/?' do
-
1
redirect '/docs/index.html'
-
end
-
-
# returns the api docs for the resource listing
-
1
get '/api-docs/?', :provides => [:json] do
-
1
res = File.read(File.join('public', 'api', 'api-docs.json'))
-
1
body res
-
1
status 200
-
end
-
-
# returns the api docs for each path
-
1
get '/api-docs/:api', :provides => [:json] do
-
1
if File.exists?(File.join('public', 'api', "#{params[:api].to_s}.json"))
-
1
res = File.read(File.join('public', 'api', "#{params[:api].to_s}.json"))
-
1
body res
-
1
status 200
-
else
-
body = "api endpoint doesn't exist"
-
status 404
-
end
-
end
-
-
##~ a = s.apis.add
-
##~ a.set :path => "/index", :produces => ["application/json"], :description => "Collection of churned projects."
-
##
-
##~ op = a.operations.add
-
##~ op.type = "array"
-
##~ op.items = { "$ref" => "MinProject"}
-
##
-
##~ op.set :method => "GET", :summary => "Returns all of the churn projects.", :deprecated => false, :nickname => "list_churn"
-
##~ op.summary = "Returns a list of all the churn projects"
-
-
1
["/", "/index"].each do |path|
-
2
get path, :provides => [:html, :json] do
-
1
@projects = Project.projects
-
1
respond_to do |format|
-
1
format.json {
-
1
Project.projects_as_json(@projects, request)
-
}
-
1
format.html { erb :index }
-
end
-
end
-
end
-
-
1
get '/about' do
-
1
erb :about
-
end
-
-
1
get '/instructions' do
-
1
erb :instructions
-
end
-
-
##~ a = s.apis.add
-
##~ a.set :path => "/{projectPath}/commits/{commitId}", :produces => ["application/json"], :description => "Access to a projects single commit data"
-
##
-
##~ op = a.operations.add
-
##~ op.type = "array"
-
##~ op.items = { "$ref" => "Commit"}
-
##~ op.set :method => "GET", :deprecated => false, :nickname => "get_project_commit"
-
##~ op.summary = "Returns a single commit by commit id and project_path"
-
##~ op.parameters.add :name => "projectPath", :description => "The project_name for which this commit belongs to", :type => "string", :required => true, :paramType => "path"
-
##~ op.parameters.add :name => "commitId", :description => "The commit id which points to this commit data", :type => "string", :required => true, :paramType => "path"
-
1
get '/*/commits/*', :provides => [:html, :json] do |project_path, commit|
-
1
@project = Project.get_project(project_path)
-
1
@commit = Commit.get_commit(@project.name, commit) if @project
-
1
if @project && @commit
-
1
respond_to do |format|
-
1
format.json { @commit.as_hash(request).to_json }
-
1
format.html { erb :commit }
-
end
-
elsif @project
-
flash[:error] = 'project commit not found'
-
redirect "/#{@project.name}/"
-
else
-
flash[:error] = 'project for the commit not found'
-
redirect "/"
-
end
-
end
-
-
1
post '/*/commits/*' do |project_name, commit|
-
1
@project = Project.get_project(project_name)
-
1
@commit = Commit.get_commit(@project.name, commit)
-
1
rechurn = params['rechurn'] || 'true'
-
1
if @project && commit
-
1
project_data = Octokit.repo project_name
-
1
begin
-
1
gh_commit = Octokit.commits(project_name, nil, :sha => commit).first
-
rescue Octokit::NotFound, Octokit::BadGateway
-
msg = "commit not found, likely not on master branch (currently only supports master branch)"
-
flash[:error] = msg
-
redirect '/'
-
end
-
1
commit = gh_commit['sha']
-
1
commit_data = gh_commit
-
1
puts "sending commit #{commit} with rechurn #{rechurn}"
-
1
find_or_create_project(project_name, project_data, commit, commit_data, :rechurn => rechurn)
-
1
flash[:notice] = 'project rechurning'
-
1
if rechurn=='false'
-
"success"
-
else
-
1
redirect "/#{@project.name}/commits/#{@commit.name}"
-
end
-
else
-
puts "error project #{project_name} commit #{commit}"
-
flash[:error] = 'project or commit not found'
-
redirect '/'
-
end
-
end
-
-
##~ a = s.apis.add
-
##~ a.set :path => "/churn/{project_path}", :produces => ["application/json"], :description => "Starts generating churn report against HEAD of project_path"
-
##
-
##~ op = a.operations.add
-
##~ op.type = "Project"
-
##~ op.set :method => "POST", :deprecated => false, :nickname => "churn_project"
-
##~ op.summary = "Starts generating churn report against HEAD of project_path"
-
##~ op.parameters.add :name => "project_path", :description => "The project_name for which a churn report will be generated against", :type => "string", :allowMultiple => false, :required => true, :paramType => "path"
-
##~ op.parameters.add :name => "existing", :description => "If we only need to add commit data as the report already exists", :type => "string", :allowMultiple => false, :required => false, :paramType => "query"
-
1
post '/churn/*', :provides => [:html, :json] do |project_path|
-
@project = Project.get_project(project_path)
-
if @project
-
begin
-
if params['existing']=='true'
-
commits = @project.sorted_commits.map{|commit| commit.name }
-
forward_to_deferred_server(@project.name, commits.join(','))
-
else
-
forward_to_deferred_server(@project.name, 'history')
-
end
-
respond_to do |format|
-
format.json { @project.as_hash(request).to_json }
-
format.html { flash[:notice] = 'project building history (refresh soon)' }
-
end
-
rescue RestClient::InternalServerError, RestClient::ResourceNotFound => error
-
puts "error on #{project_path} error #{error}"
-
flash[:error] = 'error creating project history, try again'
-
end
-
redirect "/#{@project.name}"
-
else
-
flash[:error] = 'churn project not found'
-
redirect '/'
-
end
-
end
-
-
1
get '/chart/*' do |project_path|
-
1
project = Project.get_project(project_path)
-
-
1
if project
-
1
@chartdata = project.churn_chart_json
-
1
erb :chart, :layout => false
-
else
-
flash[:error] = 'project to chart not found'
-
redirect '/'
-
end
-
end
-
-
##~ a = s.apis.add
-
##~ a.set :path => "/{project_name}", :produces => ["application/json"], :description => "Access to a churn project"
-
##
-
##~ op = a.operations.add
-
##~ op.type = "Project"
-
##~ op.set :method => "GET", :deprecated => false, :nickname => "get_project"
-
##~ op.summary = "Returns a single churn project by project_name"
-
##~ op.parameters.add :name => "project_name", :description => "The project_name of the churn project to be returned", :type => "string", :allowMultiple => false, :required => true, :paramType => "path"
-
##
-
## Declaring errors for the operation
-
##~ err = op.responseMessages.add
-
##~ err.set :message => "no project found", :code => 404
-
1
get '/*', :provides => [:html, :json] do |project_path|
-
1
@project = Project.get_project(project_path)
-
1
if @project
-
1
respond_to do |format|
-
1
format.json { @project.as_hash(request).to_json }
-
1
format.html { erb :project }
-
end
-
else
-
1
if project_path.strip.length > 0 && project_path!='favicon.ico'
-
1
respond_to do |format|
-
1
format.json { halt 404, json({ message: "no project found" }) }
-
1
format.html { flash[:error] = "existing project not found, please add it" }
-
end
-
end
-
redirect '/'
-
end
-
end
-
-
-
##~ a = s.apis.add
-
##~ a.set :path => "/projects/add", :produces => ["application/json"], :description => "Create a new churn project resource"
-
##
-
##~ op = a.operations.add
-
##~ op.type = "string"
-
##~ op.set :method => "POST", :deprecated => false, :nickname => "create_project"
-
##~ op.summary = "creates a new churn project by project_name"
-
##~ op.parameters.add :name => "project_name", :description => "The project_name of the churn project to be created", :type => "string", :allowMultiple => false, :required => true, :paramType => "query"
-
##
-
1
post '/projects/add*', :provides => [:html, :json] do
-
1
project_name = params && params['project_name']
-
#fix starting with a slash if cleint passed with a slash
-
1
project_name = project_name[1...project_name.length] if project_name[0]=='/'
-
1
if project_name && project_name.length > 0
-
1
begin
-
1
project_data = Octokit.repo project_name
-
1
if project_data
-
1
gh_commits = Octokit.commits(project_name)
-
1
if gh_commits.any?
-
1
gh_commit = gh_commits.first
-
1
commit = gh_commit['sha']
-
1
commit_data = gh_commit
-
1
find_or_create_project(project_name, project_data, commit, commit_data)
-
1
flash[:notice] = "project #{project_name} created"
-
else
-
flash[:error] = "no commits found on github for #{project_name}"
-
end
-
else
-
flash[:error] = "project #{project_name} couldn't be found on github"
-
end
-
rescue RestClient::InternalServerError, RestClient::ResourceNotFound => error
-
error_msg = "error adding #{project_name} error #{error}"
-
puts error_msg
-
flash[:error] = error_msg
-
rescue Octokit::NotFound
-
flash[:notice] = "project not found try without full url or initial slash EX:'danmayer/churn'"
-
end
-
else
-
flash[:notice] = 'project name required'
-
end
-
1
respond_to do |format|
-
1
format.json { flash[:notice] || flash[:error] }
-
1
format.html { redirect '/' }
-
end
-
end
-
-
#handles github post push webhook calls
-
1
post '/' do
-
1
if params['payload']
-
1
receive_github_payload
-
else
-
receive_churn_client_payload
-
end
-
end
-
-
1
private
-
-
2
def receive_github_payload
-
1
puts "receiving github post commit hook payload"
-
1
push = JSON.parse(params['payload'])
-
1
project_url = push['repository']['url']
-
1
project_name = project_url.gsub(/.*com\//,'')
-
1
project_data = push['repository']
-
1
commit = push['after']
-
1
commit_data = push['commits'].detect{|a_commit| a_commit['id']==commit }
-
1
find_or_create_project(project_name, project_data, commit, commit_data)
-
1
end
-
-
1
def receive_churn_client_payload
-
puts "receiving churn client payload"
-
results = JSON.parse(params['results'])
-
if results
-
project_name = results['name']
-
commit = results['revision']
-
churn_results = results['data']
-
begin
-
project_data = Octokit.repo project_name
-
rescue Octokit::NotFound
-
#non public project, ignore other project data besides the name
-
project_data = {}
-
end
-
commit_data =
-
begin
-
gh_commit = Octokit.commits(project_name, nil, :sha => commit).first
-
rescue Octokit::NotFound, Octokit::BadGateway
-
#non public project, ignore other project data besides the name
-
commit_data = {'sha' => commit,
-
'timestamp' => Time.now,
-
'message' => 'unknown: pushed via churn',
-
'author' => 'unknown: pushed via churn'
-
}
-
end
-
find_or_create_project(project_name, project_data, commit, commit_data, :rechurn => 'false')
-
ChurnResult.new(project_name, commit).write_results(churn_results)
-
puts "save churn results #{churn_results}"
-
'OK'
-
else
-
msg = 'params for churn results must be wrapped in a results param'
-
puts msg
-
msg
-
end
-
end
-
-
2
def find_or_create_project(project_name, project_data, commit, commit_data, options = {})
-
1
if project = Project.get_project(project_name)
-
1
project.update(project_data)
-
1
project.add_commit(commit, commit_data)
-
1
if options[:rechurn]==nil || options[:rechurn]=='true'
-
1
puts "forwarding commit to deffered_server #{commit}"
-
1
forward_to_deferred_server(project.name, commit)
-
end
-
else
-
1
project = Project.add_project(project_name, project_data)
-
1
project.add_commit(commit, commit_data)
-
1
if options[:rechurn]==nil || options[:rechurn]=='true'
-
1
forward_to_deferred_server(project.name, commit)
-
1
forward_to_deferred_server(project.name, 'history')
-
end
-
end
-
1
project.clear_caches
-
1
end
-
-
2
def forward_to_deferred_server(project, commit, options = {})
-
1
attempts = 0
-
1
begin
-
1
request_timeout = options.fetch(:timeout){ 6 }
-
1
request_open_timeout = options.fetch(:open_timeout){ 6 }
-
1
resource = RestClient::Resource.new(DEFERRED_SERVER_ENDPOINT,
-
:timeout => request_timeout,
-
:open_timeout => request_open_timeout)
-
-
1
resource.post(:signature => DEFERRED_SERVER_TOKEN,
-
:project => project,
-
:commit => commit,
-
:command => 'churn --yaml')
-
1
rescue RestClient::ResourceNotFound
-
attempts +=1
-
retry if attempts < 3
-
rescue RestClient::RequestTimeout
-
1
attempts +=1
-
1
retry if attempts < 2
-
1
puts "timed out during deferred-server hit"
-
end
-
1
end
-
1
module Rack
-
1
class Catcher
-
-
1
def initialize(app)
-
@app = app
-
end
-
-
2
def call(env)
-
1
begin
-
1
response = @app.call(env)
-
rescue => ex
-
"error"
-
end
-
1
response
-
1
end
-
-
end
-
end
-
# encoding: utf-8
-
1
require 'date'
-
-
1
REDIS = if ENV['RACK_ENV']=='production'
-
Redis.new(:host => ENV["REDIS_HOST"], :port => ENV["REDIS_PORT"], :password => ENV["REDIS_PASSWORD"])
-
elsif ENV['RACK_ENV']=='test'
-
{}
-
else
-
1
Redis.new(:host => '127.0.0.1', :port => 6379)
-
end
-
-
1
class UsageCount
-
-
1
def self.increase
-
REDIS.incr(self.counter_key)
-
end
-
-
1
def self.get_count
-
REDIS.get(self.counter_key).to_i
-
end
-
-
1
def self.usage_remaining
-
80 - self.get_count
-
end
-
-
1
def self.counter_key
-
"churn:usage:#{Date.today}"
-
end
-
-
end
-
1
module ServerFiles
-
-
2
def connection
-
@connection ||= Fog::Storage.new(
-
:provider => 'AWS',
-
1
:aws_access_key_id => ENV['AMAZON_ACCESS_KEY_ID'],
-
1
:aws_secret_access_key => ENV['AMAZON_SECRET_ACCESS_KEY'])
-
1
end
-
-
2
def get_file(filename)
-
1
begin
-
1
file = directory.files.get(filename)
-
1
file.body
-
1
rescue => error
-
1
puts "error getting file #{error}"
-
1
''
-
end
-
1
end
-
-
1
def write_file(filename, body, options = {})
-
file_options = {
-
:key => filename,
-
:body => body,
-
:public => true
-
}
-
if options[:content_type]
-
file_options[:content_type] = options[:content_type]
-
end
-
file = directory.files.new(file_options)
-
file.save
-
end
-
-
2
def directory
-
1
directory = connection.directories.create(
-
:key => "deferred-server",
-
:public => true
-
)
-
1
end
-
-
end
-
1
require 'churn/churn_calculator'
-
-
1
class ChurnResult
-
1
include ServerFiles
-
1
MISSING_CHURN_RESULTS = 'churn results missing'
-
-
1
attr_accessor :project_name, :commit, :data
-
-
2
def initialize(project_name, commit)
-
1
@project_name = project_name
-
1
@commit = commit
-
1
end
-
-
2
def filename
-
1
"project_results/results_for_#{@project_name}_#{@commit}_churn"
-
1
end
-
-
1
def write_results(churn_results)
-
churn_results = churn_results.to_json unless churn_results.is_a?(String)
-
write_file(filename, churn_results)
-
end
-
-
2
def data
-
@data ||= begin
-
1
@data = get_file(filename)
-
1
if @data && @data!=''
-
1
@data = JSON.parse(@data)
-
#old data wasn't a hash but a string ignore old data
-
1
MISSING_CHURN_RESULTS if @data.is_a?(String)
-
1
@data
-
else
-
1
MISSING_CHURN_RESULTS
-
end
-
1
end
-
1
end
-
-
2
def exists?
-
1
data!=MISSING_CHURN_RESULTS
-
1
end
-
-
2
def command
-
1
data['cmd_run']
-
1
end
-
-
2
def exit_status
-
1
data['exit_status']
-
1
end
-
-
2
def results
-
1
data['results']
-
1
end
-
-
2
def formatted_results
-
1
@formatted_results ||= Churn::ChurnCalculator.to_s(parsed_results[:churn])
-
1
rescue Psych::SyntaxError
-
1
"error parsing results:\n #{results}"
-
rescue TypeError
-
1
"error in results:\n #{results}"
-
1
end
-
-
2
def parsed_results
-
1
if data['churn']
-
1
HashWithIndifferentAccess.new(data)
-
else
-
1
yaml_results
-
end
-
1
end
-
-
2
def yaml_results
-
1
@yaml_results ||= YAML.load(data['results'].gsub(/(.*)---/m,'---'))
-
1
end
-
-
2
def file_changes
-
1
parsed_results[:churn][:changes]
-
1
rescue Psych::SyntaxError, TypeError
-
1
nil
-
rescue TypeError
-
nil
-
1
end
-
-
2
def class_changes
-
1
parsed_results[:churn][:class_churn]
-
rescue Psych::SyntaxError, TypeError
-
nil
-
rescue TypeError
-
nil
-
1
end
-
-
2
def method_changes
-
1
parsed_results[:churn][:method_churn]
-
rescue Psych::SyntaxError, TypeError
-
nil
-
rescue TypeError
-
nil
-
1
end
-
-
2
def file_changes_count
-
1
file_changes ? file_changes.length : 0
-
1
end
-
-
2
def class_changes_count
-
1
class_changes ? class_changes.length : 0
-
1
end
-
-
2
def method_changes_count
-
1
method_changes ? method_changes.length : 0
-
1
end
-
-
2
def avg_churn_file_count
-
#todo normal the hash it is symbols here and string elsewhere
-
1
sum = parsed_results[:churn][:changes].sum{|item| item[:times_changed].to_i}
-
1
(sum.to_f / file_changes_count.to_f).round(2)
-
1
rescue Psych::SyntaxError, TypeError, FloatDomainError
-
1
nil
-
rescue TypeError
-
nil
-
1
end
-
-
2
def avg_churn_class_count
-
1
sum = parsed_results[:churn][:class_churn].sum{|item| item["times_changed"].to_i}
-
1
(sum.to_f / class_changes_count.to_f).round(2)
-
1
rescue Psych::SyntaxError, TypeError, FloatDomainError
-
1
nil
-
rescue TypeError
-
nil
-
1
end
-
-
2
def avg_churn_method_count
-
1
sum = parsed_results[:churn][:method_churn].sum{|item| item["times_changed"].to_i}
-
1
(sum.to_f / method_changes_count.to_f).round(2)
-
1
rescue Psych::SyntaxError, TypeError, FloatDomainError
-
1
nil
-
rescue TypeError
-
nil
-
1
end
-
-
2
def high_churn_file_count
-
1
parsed_results[:churn][:changes].select{|item| item[:times_changed].to_f > avg_churn_file_count.to_f}.length
-
1
rescue Psych::SyntaxError, TypeError
-
1
nil
-
rescue TypeError
-
nil
-
1
end
-
-
2
def high_churn_class_count
-
1
parsed_results[:churn][:class_churn].select{|item| item["times_changed"].to_f > avg_churn_class_count.to_f}.length
-
1
rescue Psych::SyntaxError, TypeError
-
1
nil
-
rescue TypeError
-
nil
-
1
end
-
-
2
def high_churn_method_count
-
1
parsed_results[:churn][:method_churn].select{|item| item["times_changed"].to_f > avg_churn_method_count.to_f}.length
-
1
rescue Psych::SyntaxError, TypeError
-
1
nil
-
rescue TypeError
-
nil
-
1
end
-
-
end
-
1
class Commit
-
1
REDIS_KEY = 'churn-commits'
-
-
2
def self.commits_key(project_name)
-
1
"#{REDIS_KEY}:#{project_name}"
-
1
end
-
-
2
def self.commits(project_name)
-
1
REDIS.hkeys(commits_key(project_name))
-
1
end
-
-
2
def self.get_sorted_commits_with_details(project_name)
-
1
commits = commits(project_name)
-
1
sorted_commits = []
-
1
commits.each do |commit|
-
1
sorted_commits << get_commit(project_name, commit)
-
end
-
1
sorted_commits.compact.sort{|commit_a, commit_b| commit_b.commit_time <=> commit_a.commit_time }
-
1
end
-
-
2
def self.add_commit(project_name, commit, data)
-
1
REDIS.hset(commits_key(project_name), commit, data.to_json)
-
1
end
-
-
1
def self.remove_commit(project_name, commit)
-
REDIS.hdel(commits_key(project_name), commit)
-
end
-
-
2
def self.get_commit(project_name, commit)
-
1
commit_data = REDIS.hget(commits_key(project_name), commit)
-
1
if commit_data
-
1
Commit.new(project_name, commit, commit_data)
-
else
-
nil
-
end
-
1
end
-
-
2
def initialize(project_name, commit, data)
-
1
@project_name = project_name
-
1
@commit = commit
-
1
@data = JSON.parse(data)
-
1
end
-
-
2
def name
-
1
@commit
-
1
end
-
-
2
def data
-
1
@data
-
1
end
-
-
2
def commit_time
-
1
begin
-
1
if data['timestamp']
-
1
Time.parse(data['timestamp'])
-
else
-
1
Time.parse(data['commit']['committer']['date'])
-
end
-
rescue
-
Time.now
-
end
-
1
end
-
-
2
def message
-
1
if data['message']
-
1
data['message']
-
else
-
1
data['commit']['message']
-
end
-
1
end
-
-
2
def author
-
1
if data['author']
-
1
data['author']['login']
-
else
-
1
data['commit']['author']['login']
-
end
-
1
end
-
-
2
def formatted_commit_time
-
1
commit_time.strftime("%m/%d/%Y at %I:%M%p")
-
1
end
-
-
2
def short_formatted_commit_datetime
-
1
commit_time.strftime("%m/%d/%Y")
-
1
end
-
-
2
def churn_results
-
1
ChurnResult.new(@project_name, @commit)
-
1
end
-
-
1
def update(data)
-
REDIS.hset(commits_key(@project_name), @commit, data.to_json)
-
@data = JSON.parse(data.to_json)
-
end
-
-
1
def as_hash(request)
-
{
-
:project_name => @project_name,
-
:name => name,
-
:churn_results => churn_results.try(:yaml_results)
-
}
-
end
-
-
1
private
-
-
-
end
-
1
class Project
-
1
REDIS_KEY = 'churn-projects'
-
-
2
def self.projects
-
1
REDIS.hkeys(REDIS_KEY)
-
1
end
-
-
2
def self.projects_as_json(projects, request)
-
1
projects.map{|proj| {:name => proj, :project_url => "#{request.url.gsub(/#{request.path}/,'')}/#{proj}.json"} }.to_json
-
1
end
-
-
2
def self.add_project(name, data)
-
1
REDIS.hset(REDIS_KEY, name, data.to_json)
-
1
Project.new(name, data.to_json)
-
1
end
-
-
1
def self.remove_project(name)
-
REDIS.hdel(REDIS_KEY, name)
-
end
-
-
2
def self.get_project(name)
-
1
project_data = REDIS.hget(REDIS_KEY, name)
-
1
if project_data
-
1
Project.new(name, project_data)
-
else
-
nil
-
end
-
1
end
-
-
2
def initialize(name, data)
-
1
@name = name
-
1
@data = JSON.parse(data)
-
1
end
-
-
2
def name
-
1
@name
-
1
end
-
-
2
def update(data)
-
1
REDIS.hset(REDIS_KEY, @name, data.to_json)
-
1
@data = JSON.parse(data.to_json)
-
1
end
-
-
1
def commits
-
@commits ||= Commit.commits(@name)
-
end
-
-
2
def sorted_commits
-
1
@sorted_commits ||= Commit.get_sorted_commits_with_details(@name)
-
1
end
-
-
2
def add_commit(commit, data)
-
1
Commit.add_commit(@name, commit, data)
-
1
end
-
-
1
def as_hash(request)
-
{
-
:name => name,
-
:commits => sorted_commits.map{|commit| {:commit_url => "#{request.url.gsub(/#{request.path}/,'')}/#{name}/commits/#{commit.name}.json" } }
-
}
-
end
-
-
2
def churn_chart_json
-
1
chartdata = REDIS.get("project_#{name}_chart_data")
-
1
json_try = true
-
1
begin
-
1
bad_json_data = ['',"\"\"",'""','null','"\"\""']
-
1
if json_try==true && chartdata && !bad_json_data.include?(chartdata.strip)
-
1
chartdata = JSON.parse(chartdata)
-
else
-
1
series_labels = []
-
1
series_data = []
-
1
sorted_commits.reverse.map do |commit|
-
1
if !series_labels.include?(commit.short_formatted_commit_datetime)
-
1
churn_results = commit.churn_results
-
1
if churn_results.exists? && churn_results.file_changes!=nil
-
1
series_labels << commit.short_formatted_commit_datetime
-
1
series_data << churn_results.file_changes_count
-
end
-
end
-
end
-
-
1
chartdata = {
-
labels: series_labels,
-
datasets: [
-
{
-
fillColor: "rgba(151,187,205,0.5)",
-
strokeColor: "rgba(151,187,205,1)",
-
data: series_data
-
}
-
]
-
}
-
-
1
REDIS.set("project_#{name}_chart_data", chartdata.to_json)
-
end
-
rescue JSON::ParserError => error
-
puts "json error #{error} json looked like: #{chartdata}"
-
json_try = false
-
retry
-
end
-
1
chartdata
-
1
end
-
-
2
def clear_caches
-
1
REDIS.set("project_#{name}_chart_data", nil)
-
1
end
-
-
1
private
-
-
-
end
-
1
<h1>Code Churn</h1>
-
1
-
1
<p>
-
1
There are many metrics that can help identify potentially problematic code. Code churn is how often code is changing over time. One metric that has a high correlation with buggy code is high-churn code. High churn doesn't always mean bad, but it can help point to potential 'hotspots' in your code ripe for bugs.
-
1
</p>
-
1
-
1
<table>
-
1
<tr>
-
1
<td width="50%">
-
1
<img src="/images/churn.jpg" alt="churn" />
-
1
<br/>
-
1
image courtesy of <a href="http://www.flickr.com/photos/zooboing/4402413305/sizes/o/in/photostream/">zooboing</a>
-
1
</td>
-
1
<td width="50%">
-
1
<blockquote class="pull-right" style="display:inline-block">
-
1
Code churn metrics were found to be among the most highly correlated with problem reports
-
1
<small>
-
1
<a href="http://research.microsoft.com/pubs/69126/icse05churn.pdf">
-
1
<cite title="Use of Relative Code Churn Measures to Predict System Defect Density">Use of Relative Code Churn Measures to Predict System Defect Density, Microsoft Research</cite>
-
1
</a>
-
1
</small>
-
1
</blockquote>
-
1
</td>
-
1
</tr>
-
1
</table>
-
1
<div class="clearfix"><br/></div>
-
1
-
1
<p>
-
1
Code with high churn often means that it is closely couple with other parts of the system. Which means when any other part of the code is changed the high churn code also needs to be changed. That isn't really a problem if it is a well tested interface or configuration file that is expected to change often. It is a problem if it is a crazy conditional soup that is copied multiple places in the code. Often a class or function that has high churn is violating the <a href="http://en.wikipedia.org/wiki/Single_responsibility_principle">Single Responsibility Principle</a>. Churn lets you see the frequency of sections of your code as they change over time.
-
1
<br/><br/>
-
1
</p>
-
1
-
1
<blockquote class="pull-right">
-
1
Quite often, metrics views of code are restricted to static measures of code quality. Adding the time dimension through version-control history gives us a broader view. We can use that view to guide our refactoring decisions.
-
1
<small>
-
1
<a href="http://www.stickyminds.com/sitewide.asp?Function=edetail&ObjectType=COL&ObjectId=16679&tth=DYN&tt=siteemail&iDyn=2">
-
1
<cite title="wikipedia article">Getting Empirical about Refactoring, Michael Feathers</cite>
-
1
</a>
-
1
</small>
-
1
</blockquote>
-
1
<div class="clearfix"></div>
-
1
-
1
<h3>Picoappz Code Churn Implementation</h3>
-
1
<p>
-
1
Currently this site is focused on Ruby projects, it uses the <a href="https://github.com/danmayer/churn">churn gem</a> to power it's data. The code, is a small open source Sinatra app called <a href="https://github.com/danmayer/churn-site">churn-site</a>, which is running on Heroku. The churn gem analyzes a commit in a Git repo and notes which files, classes, and methods were changed. While it does support files in any language it currently only supports class and method level churn for Ruby code.
-
1
<br/><br/>
-
1
This project makes it easier to run churn over a series of commits to build up a list of the highest churn classes and methods (files you can get at any point in time just using git log). The site also helps chart the number of files with high churn over time. I plan on adding some additional visualizations to better extract value from the information in the future. I think finding methods with high churn compared to the average churn in a project can be valuable. Overall file count with churn over a threshold can give some insight to the size and velocity of the project.Much of the churn data isn't support valuable on it's own. I think breaking the data down more as Michael Feathers suggests, is where churn data can be mixed with other data to make it more valuable. Many of the projects mentioned below try to do just that, making the metrics more. OK, so why aren't more code metrics included here?
-
1
<br/><br/>
-
1
This is just a personal side project and I am trying to keep it simple and solve one problem well, code churn. I want to track churn over the history of a project at the file, class, and method level. I want to provide a API to add and fetch the churn data. I want to add support for multiple languages. I have seen to many projects crumble under the weight of to many features, I want to keep this project simple.
-
1
<br/><br/>
-
1
I am hoping that additional features can be built utilizing data provided by the API, and that this can explore a different SOA approach to building code metrics than currently existing tools. If your interested in integrating a tool or collecting churn data across projects, let me know.
-
1
</p>
-
1
-
1
<h3>Churn alternatives / Tools utilizing code churn</h3>
-
1
<p>
-
1
There are a number of good tools that give access to churn data. The <a href="https://github.com/danmayer/churn">churn gem</a> (same that powers this project) is integrated into <a href="http://metric-fu.rubyforge.org/">Metric Fu</a>, which combines a number of useful code metrics. It helps make sense of many of the metrics and can build graphs of the metrics over time. Not up for managing metrics for your own project? Definitely check out, <a href="https://codeclimate.com/">Code Climate</a>. Code Climate takes churn into account for it's hotspots. It is similar to Metric Fu but much more polished and provides more actionable and readable data. Also, Code Climate is free for Open Source software! Another, simpler approach than more fancy code metrics dashboards is <a href="http://chadfowler.com/">Chad Fowler's</a> <a href="https://github.com/chad/turbulence">Turbulence Gem</a> which graphs churn vs. code complexity. Turbulence is an implementation of the ideas explained in the <a href="http://www.stickyminds.com/sitewide.asp?Function=edetail&ObjectType=COL&ObjectId=16679&tth=DYN&tt=siteemail&iDyn=2">Getting Empirical about Refactoring</a> article I cited above.
-
1
</p>
-
1
<canvas id="BarChart" width="700" height="340" data-bardata='<%= @chartdata.to_json %>'></canvas>
-
1
<h3><a href="/<%= @project.name %>"><%= @project.name %></a></h3>
-
1
<h5>commit: <%= @commit.name %></h5>
-
1
<p>
-
1
message: <%= @commit.message %><br/>
-
1
author: <%= @commit.author %><br/>
-
1
date: <%= @commit.formatted_commit_time %>
-
1
</p>
-
1
-
1
<h4>Actions</h4>
-
1
<ul>
-
1
<li>
-
1
<a href="http://github.com/<%= @project.name %>">
-
1
view <%= @project.name %> on github
-
1
</a>
-
1
</li>
-
1
<li>
-
1
<form action="/<%= @project.name %>/commits/<%= @commit.name %>" method="post">
-
1
<input type="submit" name="submit" value="re-churn commit" class="button stacked" />
-
1
</form>
-
1
</li>
-
1
</ul>
-
1
-
1
<% if ENV['RACK_ENV']=='development' %>
-
1
<pre>
-
1
<%= @commit.data.inspect %>
-
</pre>
-
<% end %>
-
1
-
1
<h3>Churn Data</h3>
-
1
-
1
<% @churn_results = @commit.churn_results %>
-
1
<% if @churn_results.exists? %>
-
1
-
1
<% @most_recent_results = @churn_results %>
-
1
<h5>Stats</h5>
-
1
<%= partial :commit_breakdown %>
-
1
<br/>
-
1
-
1
<% if @churn_results.command %>
-
1
<p>
-
1
<strong>command run:</strong> <code><%= @churn_results.command %></code>
-
1
</p>
-
1
<% end %>
-
1
-
1
<% if @churn_results.exit_status %>
-
1
<p>
-
1
<strong>command exit status:</strong> <code><%= @churn_results.exit_status %></code>
-
1
</p>
-
1
<% end %>
-
1
-
1
<pre><%= @churn_results.formatted_results %></pre>
-
1
-
1
<strong>files changes over threshold: <%= @churn_results.file_changes_count %></strong>
-
1
-
1
<% else %>
-
1
<pre>
-
1
<%= ChurnResult::MISSING_CHURN_RESULTS %>
-
1
</pre>
-
1
<% end %>
-
1
<ul>
-
1
<li>
-
1
<strong>High Churn Files</strong>
-
1
<%= @most_recent_results.high_churn_file_count %> (files with churn > avg file churn)
-
1
<br/><%= @most_recent_results.avg_churn_file_count %> (avg file churn)
-
1
</li>
-
1
<li><strong>High Churn Classes</strong>
-
1
<%= @most_recent_results.high_churn_class_count %> (classes with churn > avg class churn)
-
1
<br/><%= @most_recent_results.avg_churn_class_count %> (avg class churn)
-
1
</li>
-
1
<li><strong>High Churn Methods</strong>
-
1
<%= @most_recent_results.high_churn_method_count %> (methods with churn > avg method churn)
-
1
<br/><%= @most_recent_results.avg_churn_method_count %> (avg method churn)
-
1
</li>
-
1
</ul>
-
1
<h1>Code Churn: track code change velocity over time</h1>
-
1
<p>Churn can help you discover overly coupled code in your project. Learn more about <a href="/about">code churn</a> to see how it can help you understand your project better. Ready to get started? Add a project below or browse exsiting projects to get a better idea of what churn data can show you.</p>
-
1
-
1
<table>
-
1
<tr><td widht="50%" valign="top">
-
1
<h3>Add Project</h3>
-
1
-
1
<form action="/projects/add" method="post">
-
1
<label>Github path:
-
1
<input type="text" name="project_name" placeholder="danmayer/churn" />
-
1
</label>
-
1
<input type="submit" name="submit" value="Churn It" class="button stacked" />
-
1
</form>
-
1
-
1
</td><td width="50%" valign="top">
-
1
-
1
<h3>Projects tracking code churn: (<%= @projects.length %>)</h3>
-
1
<ul>
-
1
<% @projects.sort{|a,b| a.downcase <=> b.downcase }.each do |project| %>
-
1
<li><a href='/<%= project %>'><%= project %></a></li>
-
1
<% end %>
-
1
</ul>
-
1
</td></tr>
-
1
</table>
-
1
<h1>Code Churn Github Webhook</h1>
-
1
-
1
<p>
-
1
The easiest way to use this site it to just setup a webhook on your github project. You can add it manually (see below), but it won't keep your project up to date unless you add the webhook. Adding Github webhooks is simple visit your project's settings page and click 'Service Hooks' the type or paste 'http://churn.picoappz.com/' in the url. Click update settings and you are good to go. Click 'test hook' if you want to trigger churn immediately.
-
1
-
1
<strong>churn-site currently only works on the *master* branch, eventually I will add some configuration options to churn other branches.</strong>
-
1
</p>
-
1
-
1
<div style="text-align:center">
-
1
<img src="/images/churn_webook_settings.png" alt="churn webhook settings" />
-
1
<br/>
-
1
<small>
-
1
<a href="https://help.github.com/articles/post-receive-hooks">
-
1
Github Webhook Settings
-
1
</a>
-
1
</small>
-
1
</div>
-
1
-
1
<h3>Manually Add Project</h3>
-
1
-
1
<p>
-
1
If you want to just try it out first you can manually add your project on the home page by just filling in 'user_name/project_name' for your github project and it will pull your project down. It won't automatically run churn on any future commits unless you setup the webhook in github. You can always add the webhook later after your project has been added to churn app.
-
1
</p>
-
1
-
1
<div style="text-align:center">
-
1
<img src="/images/add_project.png" alt="add churn project" style="text-align:center" />
-
1
<br/>
-
1
<small>
-
1
<a href="/">
-
1
Add Churn Project
-
1
</a>
-
1
</small>
-
1
</div>
-
1
<!DOCTYPE html>
-
1
<html>
-
1
<head>
-
1
<title>Churn - Find code that is a moving target in your project</title>
-
1
<script src="/lib/jquery-1.7.2.min.js" type="text/javascript"></script>
-
1
<script src="/lib/bootstrap/js/bootstrap.min.js"></script>
-
1
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css">
-
1
<link rel="stylesheet" href="/css/application.css">
-
1
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon">
-
1
<link rel="icon" href="/favicon.ico" type="image/x-icon">
-
1
</head>
-
1
<body>
-
1
-
1
<div class="navbar navbar-fixed-top">
-
1
<div class="navbar-inner">
-
1
<div class="container">
-
1
-
1
<a class="brand" href="/">
-
1
Churn
-
1
</a>
-
1
-
1
<ul class="nav">
-
1
<li><a href="/instructions">instructions</a></li>
-
1
<li><a href="/about">about code churn</a></li>
-
1
</ul>
-
1
-
1
<ul class="nav pull-right">
-
1
</ul>
-
1
-
1
</div>
-
1
</div>
-
1
</div>
-
1
-
1
-
1
<div class="container">
-
1
<% if flash[:error] %>
-
1
<div class="alert alert-error"><%= flash[:error] %></div>
-
1
<% end %>
-
1
<% if notice = flash[:notice] %>
-
1
<div class="alert alert-notice"><%= flash[:notice] %></div>
-
1
<% end %>
-
1
<%= yield %>
-
1
</div>
-
1
-
1
<footer>
-
1
<div class="container">
-
1
built by <a href="http://mayerdan.com">Dan Mayer</a>, problem? <a href="https://github.com/danmayer/churn-site">code & issues</a> on github
-
1
<span class="right">a part of <a href="http://picoappz.com">picoappz</a></span>
-
1
</div>
-
1
</footer>
-
1
<script src="/javascript/chart.min.js"></script>
-
1
<script src="/javascript/application.js"></script>
-
1
-
1
<script>
-
1
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
-
1
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
-
1
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
-
1
})(window,document,'script','//www.google-analytics.com/analytics.js','ga');
-
1
ga('create', 'UA-42786883-2', 'picoappz.com');
-
1
ga('send', 'pageview');
-
1
</script>
-
1
</body>
-
1
</html>
-
1
<h3><a href="/<%= @project.name %>"><%= @project.name %></a></h3>
-
1
-
1
<h4>Actions</h4>
-
1
<ul class="actions">
-
1
<li>
-
1
<a href="http://github.com/<%= @project.name %>">
-
1
view <%= @project.name %> on github
-
1
</a>
-
1
</li>
-
1
<li>
-
1
<form action="/churn/<%= @project.name %>" method="post">
-
1
<input type="hidden" name="existing" value="true" />
-
1
<input type="submit" name="submit" value="rebuilt commits" class="button stacked" />
-
1
</form>
-
1
</li>
-
1
<li>
-
1
<form action="/churn/<%= @project.name %>" method="post">
-
1
<input type="submit" name="submit" value="build churn history" class="button stacked" />
-
1
</form>
-
1
</li>
-
1
</ul>
-
1
-
1
<% @most_recent_results = @project.sorted_commits.first.try(:churn_results) %>
-
1
<% if @most_recent_results %>
-
1
<h5>Current Stats</h5>
-
1
<%= partial :commit_breakdown %>
-
1
<% end %>
-
1
-
1
<h5>Churn History</h5>
-
1
-
1
<div class="chart-loading" data-url="/chart/<%= @project.name %>" style="width:700px; height:300px; text-align:center">
-
1
<img src="/images/ajax-loader.gif" id="loading-indicator" />
-
1
</div>
-
1
<h5 class="chart-label"># Files above churn threshold</h5>
-
1
-
1
<ul>
-
1
<% @project.sorted_commits.each do |commit| %>
-
1
<li>
-
1
<a href='/<%= @project.name %>/commits/<%= commit.name %>'>
-
1
<%= commit.name %>
-
1
</a>
-
1
(<%= commit.message %> @
-
1
<%= commit.formatted_commit_time %>)
-
1
</li>
-
1
<% end %>
-
1
</ul>