jirametrics 2.14 → 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 +96 -96
- 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 +3 -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 +139 -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 +42 -31
- 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 +244 -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 +302 -98
- 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 +108 -9
- 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
|
|
@@ -134,6 +156,17 @@ class ProjectConfig
|
|
|
134
156
|
@file_prefix
|
|
135
157
|
end
|
|
136
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
|
+
|
|
137
170
|
def raise_if_prefix_already_used prefix
|
|
138
171
|
@exporter.project_configs.each do |project|
|
|
139
172
|
next unless project.get_file_prefix(raise_if_not_set: false) == prefix && project.target_path == target_path
|
|
@@ -258,9 +291,16 @@ class ProjectConfig
|
|
|
258
291
|
end
|
|
259
292
|
|
|
260
293
|
def load_board board_id:, filename:
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
)
|
|
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)
|
|
264
304
|
board.project_config = self
|
|
265
305
|
@all_boards[board_id] = board
|
|
266
306
|
end
|
|
@@ -295,8 +335,9 @@ class ProjectConfig
|
|
|
295
335
|
file_system.foreach(@target_path) do |file|
|
|
296
336
|
next unless file =~ /^#{get_file_prefix}_board_(\d+)_sprints_\d+.json$/
|
|
297
337
|
|
|
338
|
+
board_id = $1.to_i
|
|
298
339
|
file_path = File.join(@target_path, file)
|
|
299
|
-
board = @all_boards[
|
|
340
|
+
board = @all_boards[board_id]
|
|
300
341
|
unless board
|
|
301
342
|
@exporter.file_system.log(
|
|
302
343
|
'Found sprint data but can\'t find a matching board in config. ' \
|
|
@@ -316,6 +357,13 @@ class ProjectConfig
|
|
|
316
357
|
end
|
|
317
358
|
end
|
|
318
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
|
+
|
|
319
367
|
def load_project_metadata
|
|
320
368
|
filename = File.join @target_path, "#{get_file_prefix}_meta.json"
|
|
321
369
|
json = file_system.load_json(filename)
|
|
@@ -352,6 +400,25 @@ class ProjectConfig
|
|
|
352
400
|
json.each { |user_data| @users << User.new(raw: user_data) }
|
|
353
401
|
end
|
|
354
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
|
+
|
|
355
422
|
def to_time string, end_of_day: false
|
|
356
423
|
time = end_of_day ? '23:59:59' : '00:00:00'
|
|
357
424
|
string = "#{string}T#{time}#{exporter.timezone_offset}" if string.match?(/^\d{4}-\d{2}-\d{2}$/)
|
|
@@ -386,7 +453,7 @@ class ProjectConfig
|
|
|
386
453
|
# To be used by the aggregate_config only. Not intended to be part of the public API
|
|
387
454
|
def add_issues issues_list
|
|
388
455
|
@issues = IssueCollection.new if @issues.nil?
|
|
389
|
-
@all_boards
|
|
456
|
+
@all_boards ||= {}
|
|
390
457
|
|
|
391
458
|
issues_list.each do |issue|
|
|
392
459
|
@issues << issue
|
|
@@ -428,6 +495,7 @@ class ProjectConfig
|
|
|
428
495
|
# attached them in the appropriate places, remove any that aren't part of that initial set.
|
|
429
496
|
issues.reject! { |i| !i.in_initial_query? } # rubocop:disable Style/InverseMethods
|
|
430
497
|
@issues = issues
|
|
498
|
+
attach_github_prs
|
|
431
499
|
end
|
|
432
500
|
|
|
433
501
|
@issues
|
|
@@ -543,9 +611,12 @@ class ProjectConfig
|
|
|
543
611
|
end
|
|
544
612
|
|
|
545
613
|
def discard_changes_before status_becomes: nil, &block
|
|
614
|
+
cycletimes_touched = Set.new
|
|
546
615
|
if status_becomes
|
|
547
616
|
status_becomes = [status_becomes] unless status_becomes.is_a? Array
|
|
548
617
|
|
|
618
|
+
status_becomes.each { |status_name| validate_discard_status status_name }
|
|
619
|
+
|
|
549
620
|
block = lambda do |issue|
|
|
550
621
|
trigger_statuses = status_becomes.collect do |status_name|
|
|
551
622
|
if status_name == :backlog
|
|
@@ -571,10 +642,11 @@ class ProjectConfig
|
|
|
571
642
|
cutoff_time = block.call(issue)
|
|
572
643
|
next if cutoff_time.nil?
|
|
573
644
|
|
|
574
|
-
original_start_time = issue.
|
|
645
|
+
original_start_time = issue.started_stopped_times.first
|
|
575
646
|
next if original_start_time.nil?
|
|
576
647
|
|
|
577
648
|
issue.discard_changes_before cutoff_time
|
|
649
|
+
cycletimes_touched << issue.board.cycletime
|
|
578
650
|
|
|
579
651
|
next unless cutoff_time
|
|
580
652
|
next if original_start_time > cutoff_time # ie the cutoff would have made no difference.
|
|
@@ -585,5 +657,32 @@ class ProjectConfig
|
|
|
585
657
|
issue: issue
|
|
586
658
|
}
|
|
587
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
|
|
588
687
|
end
|
|
589
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
|