central-support 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +13 -0
  3. data/.rspec +2 -0
  4. data/.travis.yml +14 -0
  5. data/Gemfile +3 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +49 -0
  8. data/Rakefile +6 -0
  9. data/bin/console +14 -0
  10. data/bin/setup +8 -0
  11. data/central-support.gemspec +30 -0
  12. data/lib/central/support.rb +27 -0
  13. data/lib/central/support/concerns/activity_concern.rb +4 -0
  14. data/lib/central/support/concerns/activity_concern/associations.rb +15 -0
  15. data/lib/central/support/concerns/activity_concern/callbacks.rb +25 -0
  16. data/lib/central/support/concerns/activity_concern/scopes.rb +55 -0
  17. data/lib/central/support/concerns/activity_concern/validations.rb +17 -0
  18. data/lib/central/support/concerns/project_concern.rb +5 -0
  19. data/lib/central/support/concerns/project_concern/associations.rb +27 -0
  20. data/lib/central/support/concerns/project_concern/attributes.rb +25 -0
  21. data/lib/central/support/concerns/project_concern/csv.rb +59 -0
  22. data/lib/central/support/concerns/project_concern/scopes.rb +26 -0
  23. data/lib/central/support/concerns/project_concern/validations.rb +43 -0
  24. data/lib/central/support/concerns/story_concern.rb +7 -0
  25. data/lib/central/support/concerns/story_concern/associations.rb +23 -0
  26. data/lib/central/support/concerns/story_concern/attributes.rb +29 -0
  27. data/lib/central/support/concerns/story_concern/callbacks.rb +50 -0
  28. data/lib/central/support/concerns/story_concern/csv.rb +81 -0
  29. data/lib/central/support/concerns/story_concern/scopes.rb +16 -0
  30. data/lib/central/support/concerns/story_concern/transitions.rb +71 -0
  31. data/lib/central/support/concerns/story_concern/validations.rb +45 -0
  32. data/lib/central/support/concerns/team_concern.rb +4 -0
  33. data/lib/central/support/concerns/team_concern/associations.rb +29 -0
  34. data/lib/central/support/concerns/team_concern/domain_validator.rb +19 -0
  35. data/lib/central/support/concerns/team_concern/scopes.rb +15 -0
  36. data/lib/central/support/concerns/team_concern/validations.rb +13 -0
  37. data/lib/central/support/concerns/user_concern.rb +3 -0
  38. data/lib/central/support/concerns/user_concern/associations.rb +21 -0
  39. data/lib/central/support/concerns/user_concern/callbacks.rb +39 -0
  40. data/lib/central/support/concerns/user_concern/validations.rb +14 -0
  41. data/lib/central/support/iteration.rb +51 -0
  42. data/lib/central/support/iteration_service.rb +213 -0
  43. data/lib/central/support/mattermost.rb +36 -0
  44. data/lib/central/support/validators/belongs_to_project_validator.rb +11 -0
  45. data/lib/central/support/validators/changed_validator.rb +7 -0
  46. data/lib/central/support/validators/estimate_validator.rb +11 -0
  47. data/lib/central/support/version.rb +5 -0
  48. metadata +146 -0
@@ -0,0 +1,15 @@
1
+ module Central
2
+ module Support
3
+ module TeamConcern
4
+ module Scopes
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ scope :not_archived, -> { where(archived_at: nil) }
9
+ scope :archived, -> { where.not(archived_at: nil) }
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
15
+
@@ -0,0 +1,13 @@
1
+ module Central
2
+ module Support
3
+ module TeamConcern
4
+ module Validations
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ validates :name, presence: true, uniqueness: true
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,3 @@
1
+ require 'central/support/concerns/user_concern/associations'
2
+ require 'central/support/concerns/user_concern/validations'
3
+ require 'central/support/concerns/user_concern/callbacks'
@@ -0,0 +1,21 @@
1
+ module Central
2
+ module Support
3
+ module UserConcern
4
+ module Associations
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ has_many :enrollments
9
+ has_many :teams, through: :enrollments
10
+
11
+ has_many :memberships, dependent: :destroy
12
+ has_many :projects, -> { uniq }, through: :memberships do
13
+ def not_archived
14
+ where(archived_at: nil)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,39 @@
1
+ module Central
2
+ module Support
3
+ module UserConcern
4
+ module Callbacks
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ attr_accessor :team_slug
9
+
10
+ before_validation :set_random_password_if_blank
11
+
12
+ after_save :set_team
13
+
14
+ before_destroy :remove_story_association
15
+ end
16
+
17
+ def set_random_password_if_blank
18
+ if new_record? && self.password.blank? && self.password_confirmation.blank?
19
+ self.password = self.password_confirmation = Digest::SHA1.hexdigest("--#{Time.current.to_s}--#{email}--")[0,8]
20
+ end
21
+ end
22
+
23
+ def set_team
24
+ if team_slug
25
+ team = Team.not_archived.find_by_slug(team_slug)
26
+ self.enrollments.create(team: team) if team
27
+ end
28
+ end
29
+
30
+ def remove_story_association
31
+ Story.where(requested_by_id: id).update_all(requested_by_id: nil, requested_by_name: nil)
32
+ Story.where(owned_by_id: id).update_all(owned_by_id: nil, owned_by_name: nil)
33
+ Membership.where(user_id: id).delete_all
34
+ end
35
+
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,14 @@
1
+ module Central
2
+ module Support
3
+ module UserConcern
4
+ module Validations
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ validates :name, :username, :initials, presence: true
9
+ validates :username, uniqueness: true
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,51 @@
1
+ module Central
2
+ module Support
3
+ # mimics the iteration.js counterpart
4
+ class Iteration < Array
5
+ attr_reader :start_date, :number, :maximum_points
6
+
7
+ def initialize(service, iteration_number, maximum_points = nil)
8
+ @service = service
9
+ @number = iteration_number
10
+ @maximum_points = maximum_points
11
+ @is_full = false
12
+ super([])
13
+ end
14
+
15
+ def points
16
+ self.reduce(0) { |total, story| total + ( story.estimate || 0 ) }
17
+ end
18
+
19
+ def available_points
20
+ maximum_points - points
21
+ end
22
+
23
+ def can_take_story?(story)
24
+ return true if %w(started finished delivered accepted rejected).include? story.state
25
+ return false if @is_full
26
+ return true if points == 0
27
+ return true if story.story_type != 'feature'
28
+
29
+ @is_full = (story.estimate || 0) > available_points
30
+ !@is_full
31
+ end
32
+
33
+ def overflows_by
34
+ difference = points - maximum_points
35
+ difference < 0 ? 0 : difference
36
+ end
37
+
38
+ def start_date
39
+ @service.date_for_iteration_number(@number)
40
+ end
41
+
42
+ def details
43
+ {
44
+ points: self.reduce(0) { |total, story| total + (story.estimate || 0) },
45
+ count: self.size,
46
+ non_estimable: self.select { |story| !Story::ESTIMABLE_TYPES.include?(story.story_type) }.size
47
+ }
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,213 @@
1
+ module Central
2
+ module Support
3
+ class IterationService
4
+ DAYS_IN_WEEK = (1.week / 1.day)
5
+ VELOCITY_ITERATIONS = 3
6
+
7
+ attr_reader :project
8
+
9
+ delegate :start_date, :start_date=,
10
+ :iteration_length, :iteration_length=,
11
+ :iteration_start_day, :iteration_start_day=,
12
+ to: :project
13
+
14
+ def initialize(project, since = nil)
15
+ @project = project
16
+
17
+ relation = project.stories.includes(:owned_by)
18
+ relation = relation.where('accepted_at > ? or accepted_at is null', since) if since
19
+ @stories = relation.to_a
20
+
21
+ @accepted_stories = @stories.
22
+ select { |story| story.column == '#done' }.
23
+ select { |story| story.accepted_at < iteration_start_date(Time.current) }
24
+
25
+ calculate_iterations!
26
+ fix_owner!
27
+
28
+ @stories.each { |s| s.iteration_service = self }
29
+ @backlog = ( @stories - @accepted_stories.select { |s| s.column == '#done' } ).sort_by(&:position)
30
+ end
31
+
32
+ def iteration_start_date(date = nil)
33
+ date = start_date if date.nil?
34
+ iteration_start_date = date.beginning_of_day
35
+ if start_date.wday != iteration_start_day
36
+ day_difference = start_date.wday - iteration_start_day
37
+ day_difference += DAYS_IN_WEEK if day_difference < 0
38
+
39
+ iteration_start_date -= day_difference.days
40
+ end
41
+ iteration_start_date
42
+ end
43
+
44
+ def iteration_number_for_date(compare_date)
45
+ compare_date = compare_date.to_time if compare_date.is_a?(Date)
46
+ days_apart = ( compare_date - iteration_start_date ) / 1.day
47
+ days_in_iteration = iteration_length * DAYS_IN_WEEK
48
+ ( days_apart / days_in_iteration ).floor + 1
49
+ end
50
+
51
+ def date_for_iteration_number(iteration_number)
52
+ difference = (iteration_length * DAYS_IN_WEEK) * (iteration_number - 1)
53
+ iteration_start_date + difference.days
54
+ end
55
+
56
+ def current_iteration_number
57
+ iteration_number_for_date(Time.current)
58
+ end
59
+
60
+ def calculate_iterations!
61
+ @accepted_stories.each do |record|
62
+ iteration_number = iteration_number_for_date(record.accepted_at)
63
+ iteration_start_date = date_for_iteration_number(iteration_number)
64
+ record.iteration_number = iteration_number
65
+ record.iteration_start_date = iteration_start_date
66
+ end
67
+ end
68
+
69
+ # FIXME must figure out why the Story allows a nil owner in delivered states
70
+ def fix_owner!
71
+ @dummy_user ||= User.find_or_create_by!(username: "dummy", email: "dummy@foo.com", name: "Dummy", initials: "XX")
72
+ @accepted_stories.
73
+ select { |record| record.owned_by.nil? }.
74
+ each { |record| record.owned_by = @dummy_user }
75
+ end
76
+
77
+ def group_by_iteration
78
+ @group_by_iteration ||= @accepted_stories.
79
+ group_by { |story| story.iteration_number }.
80
+ reduce({}) do |group, iteration|
81
+ group.merge(iteration.first => stories_estimates(iteration.last))
82
+ end
83
+ end
84
+
85
+ def stories_estimates(stories)
86
+ stories.map do |story|
87
+ if Story::ESTIMABLE_TYPES.include? story.story_type
88
+ story.estimate || 0
89
+ else
90
+ 0
91
+ end
92
+ end
93
+ end
94
+
95
+ def group_by_velocity
96
+ @group_by_velocity ||= group_by_iteration.reduce({}) do |group, iteration|
97
+ group.merge(iteration.first => iteration.last.reduce(&:+))
98
+ end
99
+ end
100
+
101
+ def bugs_impact(stories)
102
+ stories.map do |story|
103
+ if Story::ESTIMABLE_TYPES.include? story.story_type
104
+ 0
105
+ else
106
+ 1
107
+ end
108
+ end
109
+ end
110
+
111
+ def group_by_bugs
112
+ @group_by_bugs ||= @accepted_stories.
113
+ group_by { |story| story.iteration_number }.
114
+ reduce({}) do |group, iteration|
115
+ group.merge(iteration.first => bugs_impact(iteration.last))
116
+ end.
117
+ reduce({}) do |group, iteration|
118
+ group.merge(iteration.first => iteration.last.reduce(&:+))
119
+ end
120
+ end
121
+
122
+ def velocity(number_of_iterations = VELOCITY_ITERATIONS)
123
+ @velocity ||= {}
124
+ @velocity[number_of_iterations] ||= begin
125
+ number_of_iterations = group_by_iteration.size if number_of_iterations > group_by_iteration.size
126
+ return 1 if number_of_iterations.zero?
127
+
128
+ sum = group_by_velocity.values.slice((-1 * number_of_iterations)..-1).sum
129
+
130
+ velocity = (sum / number_of_iterations).floor
131
+ velocity < 1 ? 1 : velocity
132
+ end
133
+ end
134
+
135
+ def group_by_developer
136
+ @group_by_developer ||= begin
137
+ min_iteration = @accepted_stories.map(&:iteration_number).min
138
+ max_iteration = @accepted_stories.map(&:iteration_number).max
139
+ @accepted_stories.
140
+ group_by { |story| story.owned_by.name }.
141
+ map do |owner|
142
+ # all multiple series must have all the same keys or they will mess the graph
143
+ data = (min_iteration..max_iteration).reduce({}) { |group, key| group.merge(key => 0)}
144
+ owner.last.group_by { |story| story.iteration_number }.
145
+ each do |iteration|
146
+ data[iteration.first] = stories_estimates(iteration.last).reduce(&:+)
147
+ end
148
+ { name: owner.first, data: data }
149
+ end
150
+ end
151
+ end
152
+
153
+ def backlog_iterations(velocity_value = velocity)
154
+ velocity_value = 1 if velocity_value < 1
155
+ @backlog_iterations ||= {}
156
+ # mimics the project.js rebuildIteration() function
157
+ @backlog_iterations[velocity_value] ||= begin
158
+ current_iteration = Iteration.new(self, current_iteration_number, velocity_value)
159
+ backlog_iteration = Iteration.new(self, current_iteration_number + 1, velocity_value)
160
+ iterations = [current_iteration, backlog_iteration]
161
+ @backlog.each do |story|
162
+ if current_iteration.can_take_story?(story)
163
+ current_iteration << story
164
+ else
165
+ if !backlog_iteration.can_take_story?(story)
166
+ # Iterations sometimes 'overflow', i.e. an iteration may contain a
167
+ # 5 point story but the project velocity is 1. In this case, the
168
+ # next iteration that can have a story added is the current + 4.
169
+ next_number = backlog_iteration.number + 1 + (backlog_iteration.overflows_by / velocity_value).ceil
170
+ backlog_iteration = Iteration.new(self, next_number, velocity_value)
171
+ iterations << backlog_iteration
172
+ end
173
+ backlog_iteration << story
174
+ end
175
+ end
176
+ iterations
177
+ end
178
+ end
179
+
180
+ def current_iteration_details
181
+ current_iteration = backlog_iterations.first
182
+ %w(started finished delivered accepted rejected).reduce({}) do |data, state|
183
+ data.merge(state => current_iteration.
184
+ select { |story| story.state == state }.
185
+ reduce(0) { |points, story| points + (story.estimate || 0) } )
186
+ end
187
+ end
188
+
189
+ def standard_deviation(groups = [], sample = false)
190
+ return 0 if groups.empty?
191
+ # algorithm: https://www.mathsisfun.com/data/standard-deviation-formulas.html
192
+ #
193
+ mean = groups.sum.to_f / groups.size.to_f
194
+ differences_sqr = groups.map { |velocity| (velocity.to_f - mean) ** 2 }
195
+ count = sample ? (groups.size - 1) : groups.size
196
+ variance = differences_sqr.sum / count.to_f
197
+
198
+ Math.sqrt(variance)
199
+ end
200
+
201
+ def volatility(number_of_iterations = VELOCITY_ITERATIONS)
202
+ number_of_iterations = group_by_velocity.size if number_of_iterations > group_by_velocity.size
203
+
204
+ is_sample = number_of_iterations != group_by_velocity.size
205
+ last_iterations = group_by_velocity.values.reverse.take(number_of_iterations)
206
+ std_dev = standard_deviation(last_iterations, is_sample)
207
+ velocity_value = velocity(number_of_iterations)
208
+
209
+ ( std_dev / velocity_value )
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,36 @@
1
+ require "net/http"
2
+ require "uri"
3
+
4
+ module Central
5
+ module Support
6
+ class Mattermost
7
+ def self.send(private_uri, project_channel, bot_username, message)
8
+ Mattermost.new(private_uri, project_channel, bot_username).send(message)
9
+ end
10
+
11
+ def initialize(private_uri, project_channel = "off-topic", bot_username = "marvin")
12
+ @private_uri = URI.parse(private_uri)
13
+ @project_channel = project_channel
14
+ @bot_username = bot_username
15
+ end
16
+
17
+ def send(text)
18
+ if Rails.env.development?
19
+ Rails.logger.debug("NOT SENDING TO OUTSIDE INTEGRATION!")
20
+ Rails.logger.debug("URL: #{@private_uri}")
21
+ Rails.logger.debug("Payload: #{payload(text)}")
22
+ else
23
+ Net::HTTP.post_form(@private_uri, {"payload" => payload(text)})
24
+ end
25
+ end
26
+
27
+ def payload(text)
28
+ {
29
+ username: @bot_username,
30
+ channel: @project_channel,
31
+ text: text
32
+ }.to_json
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,11 @@
1
+ class BelongsToProjectValidator < ActiveModel::EachValidator
2
+ # Checks that the parameter being validated represents a User#id that
3
+ # is a member of the object.project
4
+ def validate_each(record, attribute, value)
5
+ if record.project && !value.nil?
6
+ unless record.project.user_ids.include?(value)
7
+ record.errors[attribute] << "user is not a member of this project"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,7 @@
1
+ class ChangedValidator < ActiveModel::EachValidator
2
+ def validate_each(record, attribute, value)
3
+ if record.action == 'update' && !value.changed?
4
+ record.errors[attribute] << ( options[:message] || "Record didn't change" )
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,11 @@
1
+ class EstimateValidator < ActiveModel::EachValidator
2
+ # Checks that the estimate being validated is valid for record.project
3
+ def validate_each(record, attribute, value)
4
+ if record.project
5
+ unless record.project.point_values.include?(value)
6
+ record.errors[attribute] << "is not an allowed value for this project"
7
+ end
8
+ end
9
+ end
10
+ end
11
+