central-support 0.9.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
|