jirametrics 2.13 → 2.30
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 +4 -4
- data/bin/jirametrics-mcp +5 -0
- data/lib/jirametrics/aggregate_config.rb +10 -2
- data/lib/jirametrics/aging_work_bar_chart.rb +191 -133
- data/lib/jirametrics/aging_work_in_progress_chart.rb +43 -11
- data/lib/jirametrics/aging_work_table.rb +9 -7
- data/lib/jirametrics/anonymizer.rb +81 -6
- data/lib/jirametrics/atlassian_document_format.rb +101 -97
- data/lib/jirametrics/bar_chart_range.rb +17 -0
- data/lib/jirametrics/blocked_stalled_change.rb +5 -3
- data/lib/jirametrics/board.rb +32 -8
- data/lib/jirametrics/board_config.rb +4 -1
- data/lib/jirametrics/board_feature.rb +14 -0
- data/lib/jirametrics/board_movement_calculator.rb +2 -2
- data/lib/jirametrics/cfd_data_builder.rb +108 -0
- data/lib/jirametrics/change_item.rb +14 -6
- data/lib/jirametrics/chart_base.rb +141 -3
- data/lib/jirametrics/css_variable.rb +1 -1
- data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
- data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +21 -4
- data/lib/jirametrics/cycletime_histogram.rb +15 -101
- data/lib/jirametrics/cycletime_scatterplot.rb +17 -83
- data/lib/jirametrics/daily_view.rb +85 -53
- data/lib/jirametrics/daily_wip_by_age_chart.rb +4 -5
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +14 -4
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
- data/lib/jirametrics/daily_wip_chart.rb +30 -8
- data/lib/jirametrics/data_quality_report.rb +43 -12
- data/lib/jirametrics/dependency_chart.rb +6 -3
- data/lib/jirametrics/download_config.rb +15 -0
- data/lib/jirametrics/downloader.rb +117 -100
- data/lib/jirametrics/downloader_for_cloud.rb +287 -0
- data/lib/jirametrics/downloader_for_data_center.rb +95 -0
- data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
- data/lib/jirametrics/examples/aggregated_project.rb +2 -2
- data/lib/jirametrics/examples/standard_project.rb +41 -28
- data/lib/jirametrics/expedited_chart.rb +3 -1
- data/lib/jirametrics/exporter.rb +26 -6
- data/lib/jirametrics/file_config.rb +9 -11
- data/lib/jirametrics/file_system.rb +59 -3
- data/lib/jirametrics/fix_version.rb +13 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
- data/lib/jirametrics/github_gateway.rb +115 -0
- data/lib/jirametrics/groupable_issue_chart.rb +11 -1
- data/lib/jirametrics/grouping_rules.rb +26 -4
- data/lib/jirametrics/html/aging_work_bar_chart.erb +5 -5
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +3 -1
- data/lib/jirametrics/html/aging_work_table.erb +5 -0
- data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +40 -5
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
- data/lib/jirametrics/html/expedited_chart.erb +6 -14
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
- data/lib/jirametrics/html/index.css +249 -69
- data/lib/jirametrics/html/index.erb +9 -35
- data/lib/jirametrics/html/index.js +164 -0
- data/lib/jirametrics/html/legacy_colors.css +174 -0
- data/lib/jirametrics/html/sprint_burndown.erb +17 -15
- data/lib/jirametrics/html/throughput_chart.erb +42 -11
- data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +15 -11
- data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
- data/lib/jirametrics/html_generator.rb +32 -0
- data/lib/jirametrics/html_report_config.rb +52 -57
- data/lib/jirametrics/issue.rb +304 -101
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +77 -17
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +128 -12
- data/lib/jirametrics/pull_request.rb +30 -0
- data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
- data/lib/jirametrics/pull_request_review.rb +13 -0
- data/lib/jirametrics/raw_javascript.rb +17 -0
- data/lib/jirametrics/settings.json +5 -1
- data/lib/jirametrics/sprint.rb +12 -0
- data/lib/jirametrics/sprint_burndown.rb +10 -4
- data/lib/jirametrics/status.rb +1 -1
- data/lib/jirametrics/stitcher.rb +81 -0
- data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
- data/lib/jirametrics/throughput_chart.rb +73 -23
- data/lib/jirametrics/time_based_histogram.rb +139 -0
- data/lib/jirametrics/time_based_scatterplot.rb +107 -0
- data/lib/jirametrics/wip_by_column_chart.rb +236 -0
- data/lib/jirametrics.rb +83 -69
- metadata +60 -6
|
@@ -6,7 +6,7 @@ require 'jirametrics/status_collection'
|
|
|
6
6
|
class ProjectConfig
|
|
7
7
|
attr_reader :target_path, :jira_config, :all_boards, :possible_statuses,
|
|
8
8
|
:download_config, :file_configs, :exporter, :data_version, :name, :board_configs,
|
|
9
|
-
:settings, :aggregate_config, :discarded_changes_data, :users
|
|
9
|
+
:settings, :aggregate_config, :discarded_changes_data, :users, :fix_versions
|
|
10
10
|
attr_accessor :time_range, :jira_url, :id
|
|
11
11
|
|
|
12
12
|
def initialize exporter:, jira_config:, block:, target_path: '.', name: '', id: nil
|
|
@@ -23,6 +23,7 @@ class ProjectConfig
|
|
|
23
23
|
@settings = load_settings
|
|
24
24
|
@id = id
|
|
25
25
|
@has_loaded_data = false
|
|
26
|
+
@fix_versions = []
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
def evaluate_next_level
|
|
@@ -40,17 +41,20 @@ class ProjectConfig
|
|
|
40
41
|
@id = guess_project_id
|
|
41
42
|
load_project_metadata
|
|
42
43
|
load_sprints
|
|
44
|
+
load_fix_versions
|
|
43
45
|
load_users
|
|
46
|
+
resolve_blocked_stalled_status_settings
|
|
44
47
|
end
|
|
45
48
|
|
|
46
49
|
def run load_only: false
|
|
47
50
|
return if @exporter.downloading?
|
|
48
51
|
|
|
49
52
|
load_data unless aggregated_project?
|
|
50
|
-
anonymize_data if @anonymizer_needed
|
|
51
53
|
|
|
52
54
|
return if load_only
|
|
53
55
|
|
|
56
|
+
anonymize_data if @anonymizer_needed
|
|
57
|
+
|
|
54
58
|
@file_configs.each do |file_config|
|
|
55
59
|
file_config.run
|
|
56
60
|
end
|
|
@@ -58,7 +62,19 @@ class ProjectConfig
|
|
|
58
62
|
|
|
59
63
|
def load_settings
|
|
60
64
|
# This is the weird exception that we don't ever want mocked out so we skip FileSystem entirely.
|
|
61
|
-
JSON.parse(File.read(File.join(__dir__, 'settings.json'), encoding: 'UTF-8'))
|
|
65
|
+
settings = JSON.parse(File.read(File.join(__dir__, 'settings.json'), encoding: 'UTF-8'))
|
|
66
|
+
|
|
67
|
+
if settings['blocked_color']
|
|
68
|
+
file_system.deprecated message: 'blocked color should be set via css now', date: '2024-05-03'
|
|
69
|
+
end
|
|
70
|
+
if settings['stalled_color']
|
|
71
|
+
file_system.deprecated message: 'stalled color should be set via css now', date: '2024-05-03'
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
settings['blocked_statuses'] = StatusCollection.new
|
|
75
|
+
settings['stalled_statuses'] = StatusCollection.new
|
|
76
|
+
|
|
77
|
+
stringify_keys(settings)
|
|
62
78
|
end
|
|
63
79
|
|
|
64
80
|
def guess_project_id
|
|
@@ -82,6 +98,12 @@ class ProjectConfig
|
|
|
82
98
|
!!@aggregate_config
|
|
83
99
|
end
|
|
84
100
|
|
|
101
|
+
def aggregate_project_names
|
|
102
|
+
return [] unless aggregated_project?
|
|
103
|
+
|
|
104
|
+
@aggregate_config.included_projects.filter_map(&:name)
|
|
105
|
+
end
|
|
106
|
+
|
|
85
107
|
def download &block
|
|
86
108
|
raise 'Not allowed to have multiple download blocks in one project' if @download_config
|
|
87
109
|
raise 'Not allowed to have both an aggregate and a download section. Pick only one.' if @aggregate_config
|
|
@@ -114,10 +136,14 @@ class ProjectConfig
|
|
|
114
136
|
def file_prefix prefix
|
|
115
137
|
# The file_prefix has to be set before almost everything else. It really should have been an attribute
|
|
116
138
|
# on the project declaration itself. Hindsight is 20/20.
|
|
139
|
+
|
|
140
|
+
# There can only be one of these
|
|
117
141
|
if @file_prefix
|
|
118
|
-
raise "file_prefix
|
|
142
|
+
raise "file_prefix can only be set once. Was #{@file_prefix.inspect} and now changed to #{prefix.inspect}."
|
|
119
143
|
end
|
|
120
144
|
|
|
145
|
+
raise_if_prefix_already_used(prefix)
|
|
146
|
+
|
|
121
147
|
@file_prefix = prefix
|
|
122
148
|
|
|
123
149
|
# Yes, this is a wierd place to be initializing this. Unfortunately, it has to happen after the file_prefix
|
|
@@ -130,8 +156,32 @@ class ProjectConfig
|
|
|
130
156
|
@file_prefix
|
|
131
157
|
end
|
|
132
158
|
|
|
133
|
-
def
|
|
134
|
-
|
|
159
|
+
def validate_discard_status status_name
|
|
160
|
+
return if status_name == :backlog
|
|
161
|
+
return if possible_statuses.empty? # not yet downloaded; skip validation
|
|
162
|
+
|
|
163
|
+
found = possible_statuses.find_all_by_name status_name
|
|
164
|
+
return unless found.empty?
|
|
165
|
+
|
|
166
|
+
raise "discard_changes_before: Status #{status_name.inspect} not found. " \
|
|
167
|
+
"Possible statuses are: #{possible_statuses}"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def raise_if_prefix_already_used prefix
|
|
171
|
+
@exporter.project_configs.each do |project|
|
|
172
|
+
next unless project.get_file_prefix(raise_if_not_set: false) == prefix && project.target_path == target_path
|
|
173
|
+
|
|
174
|
+
raise "Project #{name.inspect} specifies file prefix #{prefix.inspect}, " \
|
|
175
|
+
"but that is already used by project #{project.name.inspect} in the same target path #{target_path.inspect}. " \
|
|
176
|
+
'This is almost guaranteed to be too much copy and paste in your configuration. ' \
|
|
177
|
+
'File prefixes must be unique within a directory.'
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def get_file_prefix raise_if_not_set: true
|
|
182
|
+
if @file_prefix.nil? && raise_if_not_set
|
|
183
|
+
raise 'file_prefix has not been set yet. Move it to the top of the project declaration.'
|
|
184
|
+
end
|
|
135
185
|
|
|
136
186
|
@file_prefix
|
|
137
187
|
end
|
|
@@ -241,9 +291,16 @@ class ProjectConfig
|
|
|
241
291
|
end
|
|
242
292
|
|
|
243
293
|
def load_board board_id:, filename:
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
)
|
|
294
|
+
raw = file_system.load_json(filename)
|
|
295
|
+
|
|
296
|
+
features_filename = File.join(@target_path, "#{get_file_prefix}_board_#{board_id}_features.json")
|
|
297
|
+
features = if file_system.file_exist?(features_filename)
|
|
298
|
+
BoardFeature.from_raw(file_system.load_json(features_filename))
|
|
299
|
+
else
|
|
300
|
+
[]
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
board = Board.new(raw: raw, possible_statuses: @possible_statuses, features: features)
|
|
247
304
|
board.project_config = self
|
|
248
305
|
@all_boards[board_id] = board
|
|
249
306
|
end
|
|
@@ -278,8 +335,9 @@ class ProjectConfig
|
|
|
278
335
|
file_system.foreach(@target_path) do |file|
|
|
279
336
|
next unless file =~ /^#{get_file_prefix}_board_(\d+)_sprints_\d+.json$/
|
|
280
337
|
|
|
338
|
+
board_id = $1.to_i
|
|
281
339
|
file_path = File.join(@target_path, file)
|
|
282
|
-
board = @all_boards[
|
|
340
|
+
board = @all_boards[board_id]
|
|
283
341
|
unless board
|
|
284
342
|
@exporter.file_system.log(
|
|
285
343
|
'Found sprint data but can\'t find a matching board in config. ' \
|
|
@@ -299,6 +357,13 @@ class ProjectConfig
|
|
|
299
357
|
end
|
|
300
358
|
end
|
|
301
359
|
|
|
360
|
+
def load_fix_versions
|
|
361
|
+
filename = File.join(@target_path, "#{get_file_prefix}_fix_versions.json")
|
|
362
|
+
return unless file_system.file_exist?(filename)
|
|
363
|
+
|
|
364
|
+
@fix_versions = file_system.load_json(filename).map { |raw| FixVersion.new(raw) }
|
|
365
|
+
end
|
|
366
|
+
|
|
302
367
|
def load_project_metadata
|
|
303
368
|
filename = File.join @target_path, "#{get_file_prefix}_meta.json"
|
|
304
369
|
json = file_system.load_json(filename)
|
|
@@ -335,6 +400,25 @@ class ProjectConfig
|
|
|
335
400
|
json.each { |user_data| @users << User.new(raw: user_data) }
|
|
336
401
|
end
|
|
337
402
|
|
|
403
|
+
def attach_github_prs
|
|
404
|
+
filename = File.join(@target_path, "#{get_file_prefix}_github_prs.json")
|
|
405
|
+
return unless File.exist?(filename)
|
|
406
|
+
|
|
407
|
+
prs_by_issue_key = Hash.new { |h, k| h[k] = [] }
|
|
408
|
+
file_system.load_json(filename).each do |raw|
|
|
409
|
+
pr = PullRequest.new(raw: raw)
|
|
410
|
+
pr.issue_keys.each { |key| prs_by_issue_key[key] << pr }
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
@issues.each { |issue| issue.github_prs = prs_by_issue_key[issue.key] }
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def atlassian_document_format
|
|
417
|
+
@atlassian_document_format ||= AtlassianDocumentFormat.new(
|
|
418
|
+
users: @users, timezone_offset: exporter.timezone_offset
|
|
419
|
+
)
|
|
420
|
+
end
|
|
421
|
+
|
|
338
422
|
def to_time string, end_of_day: false
|
|
339
423
|
time = end_of_day ? '23:59:59' : '00:00:00'
|
|
340
424
|
string = "#{string}T#{time}#{exporter.timezone_offset}" if string.match?(/^\d{4}-\d{2}-\d{2}$/)
|
|
@@ -369,7 +453,7 @@ class ProjectConfig
|
|
|
369
453
|
# To be used by the aggregate_config only. Not intended to be part of the public API
|
|
370
454
|
def add_issues issues_list
|
|
371
455
|
@issues = IssueCollection.new if @issues.nil?
|
|
372
|
-
@all_boards
|
|
456
|
+
@all_boards ||= {}
|
|
373
457
|
|
|
374
458
|
issues_list.each do |issue|
|
|
375
459
|
@issues << issue
|
|
@@ -411,6 +495,7 @@ class ProjectConfig
|
|
|
411
495
|
# attached them in the appropriate places, remove any that aren't part of that initial set.
|
|
412
496
|
issues.reject! { |i| !i.in_initial_query? } # rubocop:disable Style/InverseMethods
|
|
413
497
|
@issues = issues
|
|
498
|
+
attach_github_prs
|
|
414
499
|
end
|
|
415
500
|
|
|
416
501
|
@issues
|
|
@@ -526,9 +611,12 @@ class ProjectConfig
|
|
|
526
611
|
end
|
|
527
612
|
|
|
528
613
|
def discard_changes_before status_becomes: nil, &block
|
|
614
|
+
cycletimes_touched = Set.new
|
|
529
615
|
if status_becomes
|
|
530
616
|
status_becomes = [status_becomes] unless status_becomes.is_a? Array
|
|
531
617
|
|
|
618
|
+
status_becomes.each { |status_name| validate_discard_status status_name }
|
|
619
|
+
|
|
532
620
|
block = lambda do |issue|
|
|
533
621
|
trigger_statuses = status_becomes.collect do |status_name|
|
|
534
622
|
if status_name == :backlog
|
|
@@ -554,10 +642,11 @@ class ProjectConfig
|
|
|
554
642
|
cutoff_time = block.call(issue)
|
|
555
643
|
next if cutoff_time.nil?
|
|
556
644
|
|
|
557
|
-
original_start_time = issue.
|
|
645
|
+
original_start_time = issue.started_stopped_times.first
|
|
558
646
|
next if original_start_time.nil?
|
|
559
647
|
|
|
560
648
|
issue.discard_changes_before cutoff_time
|
|
649
|
+
cycletimes_touched << issue.board.cycletime
|
|
561
650
|
|
|
562
651
|
next unless cutoff_time
|
|
563
652
|
next if original_start_time > cutoff_time # ie the cutoff would have made no difference.
|
|
@@ -568,5 +657,32 @@ class ProjectConfig
|
|
|
568
657
|
issue: issue
|
|
569
658
|
}
|
|
570
659
|
end
|
|
660
|
+
|
|
661
|
+
cycletimes_touched.each { |c| c.flush_cache }
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
def stringify_keys value
|
|
665
|
+
case value
|
|
666
|
+
when Hash then value.transform_keys(&:to_s).transform_values { |v| stringify_keys(v) }
|
|
667
|
+
when Array then value.map { |v| stringify_keys(v) }
|
|
668
|
+
else value
|
|
669
|
+
end
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
def resolve_blocked_stalled_status_settings
|
|
673
|
+
%w[blocked_statuses stalled_statuses].each do |key|
|
|
674
|
+
next if @settings[key].is_a?(StatusCollection)
|
|
675
|
+
|
|
676
|
+
collection = StatusCollection.new
|
|
677
|
+
@settings[key].each do |identifier|
|
|
678
|
+
statuses = @possible_statuses.find_all_by_name(identifier)
|
|
679
|
+
if statuses.empty?
|
|
680
|
+
file_system.warning "Status #{identifier.inspect} in #{key} not found. Ignoring."
|
|
681
|
+
else
|
|
682
|
+
statuses.each { |status| collection << status }
|
|
683
|
+
end
|
|
684
|
+
end
|
|
685
|
+
@settings[key] = collection
|
|
686
|
+
end
|
|
571
687
|
end
|
|
572
688
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require 'jirametrics/pull_request_review'
|
|
5
|
+
|
|
6
|
+
class PullRequest
|
|
7
|
+
attr_reader :raw
|
|
8
|
+
|
|
9
|
+
def initialize raw:
|
|
10
|
+
@raw = raw
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def number = @raw['number']
|
|
14
|
+
def repo = @raw['repo']
|
|
15
|
+
def url = @raw['url']
|
|
16
|
+
def title = @raw['title']
|
|
17
|
+
def branch = @raw['branch']
|
|
18
|
+
def state = @raw['state']
|
|
19
|
+
def issue_keys = @raw['issue_keys']
|
|
20
|
+
|
|
21
|
+
def opened_at = Time.parse(@raw['opened_at'])
|
|
22
|
+
def closed_at = @raw['closed_at'] ? Time.parse(@raw['closed_at']) : nil
|
|
23
|
+
def merged_at = @raw['merged_at'] ? Time.parse(@raw['merged_at']) : nil
|
|
24
|
+
|
|
25
|
+
def reviews = (@raw['reviews'] || []).map { |r| PullRequestReview.new(raw: r) }
|
|
26
|
+
def additions = @raw['additions']
|
|
27
|
+
def deletions = @raw['deletions']
|
|
28
|
+
def changed_files = @raw['changed_files']
|
|
29
|
+
def lines_changed = (additions || 0) + (deletions || 0)
|
|
30
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jirametrics/groupable_issue_chart'
|
|
4
|
+
|
|
5
|
+
class PullRequestCycleTimeHistogram < TimeBasedHistogram
|
|
6
|
+
def initialize block
|
|
7
|
+
super()
|
|
8
|
+
|
|
9
|
+
@cycletime_unit = :days
|
|
10
|
+
@x_axis_title = 'Cycle time in days'
|
|
11
|
+
|
|
12
|
+
header_text 'PR Histogram'
|
|
13
|
+
description_text <<-HTML
|
|
14
|
+
<div class="p">
|
|
15
|
+
This cycletime Histogram shows how many pull requests completed in a certain timeframe. This can be
|
|
16
|
+
useful for determining how many different types of work are flowing through, based on the
|
|
17
|
+
lengths of time they take.
|
|
18
|
+
</div>
|
|
19
|
+
HTML
|
|
20
|
+
|
|
21
|
+
init_configuration_block(block) do
|
|
22
|
+
grouping_rules do |pull_request, rule|
|
|
23
|
+
rule.label = pull_request.repo
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def cycletime_unit unit
|
|
29
|
+
unless %i[minutes hours days].include?(unit)
|
|
30
|
+
raise ArgumentError, "cycletime_unit must be :minutes, :hours, or :days, got #{unit.inspect}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@cycletime_unit = unit
|
|
34
|
+
@x_axis_title = "Cycle time in #{unit}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def all_items
|
|
38
|
+
result = []
|
|
39
|
+
issues.each do |issue|
|
|
40
|
+
next unless issue.github_prs
|
|
41
|
+
|
|
42
|
+
issue.github_prs.each do |pr|
|
|
43
|
+
next unless pr.closed_at
|
|
44
|
+
|
|
45
|
+
result << pr
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
result.uniq
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def value_for_item item
|
|
52
|
+
divisor = { minutes: 60.0, hours: 3600.0, days: 86_400.0 }[@cycletime_unit]
|
|
53
|
+
((item.closed_at - item.opened_at) / divisor).ceil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def label_cycletime value
|
|
57
|
+
case @cycletime_unit
|
|
58
|
+
when :minutes then label_minutes(value)
|
|
59
|
+
when :hours then label_hours(value)
|
|
60
|
+
when :days then label_days(value)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def title_for_item count:, value:
|
|
65
|
+
"#{count} PR#{'s' unless count == 1} closed in #{label_cycletime value}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def sort_items items
|
|
69
|
+
items.sort_by(&:opened_at)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def label_for_item item, hint:
|
|
73
|
+
label = "#{item.number} #{item.title}"
|
|
74
|
+
label << hint if hint
|
|
75
|
+
label
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jirametrics/groupable_issue_chart'
|
|
4
|
+
|
|
5
|
+
class PullRequestCycleTimeScatterplot < TimeBasedScatterplot
|
|
6
|
+
def initialize block
|
|
7
|
+
super()
|
|
8
|
+
|
|
9
|
+
@cycletime_unit = :days
|
|
10
|
+
@y_axis_title = 'Cycle time in days'
|
|
11
|
+
|
|
12
|
+
header_text 'Pull Request (PR) Scatterplot'
|
|
13
|
+
description_text <<-HTML
|
|
14
|
+
<div class="p">
|
|
15
|
+
This graph shows the cycle time for all closed pull requests (time from opened to closed).
|
|
16
|
+
</div>
|
|
17
|
+
#{describe_non_working_days}
|
|
18
|
+
HTML
|
|
19
|
+
|
|
20
|
+
init_configuration_block(block) do
|
|
21
|
+
grouping_rules do |pull_request, rule|
|
|
22
|
+
rule.label = pull_request.repo
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def cycletime_unit unit
|
|
28
|
+
unless %i[minutes hours days].include?(unit)
|
|
29
|
+
raise ArgumentError, "cycletime_unit must be :minutes, :hours, or :days, got #{unit.inspect}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@cycletime_unit = unit
|
|
33
|
+
@y_axis_title = "Cycle time in #{unit}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def all_items
|
|
37
|
+
result = []
|
|
38
|
+
issues.each do |issue|
|
|
39
|
+
issue.github_prs&.each do |pr|
|
|
40
|
+
result << pr if pr.closed_at
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
result
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def x_value pull_request
|
|
47
|
+
pull_request.closed_at
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def y_value pull_request
|
|
51
|
+
if @cycletime_unit == :days
|
|
52
|
+
tz = timezone_offset || '+00:00'
|
|
53
|
+
opened = pull_request.opened_at.getlocal(tz).to_date
|
|
54
|
+
closed = pull_request.closed_at.getlocal(tz).to_date
|
|
55
|
+
(closed - opened).to_i + 1
|
|
56
|
+
else
|
|
57
|
+
divisor = { minutes: 60, hours: 3600 }[@cycletime_unit]
|
|
58
|
+
((pull_request.closed_at - pull_request.opened_at) / divisor).round
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def label_cycletime value
|
|
63
|
+
case @cycletime_unit
|
|
64
|
+
when :minutes then label_minutes(value)
|
|
65
|
+
when :hours then label_hours(value)
|
|
66
|
+
when :days then label_days(value)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def title_value pull_request, rules: nil
|
|
71
|
+
age_label = label_cycletime y_value(pull_request)
|
|
72
|
+
keys = pull_request.issue_keys.join(', ')
|
|
73
|
+
"#{keys} | #{pull_request.title} | #{rules.label} | Age:#{age_label}#{lines_changed_text(pull_request)}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def lines_changed_text pull_request
|
|
77
|
+
return '' unless pull_request.changed_files
|
|
78
|
+
|
|
79
|
+
additions = pull_request.additions || 0
|
|
80
|
+
deletions = pull_request.deletions || 0
|
|
81
|
+
text = +' | Lines changed: ['
|
|
82
|
+
text << "+#{to_human_readable additions}" unless additions.zero?
|
|
83
|
+
text << ' ' if additions != 0 && deletions != 0
|
|
84
|
+
text << "-#{to_human_readable deletions}" unless deletions.zero?
|
|
85
|
+
text << "], Files changed: #{to_human_readable pull_request.changed_files}"
|
|
86
|
+
text
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# When strings are serialized into JSON, they're converted to actual strings. The purpose
|
|
4
|
+
# of this class is to allow raw javascript to be passed through.
|
|
5
|
+
class RawJavascript
|
|
6
|
+
def initialize content
|
|
7
|
+
@content = content
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def to_json(*_args)
|
|
11
|
+
@content
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def == other
|
|
15
|
+
other.is_a?(RawJavascript) && to_json == other.to_json
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -7,5 +7,9 @@
|
|
|
7
7
|
"flagged_means_blocked": true,
|
|
8
8
|
|
|
9
9
|
"expedited_priority_names": ["Critical", "Highest"],
|
|
10
|
-
"priority_order": ["Lowest", "Low", "Medium", "High", "Highest"]
|
|
10
|
+
"priority_order": ["Lowest", "Low", "Medium", "High", "Highest"],
|
|
11
|
+
|
|
12
|
+
"cache_cycletime_calculations": true,
|
|
13
|
+
|
|
14
|
+
"date_annotations": []
|
|
11
15
|
}
|
data/lib/jirametrics/sprint.rb
CHANGED
|
@@ -13,6 +13,7 @@ class Sprint
|
|
|
13
13
|
def id = @raw['id']
|
|
14
14
|
def active? = (@raw['state'] == 'active')
|
|
15
15
|
def closed? = (@raw['state'] == 'closed')
|
|
16
|
+
def future? = (@raw['state'] == 'future')
|
|
16
17
|
|
|
17
18
|
def completed_at? time
|
|
18
19
|
completed_at = completed_time
|
|
@@ -36,6 +37,17 @@ class Sprint
|
|
|
36
37
|
def goal = @raw['goal']
|
|
37
38
|
def name = @raw['name']
|
|
38
39
|
|
|
40
|
+
def day_count
|
|
41
|
+
return '' if future?
|
|
42
|
+
|
|
43
|
+
if closed?
|
|
44
|
+
days = (completed_time.to_date - start_time.to_date).to_i + 1
|
|
45
|
+
else
|
|
46
|
+
days = (end_time.to_date - start_time.to_date).to_i + 1
|
|
47
|
+
end
|
|
48
|
+
"#{days} days"
|
|
49
|
+
end
|
|
50
|
+
|
|
39
51
|
private
|
|
40
52
|
|
|
41
53
|
def parse_time time_string
|
|
@@ -29,6 +29,8 @@ class SprintBurndown < ChartBase
|
|
|
29
29
|
</div>
|
|
30
30
|
#{describe_non_working_days}
|
|
31
31
|
TEXT
|
|
32
|
+
@x_axis_title = 'Date'
|
|
33
|
+
@y_axis_title = 'Items remaining'
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
def options= arg
|
|
@@ -48,8 +50,9 @@ class SprintBurndown < ChartBase
|
|
|
48
50
|
end
|
|
49
51
|
|
|
50
52
|
def run
|
|
51
|
-
|
|
52
|
-
|
|
53
|
+
return nil unless current_board.scrum?
|
|
54
|
+
|
|
55
|
+
sprints = sprints_in_time_range current_board
|
|
53
56
|
|
|
54
57
|
change_data_by_sprint = {}
|
|
55
58
|
sprints.each do |sprint|
|
|
@@ -63,7 +66,7 @@ class SprintBurndown < ChartBase
|
|
|
63
66
|
result = +''
|
|
64
67
|
result << render_top_text(binding)
|
|
65
68
|
|
|
66
|
-
possible_colours = (1..
|
|
69
|
+
possible_colours = (1..ChartBase::OKABE_ITO_PALETTE.size).collect { |i| CssVariable["--sprint-burndown-sprint-color-#{i}"] }
|
|
67
70
|
charts_to_generate = []
|
|
68
71
|
charts_to_generate << [:data_set_by_story_points, 'Story Points'] if @use_story_points
|
|
69
72
|
charts_to_generate << [:data_set_by_story_counts, 'Story Count'] if @use_story_counts
|
|
@@ -110,6 +113,9 @@ class SprintBurndown < ChartBase
|
|
|
110
113
|
|
|
111
114
|
def sprints_in_time_range board
|
|
112
115
|
board.sprints.select do |sprint|
|
|
116
|
+
# If it's never been started then it's just a holding area. Ignore it.
|
|
117
|
+
next if sprint.future?
|
|
118
|
+
|
|
113
119
|
sprint_end_time = sprint.completed_time || sprint.end_time
|
|
114
120
|
sprint_start_time = sprint.start_time
|
|
115
121
|
next false if sprint_start_time.nil?
|
|
@@ -128,7 +134,7 @@ class SprintBurndown < ChartBase
|
|
|
128
134
|
|
|
129
135
|
estimate_display_name = current_board.estimation_configuration.display_name
|
|
130
136
|
|
|
131
|
-
issue_completed_time = issue.
|
|
137
|
+
issue_completed_time = issue.started_stopped_times.last
|
|
132
138
|
completed_has_been_tracked = false
|
|
133
139
|
|
|
134
140
|
issue.changes.each do |change|
|
data/lib/jirametrics/status.rb
CHANGED
|
@@ -36,7 +36,7 @@ class Status
|
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def self.from_raw raw
|
|
39
|
-
raise
|
|
39
|
+
raise 'raw cannot be nil' if raw.nil?
|
|
40
40
|
|
|
41
41
|
category_config = raw['statusCategory']
|
|
42
42
|
raise "statusCategory can't be nil in #{category_config.inspect}" if category_config.nil?
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Stitcher < HtmlGenerator
|
|
4
|
+
class StitchContent
|
|
5
|
+
include ValueEquality
|
|
6
|
+
|
|
7
|
+
attr_reader :file, :title, :content, :type
|
|
8
|
+
|
|
9
|
+
def initialize file:, title:, type:, content:
|
|
10
|
+
@file = file
|
|
11
|
+
@title = title
|
|
12
|
+
@content = content
|
|
13
|
+
@type = type
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
attr_reader :loaded_files, :all_stitches
|
|
18
|
+
|
|
19
|
+
def initialize file_system:
|
|
20
|
+
super()
|
|
21
|
+
self.file_system = file_system
|
|
22
|
+
@all_stitches = []
|
|
23
|
+
@loaded_files = []
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run stitch_file:
|
|
27
|
+
output_filename = make_output_filename stitch_file
|
|
28
|
+
file_system.log "Creating file #{output_filename.inspect}", also_write_to_stderr: true
|
|
29
|
+
erb = ERB.new file_system.load(stitch_file)
|
|
30
|
+
@sections = [[erb.result(binding), :body]]
|
|
31
|
+
create_html output_filename: output_filename, settings: {}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def make_output_filename input_filename
|
|
35
|
+
if /^(.+)\.erb$/ =~ input_filename
|
|
36
|
+
"#{$1}.html"
|
|
37
|
+
else
|
|
38
|
+
"#{input_filename}.html"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def grab_by_title title, from_file:, type: 'chart'
|
|
43
|
+
parse_file from_file
|
|
44
|
+
stitch_content = @all_stitches.find { |s| s.file == from_file && s.title == title && s.type == type }
|
|
45
|
+
return stitch_content.content if stitch_content
|
|
46
|
+
|
|
47
|
+
file_system.error "Unable to find content in file #{from_file.inspect} matching title: #{title.inspect}"
|
|
48
|
+
''
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def parse_file filename
|
|
52
|
+
return false if @loaded_files.include? filename
|
|
53
|
+
|
|
54
|
+
# To match: <!-- seam-start | chart78 | GithubPrScatterplot | PR Scatterplot | chart -->
|
|
55
|
+
regex = /^<!-- seam-(?<seam>start|end) \| (?<id>[^|]+) \| (?<clazz>[^|]+) \| (?<title>[^|]+) \| (?<type>[^|]+) -->$/
|
|
56
|
+
content = nil
|
|
57
|
+
file_system.load(filename).lines do |line|
|
|
58
|
+
matches = line.match(regex)
|
|
59
|
+
if matches
|
|
60
|
+
if matches[:seam] == 'start'
|
|
61
|
+
content = +''
|
|
62
|
+
else
|
|
63
|
+
if content.nil? || content.strip.empty?
|
|
64
|
+
file_system.warning "Seam found with no content in #{filename.inspect}: " \
|
|
65
|
+
"id=#{matches[:id].strip.inspect}, class=#{matches[:clazz].strip.inspect}, " \
|
|
66
|
+
"title=#{matches[:title].strip.inspect}"
|
|
67
|
+
end
|
|
68
|
+
@all_stitches << Stitcher::StitchContent.new(
|
|
69
|
+
file: filename, title: matches[:title], type: matches[:type], content: content
|
|
70
|
+
)
|
|
71
|
+
content = nil
|
|
72
|
+
end
|
|
73
|
+
elsif content
|
|
74
|
+
content << line
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
@loaded_files << filename
|
|
79
|
+
true
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jirametrics/throughput_chart'
|
|
4
|
+
|
|
5
|
+
class ThroughputByCompletedResolutionChart < ThroughputChart
|
|
6
|
+
def initialize block
|
|
7
|
+
super
|
|
8
|
+
header_text 'Throughput, grouped by completion status and resolution'
|
|
9
|
+
description_text nil
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def default_grouping_rules issue, rules
|
|
13
|
+
status, resolution = issue.status_resolution_at_done
|
|
14
|
+
if resolution
|
|
15
|
+
rules.label = "#{status.name}:#{resolution}"
|
|
16
|
+
rules.label_hint = "Status: #{status.name.inspect}:#{status.id}, resolution: #{resolution.inspect}"
|
|
17
|
+
else
|
|
18
|
+
rules.label = status.name
|
|
19
|
+
rules.label_hint = "Status: #{status.name.inspect}:#{status.id}"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|