central-support 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
+