jirametrics 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/bin/jirametrics +4 -0
- data/lib/jirametrics/aggregate_config.rb +89 -0
- data/lib/jirametrics/aging_work_bar_chart.rb +235 -0
- data/lib/jirametrics/aging_work_in_progress_chart.rb +148 -0
- data/lib/jirametrics/aging_work_table.rb +149 -0
- data/lib/jirametrics/anonymizer.rb +186 -0
- data/lib/jirametrics/blocked_stalled_change.rb +43 -0
- data/lib/jirametrics/board.rb +85 -0
- data/lib/jirametrics/board_column.rb +14 -0
- data/lib/jirametrics/board_config.rb +31 -0
- data/lib/jirametrics/change_item.rb +80 -0
- data/lib/jirametrics/chart_base.rb +239 -0
- data/lib/jirametrics/columns_config.rb +42 -0
- data/lib/jirametrics/cycletime_config.rb +69 -0
- data/lib/jirametrics/cycletime_histogram.rb +74 -0
- data/lib/jirametrics/cycletime_scatterplot.rb +128 -0
- data/lib/jirametrics/daily_wip_by_age_chart.rb +88 -0
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +77 -0
- data/lib/jirametrics/daily_wip_chart.rb +123 -0
- data/lib/jirametrics/data_quality_report.rb +278 -0
- data/lib/jirametrics/dependency_chart.rb +217 -0
- data/lib/jirametrics/discard_changes_before.rb +37 -0
- data/lib/jirametrics/download_config.rb +41 -0
- data/lib/jirametrics/downloader.rb +337 -0
- data/lib/jirametrics/examples/aggregated_project.rb +36 -0
- data/lib/jirametrics/examples/standard_project.rb +111 -0
- data/lib/jirametrics/expedited_chart.rb +169 -0
- data/lib/jirametrics/experimental/generator.rb +209 -0
- data/lib/jirametrics/experimental/info.rb +77 -0
- data/lib/jirametrics/exporter.rb +127 -0
- data/lib/jirametrics/file_config.rb +119 -0
- data/lib/jirametrics/fix_version.rb +21 -0
- data/lib/jirametrics/groupable_issue_chart.rb +44 -0
- data/lib/jirametrics/grouping_rules.rb +13 -0
- data/lib/jirametrics/hierarchy_table.rb +31 -0
- data/lib/jirametrics/html/aging_work_bar_chart.erb +72 -0
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +52 -0
- data/lib/jirametrics/html/aging_work_table.erb +60 -0
- data/lib/jirametrics/html/collapsible_issues_panel.erb +32 -0
- data/lib/jirametrics/html/cycletime_histogram.erb +41 -0
- data/lib/jirametrics/html/cycletime_scatterplot.erb +103 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +63 -0
- data/lib/jirametrics/html/data_quality_report.erb +126 -0
- data/lib/jirametrics/html/expedited_chart.erb +67 -0
- data/lib/jirametrics/html/hierarchy_table.erb +29 -0
- data/lib/jirametrics/html/index.erb +66 -0
- data/lib/jirametrics/html/sprint_burndown.erb +116 -0
- data/lib/jirametrics/html/story_point_accuracy_chart.erb +57 -0
- data/lib/jirametrics/html/throughput_chart.erb +65 -0
- data/lib/jirametrics/html_report_config.rb +217 -0
- data/lib/jirametrics/issue.rb +521 -0
- data/lib/jirametrics/issue_link.rb +60 -0
- data/lib/jirametrics/json_file_loader.rb +9 -0
- data/lib/jirametrics/project_config.rb +442 -0
- data/lib/jirametrics/rules.rb +34 -0
- data/lib/jirametrics/self_or_issue_dispatcher.rb +15 -0
- data/lib/jirametrics/sprint.rb +43 -0
- data/lib/jirametrics/sprint_burndown.rb +335 -0
- data/lib/jirametrics/sprint_issue_change_data.rb +31 -0
- data/lib/jirametrics/status.rb +26 -0
- data/lib/jirametrics/status_collection.rb +67 -0
- data/lib/jirametrics/story_point_accuracy_chart.rb +139 -0
- data/lib/jirametrics/throughput_chart.rb +91 -0
- data/lib/jirametrics/tree_organizer.rb +96 -0
- data/lib/jirametrics/trend_line_calculator.rb +74 -0
- data/lib/jirametrics.rb +85 -0
- metadata +167 -0
@@ -0,0 +1,442 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'time'
|
4
|
+
require 'jirametrics/status_collection'
|
5
|
+
|
6
|
+
class ProjectConfig
|
7
|
+
include DiscardChangesBefore
|
8
|
+
|
9
|
+
attr_reader :target_path, :jira_config, :all_boards, :possible_statuses,
|
10
|
+
:download_config, :file_configs, :exporter, :data_version, :name, :board_configs, :settings
|
11
|
+
attr_accessor :time_range, :jira_url
|
12
|
+
|
13
|
+
def initialize exporter:, jira_config:, block:, target_path: '.', name: ''
|
14
|
+
@exporter = exporter
|
15
|
+
@block = block
|
16
|
+
@file_configs = []
|
17
|
+
@download_config = nil
|
18
|
+
@target_path = target_path
|
19
|
+
@jira_config = jira_config
|
20
|
+
@possible_statuses = StatusCollection.new
|
21
|
+
@name = name
|
22
|
+
@board_configs = []
|
23
|
+
@settings = {
|
24
|
+
'stalled_threshold' => 5,
|
25
|
+
'blocked_statuses' => [],
|
26
|
+
'stalled_statuses' => [],
|
27
|
+
'blocked_link_text' => [],
|
28
|
+
|
29
|
+
'colors' => {
|
30
|
+
'stalled' => 'orange',
|
31
|
+
'blocked' => '#FF7400'
|
32
|
+
}
|
33
|
+
}
|
34
|
+
end
|
35
|
+
|
36
|
+
def evaluate_next_level
|
37
|
+
instance_eval(&@block)
|
38
|
+
end
|
39
|
+
|
40
|
+
def run
|
41
|
+
unless aggregated_project?
|
42
|
+
load_status_category_mappings
|
43
|
+
load_all_boards
|
44
|
+
load_project_metadata
|
45
|
+
load_sprints
|
46
|
+
end
|
47
|
+
anonymize_data if @anonymizer_needed
|
48
|
+
|
49
|
+
@board_configs.each do |board_config|
|
50
|
+
board_config.run
|
51
|
+
end
|
52
|
+
@file_configs.each do |file_config|
|
53
|
+
file_config.run
|
54
|
+
end
|
55
|
+
|
56
|
+
end
|
57
|
+
|
58
|
+
def aggregated_project?
|
59
|
+
!!@aggregate_config
|
60
|
+
end
|
61
|
+
|
62
|
+
def download &block
|
63
|
+
raise 'Not allowed to have multiple download blocks in one project' if @download_config
|
64
|
+
raise 'Not allowed to have both an aggregate and a download section. Pick only one.' if @aggregate_config
|
65
|
+
|
66
|
+
@download_config = DownloadConfig.new project_config: self, block: block
|
67
|
+
end
|
68
|
+
|
69
|
+
def file &block
|
70
|
+
@file_configs << FileConfig.new(project_config: self, block: block)
|
71
|
+
end
|
72
|
+
|
73
|
+
def aggregate &block
|
74
|
+
raise 'Not allowed to have multiple aggregate blocks in one project' if @aggregate_config
|
75
|
+
raise 'Not allowed to have both an aggregate and a download section. Pick only one.' if @download_config
|
76
|
+
|
77
|
+
@aggregate_config = AggregateConfig.new project_config: self, block: block
|
78
|
+
return if @exporter.downloading?
|
79
|
+
|
80
|
+
@aggregate_config.evaluate_next_level
|
81
|
+
end
|
82
|
+
|
83
|
+
def board id:, &block
|
84
|
+
config = BoardConfig.new(id: id, block: block, project_config: self)
|
85
|
+
@board_configs << config
|
86
|
+
end
|
87
|
+
|
88
|
+
def file_prefix prefix = nil
|
89
|
+
@file_prefix = prefix unless prefix.nil?
|
90
|
+
@file_prefix
|
91
|
+
end
|
92
|
+
|
93
|
+
def status_category_mapping status:, category:, type: nil
|
94
|
+
puts "Deprecated: ProjectConfig.status_category_mapping no longer needs a type: #{type.inspect}" if type
|
95
|
+
|
96
|
+
status_object = find_status(name: status)
|
97
|
+
if status_object
|
98
|
+
puts "Status/Category mapping was already present. Ignoring redefinition: #{status_object}"
|
99
|
+
return
|
100
|
+
end
|
101
|
+
|
102
|
+
add_possible_status Status.new(name: status, id: nil, category_name: category, category_id: nil)
|
103
|
+
end
|
104
|
+
|
105
|
+
def load_all_boards
|
106
|
+
Dir.foreach(@target_path) do |file|
|
107
|
+
next unless file =~ /^#{@file_prefix}_board_(\d+)_configuration\.json$/
|
108
|
+
|
109
|
+
board_id = $1.to_i
|
110
|
+
load_board board_id: board_id, filename: "#{@target_path}#{file}"
|
111
|
+
end
|
112
|
+
raise "No boards found in #{@target_path.inspect}" if @all_boards.nil?
|
113
|
+
end
|
114
|
+
|
115
|
+
def load_board board_id:, filename:
|
116
|
+
board = Board.new(
|
117
|
+
raw: JSON.parse(File.read(filename)), possible_statuses: @possible_statuses
|
118
|
+
)
|
119
|
+
board.project_config = self
|
120
|
+
(@all_boards ||= {})[board_id] = board
|
121
|
+
end
|
122
|
+
|
123
|
+
def raise_with_message_about_missing_category_information
|
124
|
+
message = String.new
|
125
|
+
message << 'Could not determine categories for some of the statuses used in this data set.\n\n' \
|
126
|
+
'If you specify a project: then we\'ll ask Jira for those mappings. If you\'ve done that' \
|
127
|
+
' and we still don\'t have the right mapping, which is possible, then use the' \
|
128
|
+
" 'status_category_mapping' declaration in your config to manually add one.\n\n" \
|
129
|
+
' The mappings we do know about are below:'
|
130
|
+
|
131
|
+
@possible_statuses.each do |status|
|
132
|
+
message << "\n type: #{status.type.inspect}, status: #{status.name.inspect}, " \
|
133
|
+
"category: #{status.category_name.inspect}'"
|
134
|
+
end
|
135
|
+
|
136
|
+
message << "\n\nThe ones we're missing are the following:"
|
137
|
+
|
138
|
+
missing_statuses = []
|
139
|
+
issues.each do |issue|
|
140
|
+
issue.changes.each do |change|
|
141
|
+
next unless change.status?
|
142
|
+
|
143
|
+
missing_statuses << change.value unless find_status(name: change.value)
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
missing_statuses.uniq.each do |status_name|
|
148
|
+
message << "\n status: #{status_name.inspect}, category: <unknown>"
|
149
|
+
end
|
150
|
+
|
151
|
+
raise message
|
152
|
+
end
|
153
|
+
|
154
|
+
def load_status_category_mappings
|
155
|
+
filename = "#{@target_path}/#{file_prefix}_statuses.json"
|
156
|
+
# We may not always have this file. Load it if we can.
|
157
|
+
return unless File.exist? filename
|
158
|
+
|
159
|
+
status_json_snippets = []
|
160
|
+
|
161
|
+
json = JSON.parse(File.read(filename))
|
162
|
+
if json[0]['statuses']
|
163
|
+
# Response from /api/2/{project_code}/status
|
164
|
+
json.each do |type_config|
|
165
|
+
status_json_snippets += type_config['statuses']
|
166
|
+
end
|
167
|
+
else
|
168
|
+
# Response from /api/2/status
|
169
|
+
status_json_snippets = json
|
170
|
+
end
|
171
|
+
|
172
|
+
status_json_snippets.each do |snippet|
|
173
|
+
category_config = snippet['statusCategory']
|
174
|
+
status_name = snippet['name']
|
175
|
+
add_possible_status Status.new(
|
176
|
+
name: status_name,
|
177
|
+
id: snippet['id'].to_i,
|
178
|
+
category_name: category_config['name'],
|
179
|
+
category_id: category_config['id'].to_i
|
180
|
+
)
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
def load_sprints
|
185
|
+
Dir.foreach(@target_path) do |file|
|
186
|
+
next unless file =~ /#{file_prefix}_board_(\d+)_sprints_\d+/
|
187
|
+
|
188
|
+
board_id = $1.to_i
|
189
|
+
timezone_offset = exporter.timezone_offset
|
190
|
+
JSON.parse(File.read("#{target_path}#{file}"))['values'].each do |json|
|
191
|
+
@all_boards[board_id].sprints << Sprint.new(raw: json, timezone_offset: timezone_offset)
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
@all_boards.each_value do |board|
|
196
|
+
board.sprints.sort_by!(&:id)
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
def add_possible_status status
|
201
|
+
existing_status = find_status(name: status.name)
|
202
|
+
|
203
|
+
if existing_status
|
204
|
+
if existing_status.category_name != status.category_name
|
205
|
+
raise "Redefining status category #{status} with #{existing_status}. Was one set in the config?"
|
206
|
+
end
|
207
|
+
|
208
|
+
return
|
209
|
+
end
|
210
|
+
|
211
|
+
@possible_statuses << status
|
212
|
+
end
|
213
|
+
|
214
|
+
def find_status name:
|
215
|
+
@possible_statuses.find_by_name name
|
216
|
+
end
|
217
|
+
|
218
|
+
def load_project_metadata
|
219
|
+
filename = "#{@target_path}/#{file_prefix}_meta.json"
|
220
|
+
json = JSON.parse(File.read(filename))
|
221
|
+
|
222
|
+
@data_version = json['version'] || 1
|
223
|
+
|
224
|
+
start = json['date_start'] || json['time_start'] # date_start is the current format. Time is the old.
|
225
|
+
stop = json['date_end'] || json['time_end']
|
226
|
+
@time_range = to_time(start)..to_time(stop)
|
227
|
+
|
228
|
+
@jira_url = json['jira_url']
|
229
|
+
rescue Errno::ENOENT
|
230
|
+
puts "== Can't load files from the target directory. Did you forget to download first? =="
|
231
|
+
raise
|
232
|
+
end
|
233
|
+
|
234
|
+
def to_time string
|
235
|
+
string = "#{string}T00:00:00#{@timezone_offset}" if string =~ /^\d{4}-\d{2}\d{2}$/
|
236
|
+
Time.parse string
|
237
|
+
end
|
238
|
+
|
239
|
+
def guess_board_id
|
240
|
+
return nil if aggregated_project?
|
241
|
+
|
242
|
+
unless all_boards&.size == 1
|
243
|
+
message = "If the board_id isn't set then we look for all board configurations in the target" \
|
244
|
+
' directory. '
|
245
|
+
if all_boards.nil? || all_boards.empty?
|
246
|
+
message += ' In this case, we couldn\'t find any configuration files in the target directory.'
|
247
|
+
else
|
248
|
+
message += 'If there is only one, we use that. In this case we found configurations for' \
|
249
|
+
" the following board ids and this is ambiguous: #{all_boards.keys}"
|
250
|
+
end
|
251
|
+
raise message
|
252
|
+
end
|
253
|
+
all_boards.keys[0]
|
254
|
+
end
|
255
|
+
|
256
|
+
def find_board_by_id board_id = nil
|
257
|
+
board = all_boards[board_id || guess_board_id]
|
258
|
+
|
259
|
+
raise "Unable to find configuration for board_id: #{board_id}" if board.nil?
|
260
|
+
|
261
|
+
board
|
262
|
+
end
|
263
|
+
|
264
|
+
# To be used by the aggregate_config only. Not intended to be part of the public API
|
265
|
+
def add_issues issues_list
|
266
|
+
@issues = [] if @issues.nil?
|
267
|
+
@all_boards = {} if @all_boards.nil?
|
268
|
+
|
269
|
+
issues_list.each do |issue|
|
270
|
+
@issues << issue
|
271
|
+
board = issue.board
|
272
|
+
@all_boards[board.id] = board unless @all_boards[board.id]
|
273
|
+
end
|
274
|
+
end
|
275
|
+
|
276
|
+
def issues
|
277
|
+
raise "issues are being loaded before boards in project #{name.inspect}" if all_boards.nil? && !aggregated_project?
|
278
|
+
|
279
|
+
unless @issues
|
280
|
+
if @aggregate_config
|
281
|
+
raise 'This is an aggregated project and issues should have been included with the include_issues_from ' \
|
282
|
+
'declaration but none are here. Check your config.'
|
283
|
+
end
|
284
|
+
|
285
|
+
timezone_offset = exporter.timezone_offset
|
286
|
+
|
287
|
+
issues_path = "#{@target_path}#{file_prefix}_issues/"
|
288
|
+
if File.exist?(issues_path) && File.directory?(issues_path)
|
289
|
+
issues = load_issues_from_issues_directory path: issues_path, timezone_offset: timezone_offset
|
290
|
+
elsif File.exist?(@target_path) && File.directory?(@target_path)
|
291
|
+
issues = load_issues_from_target_directory path: @target_path, timezone_offset: timezone_offset
|
292
|
+
else
|
293
|
+
puts "Can't find issues in either #{path} or #{@target_path}"
|
294
|
+
end
|
295
|
+
|
296
|
+
# Attach related issues
|
297
|
+
issues.each do |i|
|
298
|
+
attach_subtasks issue: i, all_issues: issues
|
299
|
+
attach_parent issue: i, all_issues: issues
|
300
|
+
attach_linked_issues issue: i, all_issues: issues
|
301
|
+
end
|
302
|
+
|
303
|
+
# We'll have some issues that are in the list that weren't part of the initial query. Once we've
|
304
|
+
# attached them in the appropriate places, remove any that aren't part of that initial set.
|
305
|
+
@issues = issues.select { |i| i.in_initial_query? }
|
306
|
+
end
|
307
|
+
|
308
|
+
@issues
|
309
|
+
end
|
310
|
+
|
311
|
+
def attach_subtasks issue:, all_issues:
|
312
|
+
issue.raw['fields']['subtasks']&.each do |subtask_element|
|
313
|
+
subtask_key = subtask_element['key']
|
314
|
+
subtask = all_issues.find { |i| i.key == subtask_key }
|
315
|
+
issue.subtasks << subtask if subtask
|
316
|
+
end
|
317
|
+
end
|
318
|
+
|
319
|
+
def attach_parent issue:, all_issues:
|
320
|
+
parent_key = issue.parent_key
|
321
|
+
parent = all_issues.find { |i| i.key == parent_key }
|
322
|
+
issue.parent = parent if parent
|
323
|
+
end
|
324
|
+
|
325
|
+
def attach_linked_issues issue:, all_issues:
|
326
|
+
issue.issue_links.each do |link|
|
327
|
+
if link.other_issue.artificial?
|
328
|
+
other = all_issues.find { |i| i.key == link.other_issue.key }
|
329
|
+
link.other_issue = other if other
|
330
|
+
end
|
331
|
+
end
|
332
|
+
end
|
333
|
+
|
334
|
+
def find_default_board
|
335
|
+
default_board = all_boards.values.first
|
336
|
+
raise "No boards found for project #{name.inspect}" if all_boards.empty?
|
337
|
+
|
338
|
+
if all_boards.size != 1
|
339
|
+
puts "Multiple boards are in use for project #{name.inspect}. Picked #{(default_board.name).inspect} to attach issues to."
|
340
|
+
end
|
341
|
+
default_board
|
342
|
+
end
|
343
|
+
|
344
|
+
def load_issues_from_target_directory path:, timezone_offset:
|
345
|
+
puts "Deprecated: issues in the target directory for project #{@name}. " \
|
346
|
+
'Download again and this should fix itself.'
|
347
|
+
|
348
|
+
default_board = find_default_board
|
349
|
+
|
350
|
+
issues = []
|
351
|
+
Dir.foreach(path) do |filename|
|
352
|
+
if filename =~ /#{file_prefix}_\d+\.json/
|
353
|
+
content = JSON.parse File.read("#{path}#{filename}")
|
354
|
+
content['issues'].each do |issue|
|
355
|
+
issues << Issue.new(raw: issue, timezone_offset: timezone_offset, board: default_board)
|
356
|
+
end
|
357
|
+
end
|
358
|
+
end
|
359
|
+
issues
|
360
|
+
end
|
361
|
+
|
362
|
+
def load_issues_from_issues_directory path:, timezone_offset:
|
363
|
+
issues = []
|
364
|
+
default_board = nil
|
365
|
+
|
366
|
+
group_filenames_and_board_ids(path: path).each do |filename, board_ids|
|
367
|
+
content = File.read(File.join(path, filename))
|
368
|
+
if board_ids == :unknown
|
369
|
+
boards = [(default_board ||= find_default_board)]
|
370
|
+
else
|
371
|
+
boards = board_ids.collect { |b| all_boards[b] }
|
372
|
+
end
|
373
|
+
|
374
|
+
boards.each do |board|
|
375
|
+
issues << Issue.new(raw: JSON.parse(content), timezone_offset: timezone_offset, board: board)
|
376
|
+
end
|
377
|
+
end
|
378
|
+
|
379
|
+
issues
|
380
|
+
end
|
381
|
+
|
382
|
+
# Scan through the issues directory (path), select the filenames to be loaded and map them to board ids.
|
383
|
+
# It's ok if there are multiple files for the same issue. We load the newest one and map all the other
|
384
|
+
# board ids appropriately.
|
385
|
+
def group_filenames_and_board_ids path:
|
386
|
+
hash = {}
|
387
|
+
Dir.foreach(path) do |filename|
|
388
|
+
# Matches either FAKE-123.json or FAKE-123-456.json
|
389
|
+
if /^(?<key>[^-]+-\d+)(?<_>-(?<board_id>\d+))?\.json$/ =~ filename
|
390
|
+
(hash[key] ||= []) << [filename, board_id&.to_i || :unknown]
|
391
|
+
end
|
392
|
+
end
|
393
|
+
|
394
|
+
result = {}
|
395
|
+
hash.values.collect do |list|
|
396
|
+
if list.size == 1
|
397
|
+
filename, board_id = *list.first
|
398
|
+
result[filename] = board_id == :unknown ? board_id : [board_id]
|
399
|
+
else
|
400
|
+
max_time = nil
|
401
|
+
max_board_id = nil
|
402
|
+
max_filename = nil
|
403
|
+
all_board_ids = []
|
404
|
+
|
405
|
+
list.each do |filename, board_id|
|
406
|
+
mtime = File.mtime(File.join(path, filename))
|
407
|
+
if max_time.nil? || mtime > max_time
|
408
|
+
max_time = mtime
|
409
|
+
max_board_id = board_id
|
410
|
+
max_filename = filename
|
411
|
+
end
|
412
|
+
all_board_ids << board_id unless board_id == :unknown
|
413
|
+
end
|
414
|
+
|
415
|
+
result[max_filename] = all_board_ids
|
416
|
+
end
|
417
|
+
end
|
418
|
+
result
|
419
|
+
end
|
420
|
+
|
421
|
+
def anonymize
|
422
|
+
@anonymizer_needed = true
|
423
|
+
end
|
424
|
+
|
425
|
+
def anonymize_data
|
426
|
+
Anonymizer.new(project_config: self).run
|
427
|
+
end
|
428
|
+
|
429
|
+
def discard_changes_before_hook issues_cutoff_times
|
430
|
+
issues_cutoff_times.each do |issue, cutoff_time|
|
431
|
+
days = (cutoff_time.to_date - issue.changes.first.time.to_date).to_i + 1
|
432
|
+
message = "#{issue.key}(#{issue.type}) discarding #{days} "
|
433
|
+
if days == 1
|
434
|
+
message << "day of data on #{cutoff_time.to_date}"
|
435
|
+
else
|
436
|
+
message << "days of data from #{issue.changes.first.time.to_date} to #{cutoff_time.to_date}"
|
437
|
+
end
|
438
|
+
puts message
|
439
|
+
end
|
440
|
+
puts "Discarded data from #{issues_cutoff_times.count} issues out of a total #{issues.size}"
|
441
|
+
end
|
442
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Rules
|
4
|
+
def ignore
|
5
|
+
@ignore = true
|
6
|
+
end
|
7
|
+
|
8
|
+
def ignored?
|
9
|
+
@ignore == true
|
10
|
+
end
|
11
|
+
|
12
|
+
def eql?(other)
|
13
|
+
(other.class == self.class) && (other.state == state)
|
14
|
+
end
|
15
|
+
|
16
|
+
def state
|
17
|
+
instance_variables.map { |variable| instance_variable_get variable }
|
18
|
+
end
|
19
|
+
|
20
|
+
def hash
|
21
|
+
2 # TODO: While this work, it's not performant
|
22
|
+
end
|
23
|
+
|
24
|
+
def inspect
|
25
|
+
result = String.new
|
26
|
+
result << "#{self.class}("
|
27
|
+
result << instance_variables.collect do |variable|
|
28
|
+
"#{variable}=#{instance_variable_get(variable).inspect}"
|
29
|
+
end.join(', ')
|
30
|
+
result << ')'
|
31
|
+
result
|
32
|
+
end
|
33
|
+
|
34
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SelfOrIssueDispatcher
|
4
|
+
def method_missing method_name, *args, &block
|
5
|
+
raise "#{method_name} isn't a method on Issue or #{self.class}" unless ::Issue.method_defined? method_name.to_sym
|
6
|
+
|
7
|
+
->(issue) do # rubocop:disable Style/Lambda
|
8
|
+
issue.__send__ method_name, *args, &block
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
def respond_to_missing?(method_name, include_all = false)
|
13
|
+
::Issue.method_defined?(method_name.to_sym) || super
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'time'
|
4
|
+
|
5
|
+
class Sprint
|
6
|
+
attr_reader :raw
|
7
|
+
|
8
|
+
def initialize raw:, timezone_offset:
|
9
|
+
@raw = raw
|
10
|
+
@timezone_offset = timezone_offset
|
11
|
+
end
|
12
|
+
|
13
|
+
def id = @raw['id']
|
14
|
+
def active? = (@raw['state'] == 'active')
|
15
|
+
|
16
|
+
def completed_at? time
|
17
|
+
completed_at = completed_time
|
18
|
+
completed_at && completed_at <= time
|
19
|
+
end
|
20
|
+
|
21
|
+
def start_time
|
22
|
+
parse_time(@raw['activatedDate'] || @raw['startDate'])
|
23
|
+
end
|
24
|
+
|
25
|
+
# The time that was anticipated that the sprint would close
|
26
|
+
def end_time
|
27
|
+
parse_time(@raw['endDate'])
|
28
|
+
end
|
29
|
+
|
30
|
+
# The time that the sprint was actually closed
|
31
|
+
def completed_time
|
32
|
+
parse_time(@raw['completeDate'])
|
33
|
+
end
|
34
|
+
|
35
|
+
def goal = @raw['goal']
|
36
|
+
def name = @raw['name']
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def parse_time time_string
|
41
|
+
Time.parse(time_string).localtime(@timezone_offset) if time_string
|
42
|
+
end
|
43
|
+
end
|