jirametrics 1.0.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/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
|