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.
- checksums.yaml +7 -0
- data/.gitignore +13 -0
- data/.rspec +2 -0
- data/.travis.yml +14 -0
- data/Gemfile +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +49 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/central-support.gemspec +30 -0
- data/lib/central/support.rb +27 -0
- data/lib/central/support/concerns/activity_concern.rb +4 -0
- data/lib/central/support/concerns/activity_concern/associations.rb +15 -0
- data/lib/central/support/concerns/activity_concern/callbacks.rb +25 -0
- data/lib/central/support/concerns/activity_concern/scopes.rb +55 -0
- data/lib/central/support/concerns/activity_concern/validations.rb +17 -0
- data/lib/central/support/concerns/project_concern.rb +5 -0
- data/lib/central/support/concerns/project_concern/associations.rb +27 -0
- data/lib/central/support/concerns/project_concern/attributes.rb +25 -0
- data/lib/central/support/concerns/project_concern/csv.rb +59 -0
- data/lib/central/support/concerns/project_concern/scopes.rb +26 -0
- data/lib/central/support/concerns/project_concern/validations.rb +43 -0
- data/lib/central/support/concerns/story_concern.rb +7 -0
- data/lib/central/support/concerns/story_concern/associations.rb +23 -0
- data/lib/central/support/concerns/story_concern/attributes.rb +29 -0
- data/lib/central/support/concerns/story_concern/callbacks.rb +50 -0
- data/lib/central/support/concerns/story_concern/csv.rb +81 -0
- data/lib/central/support/concerns/story_concern/scopes.rb +16 -0
- data/lib/central/support/concerns/story_concern/transitions.rb +71 -0
- data/lib/central/support/concerns/story_concern/validations.rb +45 -0
- data/lib/central/support/concerns/team_concern.rb +4 -0
- data/lib/central/support/concerns/team_concern/associations.rb +29 -0
- data/lib/central/support/concerns/team_concern/domain_validator.rb +19 -0
- data/lib/central/support/concerns/team_concern/scopes.rb +15 -0
- data/lib/central/support/concerns/team_concern/validations.rb +13 -0
- data/lib/central/support/concerns/user_concern.rb +3 -0
- data/lib/central/support/concerns/user_concern/associations.rb +21 -0
- data/lib/central/support/concerns/user_concern/callbacks.rb +39 -0
- data/lib/central/support/concerns/user_concern/validations.rb +14 -0
- data/lib/central/support/iteration.rb +51 -0
- data/lib/central/support/iteration_service.rb +213 -0
- data/lib/central/support/mattermost.rb +36 -0
- data/lib/central/support/validators/belongs_to_project_validator.rb +11 -0
- data/lib/central/support/validators/changed_validator.rb +7 -0
- data/lib/central/support/validators/estimate_validator.rb +11 -0
- data/lib/central/support/version.rb +5 -0
- 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,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,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,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
|
+
|