jirametrics 2.22 → 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 +26 -10
- 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 +74 -1
- data/lib/jirametrics/atlassian_document_format.rb +93 -93
- data/lib/jirametrics/blocked_stalled_change.rb +5 -3
- data/lib/jirametrics/board.rb +28 -8
- 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 +4 -3
- data/lib/jirametrics/chart_base.rb +107 -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} +1 -2
- data/lib/jirametrics/cycletime_histogram.rb +15 -103
- data/lib/jirametrics/cycletime_scatterplot.rb +13 -98
- data/lib/jirametrics/daily_view.rb +38 -13
- data/lib/jirametrics/daily_wip_by_age_chart.rb +1 -1
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +1 -1
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
- data/lib/jirametrics/daily_wip_chart.rb +29 -7
- data/lib/jirametrics/data_quality_report.rb +38 -12
- data/lib/jirametrics/dependency_chart.rb +2 -2
- data/lib/jirametrics/download_config.rb +15 -0
- data/lib/jirametrics/downloader.rb +87 -5
- data/lib/jirametrics/downloader_for_cloud.rb +107 -22
- data/lib/jirametrics/downloader_for_data_center.rb +3 -2
- 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 +32 -19
- data/lib/jirametrics/expedited_chart.rb +3 -1
- data/lib/jirametrics/exporter.rb +15 -2
- data/lib/jirametrics/file_config.rb +9 -11
- data/lib/jirametrics/file_system.rb +35 -2
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
- data/lib/jirametrics/github_gateway.rb +115 -0
- data/lib/jirametrics/groupable_issue_chart.rb +4 -0
- data/lib/jirametrics/grouping_rules.rb +26 -4
- data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -4
- data/lib/jirametrics/html/aging_work_table.erb +3 -0
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +38 -5
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +2 -12
- data/lib/jirametrics/html/expedited_chart.erb +3 -13
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +2 -8
- data/lib/jirametrics/html/index.css +228 -60
- data/lib/jirametrics/html/index.erb +6 -0
- data/lib/jirametrics/html/index.js +53 -3
- data/lib/jirametrics/html/legacy_colors.css +174 -0
- data/lib/jirametrics/html/sprint_burndown.erb +7 -13
- data/lib/jirametrics/html/throughput_chart.erb +40 -9
- data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +59 -59
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +11 -7
- data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
- data/lib/jirametrics/html_generator.rb +2 -1
- data/lib/jirametrics/html_report_config.rb +45 -33
- data/lib/jirametrics/issue.rb +197 -99
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +32 -10
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +87 -8
- 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 +4 -0
- data/lib/jirametrics/settings.json +3 -1
- data/lib/jirametrics/sprint_burndown.rb +4 -2
- data/lib/jirametrics/status.rb +1 -1
- data/lib/jirametrics/stitcher.rb +7 -1
- 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 +58 -0
- metadata +52 -5
|
@@ -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
|
|
@@ -67,7 +71,10 @@ class ProjectConfig
|
|
|
67
71
|
file_system.deprecated message: 'stalled color should be set via css now', date: '2024-05-03'
|
|
68
72
|
end
|
|
69
73
|
|
|
70
|
-
settings
|
|
74
|
+
settings['blocked_statuses'] = StatusCollection.new
|
|
75
|
+
settings['stalled_statuses'] = StatusCollection.new
|
|
76
|
+
|
|
77
|
+
stringify_keys(settings)
|
|
71
78
|
end
|
|
72
79
|
|
|
73
80
|
def guess_project_id
|
|
@@ -91,6 +98,12 @@ class ProjectConfig
|
|
|
91
98
|
!!@aggregate_config
|
|
92
99
|
end
|
|
93
100
|
|
|
101
|
+
def aggregate_project_names
|
|
102
|
+
return [] unless aggregated_project?
|
|
103
|
+
|
|
104
|
+
@aggregate_config.included_projects.filter_map(&:name)
|
|
105
|
+
end
|
|
106
|
+
|
|
94
107
|
def download &block
|
|
95
108
|
raise 'Not allowed to have multiple download blocks in one project' if @download_config
|
|
96
109
|
raise 'Not allowed to have both an aggregate and a download section. Pick only one.' if @aggregate_config
|
|
@@ -143,6 +156,17 @@ class ProjectConfig
|
|
|
143
156
|
@file_prefix
|
|
144
157
|
end
|
|
145
158
|
|
|
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
|
+
|
|
146
170
|
def raise_if_prefix_already_used prefix
|
|
147
171
|
@exporter.project_configs.each do |project|
|
|
148
172
|
next unless project.get_file_prefix(raise_if_not_set: false) == prefix && project.target_path == target_path
|
|
@@ -267,9 +291,16 @@ class ProjectConfig
|
|
|
267
291
|
end
|
|
268
292
|
|
|
269
293
|
def load_board board_id:, filename:
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
)
|
|
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)
|
|
273
304
|
board.project_config = self
|
|
274
305
|
@all_boards[board_id] = board
|
|
275
306
|
end
|
|
@@ -326,6 +357,13 @@ class ProjectConfig
|
|
|
326
357
|
end
|
|
327
358
|
end
|
|
328
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
|
+
|
|
329
367
|
def load_project_metadata
|
|
330
368
|
filename = File.join @target_path, "#{get_file_prefix}_meta.json"
|
|
331
369
|
json = file_system.load_json(filename)
|
|
@@ -362,6 +400,19 @@ class ProjectConfig
|
|
|
362
400
|
json.each { |user_data| @users << User.new(raw: user_data) }
|
|
363
401
|
end
|
|
364
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
|
+
|
|
365
416
|
def atlassian_document_format
|
|
366
417
|
@atlassian_document_format ||= AtlassianDocumentFormat.new(
|
|
367
418
|
users: @users, timezone_offset: exporter.timezone_offset
|
|
@@ -402,7 +453,7 @@ class ProjectConfig
|
|
|
402
453
|
# To be used by the aggregate_config only. Not intended to be part of the public API
|
|
403
454
|
def add_issues issues_list
|
|
404
455
|
@issues = IssueCollection.new if @issues.nil?
|
|
405
|
-
@all_boards
|
|
456
|
+
@all_boards ||= {}
|
|
406
457
|
|
|
407
458
|
issues_list.each do |issue|
|
|
408
459
|
@issues << issue
|
|
@@ -444,6 +495,7 @@ class ProjectConfig
|
|
|
444
495
|
# attached them in the appropriate places, remove any that aren't part of that initial set.
|
|
445
496
|
issues.reject! { |i| !i.in_initial_query? } # rubocop:disable Style/InverseMethods
|
|
446
497
|
@issues = issues
|
|
498
|
+
attach_github_prs
|
|
447
499
|
end
|
|
448
500
|
|
|
449
501
|
@issues
|
|
@@ -563,6 +615,8 @@ class ProjectConfig
|
|
|
563
615
|
if status_becomes
|
|
564
616
|
status_becomes = [status_becomes] unless status_becomes.is_a? Array
|
|
565
617
|
|
|
618
|
+
status_becomes.each { |status_name| validate_discard_status status_name }
|
|
619
|
+
|
|
566
620
|
block = lambda do |issue|
|
|
567
621
|
trigger_statuses = status_becomes.collect do |status_name|
|
|
568
622
|
if status_name == :backlog
|
|
@@ -588,7 +642,7 @@ class ProjectConfig
|
|
|
588
642
|
cutoff_time = block.call(issue)
|
|
589
643
|
next if cutoff_time.nil?
|
|
590
644
|
|
|
591
|
-
original_start_time = issue.
|
|
645
|
+
original_start_time = issue.started_stopped_times.first
|
|
592
646
|
next if original_start_time.nil?
|
|
593
647
|
|
|
594
648
|
issue.discard_changes_before cutoff_time
|
|
@@ -606,4 +660,29 @@ class ProjectConfig
|
|
|
606
660
|
|
|
607
661
|
cycletimes_touched.each { |c| c.flush_cache }
|
|
608
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
|
|
687
|
+
end
|
|
609
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
|
|
@@ -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
|
|
@@ -64,7 +66,7 @@ class SprintBurndown < ChartBase
|
|
|
64
66
|
result = +''
|
|
65
67
|
result << render_top_text(binding)
|
|
66
68
|
|
|
67
|
-
possible_colours = (1..
|
|
69
|
+
possible_colours = (1..ChartBase::OKABE_ITO_PALETTE.size).collect { |i| CssVariable["--sprint-burndown-sprint-color-#{i}"] }
|
|
68
70
|
charts_to_generate = []
|
|
69
71
|
charts_to_generate << [:data_set_by_story_points, 'Story Points'] if @use_story_points
|
|
70
72
|
charts_to_generate << [:data_set_by_story_counts, 'Story Count'] if @use_story_counts
|
|
@@ -132,7 +134,7 @@ class SprintBurndown < ChartBase
|
|
|
132
134
|
|
|
133
135
|
estimate_display_name = current_board.estimation_configuration.display_name
|
|
134
136
|
|
|
135
|
-
issue_completed_time = issue.
|
|
137
|
+
issue_completed_time = issue.started_stopped_times.last
|
|
136
138
|
completed_has_been_tracked = false
|
|
137
139
|
|
|
138
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?
|
data/lib/jirametrics/stitcher.rb
CHANGED
|
@@ -44,7 +44,8 @@ class Stitcher < HtmlGenerator
|
|
|
44
44
|
stitch_content = @all_stitches.find { |s| s.file == from_file && s.title == title && s.type == type }
|
|
45
45
|
return stitch_content.content if stitch_content
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
file_system.error "Unable to find content in file #{from_file.inspect} matching title: #{title.inspect}"
|
|
48
|
+
''
|
|
48
49
|
end
|
|
49
50
|
|
|
50
51
|
def parse_file filename
|
|
@@ -59,6 +60,11 @@ class Stitcher < HtmlGenerator
|
|
|
59
60
|
if matches[:seam] == 'start'
|
|
60
61
|
content = +''
|
|
61
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
|
|
62
68
|
@all_stitches << Stitcher::StitchContent.new(
|
|
63
69
|
file: filename, title: matches[:title], type: matches[:type], content: content
|
|
64
70
|
)
|
|
@@ -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
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'cgi'
|
|
4
|
+
|
|
3
5
|
class ThroughputChart < ChartBase
|
|
4
6
|
include GroupableIssueChart
|
|
5
7
|
|
|
@@ -10,42 +12,54 @@ class ThroughputChart < ChartBase
|
|
|
10
12
|
|
|
11
13
|
header_text 'Throughput Chart'
|
|
12
14
|
description_text <<-TEXT
|
|
13
|
-
<div
|
|
14
|
-
|
|
15
|
+
<div>Throughput data is very useful for#{' '}
|
|
16
|
+
<a href="https://blog.mikebowler.ca/2024/06/02/probabilistic-forecasting/">probabilistic forecasting</a>,
|
|
17
|
+
to determine when we'll be done. Try it now with the
|
|
18
|
+
<a href="<%= throughput_forecaster_url %>" target="_blank" rel="noopener noreferrer">
|
|
19
|
+
Focused Objective throughput forecaster,</a> to see how long it would take to complete all of the
|
|
20
|
+
<%= @not_started_count %> items you currently have in your backlog.
|
|
15
21
|
</div>
|
|
16
22
|
#{describe_non_working_days}
|
|
17
23
|
TEXT
|
|
24
|
+
@x_axis_title = nil
|
|
25
|
+
@y_axis_title = 'Count of items'
|
|
18
26
|
|
|
19
27
|
init_configuration_block(block) do
|
|
20
|
-
grouping_rules
|
|
21
|
-
rule.label = issue.type
|
|
22
|
-
rule.color = color_for type: issue.type
|
|
23
|
-
end
|
|
28
|
+
grouping_rules { |issue, rule| default_grouping_rules(issue, rule) }
|
|
24
29
|
end
|
|
25
30
|
end
|
|
26
31
|
|
|
27
32
|
def run
|
|
33
|
+
# This is saved as an instance variable so that it's accessible later when rendering the description text
|
|
34
|
+
@not_started_count = issues.count { |issue| issue.started_stopped_times.first.nil? }
|
|
35
|
+
|
|
28
36
|
completed_issues = completed_issues_in_range include_unstarted: true
|
|
29
37
|
rules_to_issues = group_issues completed_issues
|
|
30
38
|
data_sets = []
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
total_data_set = weekly_throughput_dataset(
|
|
40
|
+
completed_issues: completed_issues,
|
|
41
|
+
label: 'Totals',
|
|
42
|
+
color: CssVariable['--throughput_chart_total_line_color'],
|
|
43
|
+
dashed: true
|
|
44
|
+
)
|
|
45
|
+
@throughput_samples = total_data_set[:data].collect { |d| d[:y] }
|
|
46
|
+
data_sets << total_data_set if rules_to_issues.size > 1
|
|
39
47
|
|
|
40
48
|
rules_to_issues.each_key do |rules|
|
|
41
49
|
data_sets << weekly_throughput_dataset(
|
|
42
|
-
completed_issues: rules_to_issues[rules], label: rules.label, color: rules.color
|
|
50
|
+
completed_issues: rules_to_issues[rules], label: rules.label, color: rules.color,
|
|
51
|
+
label_hint: rules.label_hint
|
|
43
52
|
)
|
|
44
53
|
end
|
|
45
54
|
|
|
46
55
|
wrap_and_render(binding, __FILE__)
|
|
47
56
|
end
|
|
48
57
|
|
|
58
|
+
def default_grouping_rules issue, rule
|
|
59
|
+
rule.label = issue.type
|
|
60
|
+
rule.color = color_for type: issue.type
|
|
61
|
+
end
|
|
62
|
+
|
|
49
63
|
def calculate_time_periods
|
|
50
64
|
first_day = @date_range.begin
|
|
51
65
|
first_day = case first_day.wday
|
|
@@ -65,10 +79,22 @@ class ThroughputChart < ChartBase
|
|
|
65
79
|
end
|
|
66
80
|
end
|
|
67
81
|
|
|
68
|
-
def
|
|
82
|
+
def calculate_custom_periods
|
|
83
|
+
last_days = @issue_periods.values.compact.uniq.sort
|
|
84
|
+
last_days.each_with_index.map do |last_day, i|
|
|
85
|
+
first_day = i.zero? ? @date_range.begin : last_days[i - 1] + 1
|
|
86
|
+
first_day..last_day
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def weekly_throughput_dataset completed_issues:, label:, color:, dashed: false, label_hint: nil
|
|
91
|
+
periods = @issue_periods&.values&.any? ? calculate_custom_periods : calculate_time_periods
|
|
69
92
|
result = {
|
|
70
93
|
label: label,
|
|
71
|
-
|
|
94
|
+
label_hint: label_hint,
|
|
95
|
+
data: throughput_dataset(
|
|
96
|
+
periods: periods, completed_issues: completed_issues, label_hint: label_hint
|
|
97
|
+
),
|
|
72
98
|
fill: false,
|
|
73
99
|
showLine: true,
|
|
74
100
|
borderColor: color,
|
|
@@ -79,20 +105,44 @@ class ThroughputChart < ChartBase
|
|
|
79
105
|
result
|
|
80
106
|
end
|
|
81
107
|
|
|
82
|
-
def
|
|
108
|
+
def throughput_forecaster_url
|
|
109
|
+
params = {
|
|
110
|
+
throughputMode: 'data',
|
|
111
|
+
samplesText: @throughput_samples.join(','),
|
|
112
|
+
storyLow: @not_started_count,
|
|
113
|
+
storyHigh: @not_started_count
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
query = params.map { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }.join('&')
|
|
117
|
+
"https://focusedobjective.com/throughput?#{query}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def throughput_dataset periods:, completed_issues:, label_hint: nil
|
|
121
|
+
custom_mode = @issue_periods&.values&.any?
|
|
83
122
|
periods.collect do |period|
|
|
84
123
|
closed_issues = completed_issues.filter_map do |issue|
|
|
85
|
-
stop_date = issue.
|
|
86
|
-
|
|
124
|
+
stop_date = issue.started_stopped_dates.last
|
|
125
|
+
next unless stop_date
|
|
126
|
+
|
|
127
|
+
if custom_mode
|
|
128
|
+
[stop_date, issue] if @issue_periods[issue] == period.end
|
|
129
|
+
elsif period.include?(stop_date)
|
|
130
|
+
[stop_date, issue]
|
|
131
|
+
end
|
|
87
132
|
end
|
|
88
133
|
|
|
89
134
|
date_label = "on #{period.end}"
|
|
90
135
|
date_label = "between #{period.begin} and #{period.end}" unless period.begin == period.end
|
|
91
136
|
|
|
92
|
-
{
|
|
137
|
+
with_label_hint = label_hint ? " with #{label_hint}" : ''
|
|
138
|
+
{
|
|
139
|
+
y: closed_issues.size,
|
|
93
140
|
x: "#{period.end}T23:59:59",
|
|
94
|
-
title: ["#{closed_issues.size} items
|
|
95
|
-
closed_issues.collect
|
|
141
|
+
title: ["#{closed_issues.size} items closed#{with_label_hint} #{date_label}"] +
|
|
142
|
+
closed_issues.collect do |_stop_date, issue|
|
|
143
|
+
hint = @issue_hints&.fetch(issue, nil)
|
|
144
|
+
"#{issue.key} : #{issue.summary}#{" #{hint}" if hint}"
|
|
145
|
+
end
|
|
96
146
|
}
|
|
97
147
|
end
|
|
98
148
|
end
|