Skip to content

Commit

Permalink
Reworking burndown for clarity and performance
Browse files Browse the repository at this point in the history
  • Loading branch information
friflaj committed Sep 22, 2012
1 parent a855c8c commit 267b51a
Show file tree
Hide file tree
Showing 17 changed files with 423 additions and 171 deletions.
1 change: 0 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ gem 'json'
gem "system_timer" if RUBY_VERSION =~ /^1\.8\./ && RUBY_PLATFORM =~ /darwin|linux/

group :development do
gem "github-v3-api"
gem "inifile"
end

Expand Down
136 changes: 136 additions & 0 deletions app/models/rb_history.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
require 'pp'

class RbHistory < ActiveRecord::Base
set_table_name 'rb_history'
belongs_to :issue

serialize :history, Array
after_initialize :set_default_history


def to_a
self.history
end

def self.statuses
Hash.new{|h, k|
s = (IssueStatus.find_by_id(k.to_i) || IssueStatus.default)
h[k] = {:open => ! s.is_closed, :success => s.is_closed ? (s.default_done_ratio.nil? || s.default_done_ratio == 100) : nil }
h[k]
}
end

def filter(sprint, status=nil)
h = Hash[*(self.expand(status).collect{|d| [d[:date], d]}.flatten)]
(sprint.sprint_start_date .. sprint.effective_date + 1).to_a.collect{|d| h[d]}
end

def expand(status=nil)
h = self.history.dup

status ||= RbHistory.statuses
h << {
:date => Date.today + 1,
:estimated_hours => self.issue.estimated_hours,
:story_points => self.issue.story_points,
:remaining_hours => self.issue.remaining_hours,
:sprint => self.issue.fixed_version_id,
:status_open => status[self.issue.status_id][:open],
:status_success => status[self.issue.status_id][:success]
}

(0..h.size - 2).to_a.collect{|i|
(h[i][:date] .. h[i+1][:date] - 1).to_a.collect{|d|
h[i].merge(:date => d)
}
}.flatten
end

def self.process(source, status=nil)
if source.is_a?(Issue)
journals = source.journals
issue = source
fill = true
elsif source.is_a?(Journal)
journals = [source]
issue = source.issue
fill = false
else
return
end

startdate = issue.created_on.to_date
rb = (RbHistory.find_by_issue_id(issue.id) || RbHistory.new(:issue_id => issue.id))

if rb.history.size == 0
rb.history = [{:date => startdate}]
else
fill = false
end

status ||= self.statuses

journals.each{|journal|
date = journal.created_on.to_date
if date == startdate
date += 1 # value on start-of-first-day is oldest value
elsif journal.created_on.hour != 0 || journal.created_on.min != 0 || journal.created_on.sec != 0
date -= 1 # if it's not on midnight, assign values to end-of-previous-day
end

journal.details.each{|jd|
next unless jd.property == 'attr'

changes = [{:prop => jd.prop_key.intern, :old => jd.old_value, :new => jd.value}]

case changes[0][:prop]
when :estimated_hours, :story_points, :remaining_hours
[:old, :new].each{|k| changes[0][k] = Float(changes[0][k]) unless changes[0][k].nil? }
when :fixed_version_id
changes[0][:prop] = :sprint
[:old, :new].each{|k| changes[0][k] = Integer(changes[0][k]) unless changes[0][k].nil? }
when :status_id
changes << changes[0].dup
[:open, :success].each_with_index{|prop, i|
changes[i][:prop] = "status_#{prop}".intern
[:old, :new].each{|k| changes[i][k] = status[changes[i][k]][k] }
}
else
next
end

changes.each{|change|
rb.history[0][change[:prop]] = change[:old] unless rb.history[0].include?(change[:prop]) if fill
next if date <= startdate
rb.history << rb.history[-1].dup if date != rb.history[-1][:date]
rb.history[-1][:date] = date
rb.history.each{|h| h[change[:prop]] = change[:new] unless h.include?(change[:prop]) } if fill
}
}
}
rb.history.each{|h|
h[:estimated_hours] = issue.estimated_hours unless h.include?(:estimated_hours)
h[:story_points] = issue.story_points unless h.include?(:story_points)
h[:remaining_hours] = issue.remaining_hours unless h.include?(:remaining_hours)
h[:sprint] = issue.fixed_version_id unless h.include?(:sprint)
h[:status_open] = status[issue.status_id][:open] unless h.include?(:status_open)
h[:status_success] = status[issue.status_id][:success] unless h.include?(:status_success)
} if fill

rb.save
end

def self.rebuild
self.delete_all

status = self.statuses

Issue.all.each{|issue| RbHistory.process(issue, status) }
end

private

def set_default_history
self.history ||= []
end
end
3 changes: 0 additions & 3 deletions app/models/rb_journal.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
# Redmine - project management software
# Copyright (C) 2006-2011 Jean-Philippe Lang
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
Expand Down
210 changes: 210 additions & 0 deletions app/models/rb_stats.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.

class RbStats < ActiveRecord::Base
REDMINE_PROPERTIES = ['estimated_hours', 'fixed_version_id', 'status_id', 'story_points', 'remaining_hours']
JOURNALED_PROPERTIES = {
'estimated_hours' => :float,
'remaining_hours' => :float,
'story_points' => :float,
'fixed_version_id' => :int,
'status_open' => :bool,
'status_success' => :bool,
}

belongs_to :issue

def self.journal(j)
j.rb_journal_properties_saved ||= []

case Backlogs.platform
when :redmine
j.details.each{|detail|
next if j.rb_journal_properties_saved.include?(detail.prop_key)
next unless detail.property == 'attr' && RbJournal::REDMINE_PROPERTIES.include?(detail.prop_key)
j.rb_journal_properties_saved << detail.prop_key
create_journal(j, detail, j.journalized_id, j.created_on)
}

when :chiliproject
if j.type == 'IssueJournal'
RbJournal::REDMINE_PROPERTIES.each{|prop|
next if j.details[prop].nil?
create_journal(j, prop, j.journaled_id, j.created_on)
}
end
end
end

def self.create_journal(j, prop, issue_id, timestamp)
if journal_property_key(prop) == 'status_id'
begin
status = IssueStatus.find(journal_property_value(prop, j))
rescue ActiveRecord::RecordNotFound
status = nil
end
changes = [ { :property => 'status_open', :value => status && !status.is_closed },
{ :property => 'status_success', :value => status && !status.backlog_is?(:success) } ]
else
changes = [ { :property => journal_property_key(prop), :value => journal_property_value(prop, j) } ]
end
changes.each{|change|
RbJournal.new(:issue_id => issue_id, :timestamp => timestamp, :change => change).save
}
end

def self.journal_property_key(property)
case Backlogs.platform
when :redmine
return property.prop_key
when :chiliproject
return property
end
end

def self.journal_property_value(property, j)
case Backlogs.platform
when :redmine
return property.value
when :chiliproject
return j.details[property][1]
end
end

def self.rebuild(issue)
RbJournal.delete_all(:issue_id => issue.id)

changes = {}
RbJournal::REDMINE_PROPERTIES.each{|prop| changes[prop] = [] }

case Backlogs.platform
when :redmine
JournalDetail.find(:all, :order => "journals.created_on asc" , :joins => :journal,
:conditions => ["property = 'attr' and prop_key in (?)
and journalized_type = 'Issue' and journalized_id = ?",
RbJournal::REDMINE_PROPERTIES, issue.id]).each {|detail|
changes[detail.prop_key] << {:time => detail.journal.created_on, :old => detail.old_value, :new => detail.value}
}

when :chiliproject
# has to be here because the ChiliProject journal design easily allows for one to delete issue statuses that remain
# in the journal, because even the already-feeble rails integrity constraints can't be enforced. This also mean it's
# not really reliable to use statuses for historic burndown calculation. Those are the breaks if you let programmers
# do database design.
valid_statuses = IssueStatus.connection.select_values("select id from #{IssueStatus.table_name}").collect{|x| x.to_i}

issue.journals.reject{|j| j.created_at < issue.created_on}.each{|j|
RbJournal::REDMINE_PROPERTIES.each{|prop|
delta = j.changes[prop]
next unless delta
if prop == 'status_id'
next if changes[prop].size == 0 && !valid_statuses.include?(delta[0])
next unless valid_statuses.include?(delta[1])
end
changes[prop] << {:time => j.created_at, :old => delta[0], :new => delta[1]}
}
}
end

RbJournal::REDMINE_PROPERTIES.each{|prop|
if changes[prop].size > 0
changes[prop].unshift({:time => issue.created_on, :new => changes[prop][0][:old]})
else
changes[prop] = [{:time => issue.created_on, :new => issue.send(prop.intern)}]
end
}

issue_status = {}
changes['status_id'].collect{|change| change[:new]}.compact.uniq.each{|id|
begin
issue_status[id] = IssueStatus.find(Integer(id))
rescue ActiveRecord::RecordNotFound
issue_status[id] = nil
end
}

['status_open', 'status_success'].each{|p| changes[p] = [] }
changes['status_id'].each{|change|
status = issue_status[change[:new]]
changes['status_open'] << change.merge(:new => status && !status.is_closed?)
changes['status_success'] << change.merge(:new => status && status.backlog_is?(:success))
}
changes.delete('status_id')

changes.each_pair{|prop, updates|
updates.each{|change|
RbJournal.new(:issue_id => issue.id, :timestamp => change[:time], :change => {:property => prop, :value => change[:new]}).save
}
}
end

def to_s
"<#{RbJournal.table_name} issue=#{issue_id}: #{property}=#{value.inspect} @ #{timestamp}>"
end

def self.changes_to_s(changes, prefix = '')
s = ''
changes.each_pair{|k, v|
s << "#{prefix}#{k}\n"
v.each{|change|
s << "#{prefix} @#{change[:time]}: #{change[:new]}\n"
}
}
return s
end

def change=(prop)
self.property = prop[:property]
self.value = prop[:value]
end
def property
return self[:property].to_sym
end
def property=(name)
name = name.to_s
raise "Unknown journal property #{name.inspect}" unless RbJournal::JOURNALED_PROPERTIES.include?(name)
self[:property] = name
end

def value
v = self[:value]

# this test against blank *only* works when not storing string properties! Otherwise test against nil? here and handle
# blank? per-type
return nil if v.blank?

case RbJournal::JOURNALED_PROPERTIES[self[:property]]
when :bool
return (v == 'true')
when :int
return Integer(v)
when :float
return Float(v)
else
raise "Unknown journal property #{self[:property].inspect}"
end
end
def value=(v)
# this test against blank *only* works when not storing string properties! Otherwise test against nil? here and handle
# blank? per-type
self[:value] = v.blank? ? nil : case RbJournal::JOURNALED_PROPERTIES[self[:property]]
when :bool
v ? 'true' : 'false'
when :int, :float
v.to_s
else
raise "Unknown journal property #{self[:property].inspect}"
end
end
end
2 changes: 2 additions & 0 deletions app/models/rb_story.rb
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ def burndown(sprint=nil)
sprint ||= self.fixed_version.becomes(RbSprint) if self.fixed_version
return nil if sprint.nil? || !sprint.has_burndown?

tasks =

return Rails.cache.fetch("RbIssue(#{self.id}@#{self.updated_on}).burndown(#{sprint.id}@#{sprint.updated_on}-#{[Date.today, sprint.effective_date].min})") {
bd = {}

Expand Down
Loading

0 comments on commit 267b51a

Please sign in to comment.