jirametrics 2.4 → 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 +16 -3
- data/lib/jirametrics/aging_work_bar_chart.rb +193 -133
- data/lib/jirametrics/aging_work_in_progress_chart.rb +138 -42
- data/lib/jirametrics/aging_work_table.rb +63 -19
- data/lib/jirametrics/anonymizer.rb +81 -6
- data/lib/jirametrics/atlassian_document_format.rb +160 -0
- data/lib/jirametrics/bar_chart_range.rb +17 -0
- data/lib/jirametrics/blocked_stalled_change.rb +6 -4
- data/lib/jirametrics/board.rb +74 -22
- data/lib/jirametrics/board_config.rb +11 -3
- data/lib/jirametrics/board_feature.rb +14 -0
- data/lib/jirametrics/board_movement_calculator.rb +155 -0
- data/lib/jirametrics/cfd_data_builder.rb +108 -0
- data/lib/jirametrics/change_item.rb +54 -18
- data/lib/jirametrics/chart_base.rb +203 -30
- data/lib/jirametrics/css_variable.rb +2 -2
- data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
- data/lib/jirametrics/cycle_time_config.rb +137 -0
- data/lib/jirametrics/cycletime_histogram.rb +17 -38
- data/lib/jirametrics/cycletime_scatterplot.rb +18 -87
- data/lib/jirametrics/daily_view.rb +306 -0
- data/lib/jirametrics/daily_wip_by_age_chart.rb +5 -8
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +15 -5
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -6
- data/lib/jirametrics/daily_wip_chart.rb +36 -16
- data/lib/jirametrics/data_quality_report.rb +251 -42
- data/lib/jirametrics/dependency_chart.rb +42 -12
- data/lib/jirametrics/download_config.rb +27 -0
- data/lib/jirametrics/downloader.rb +185 -110
- 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 +75 -14
- data/lib/jirametrics/estimation_configuration.rb +25 -0
- data/lib/jirametrics/examples/aggregated_project.rb +9 -23
- data/lib/jirametrics/examples/standard_project.rb +57 -58
- data/lib/jirametrics/expedited_chart.rb +11 -10
- data/lib/jirametrics/exporter.rb +51 -14
- data/lib/jirametrics/file_config.rb +21 -6
- data/lib/jirametrics/file_system.rb +96 -4
- data/lib/jirametrics/fix_version.rb +13 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +115 -0
- data/lib/jirametrics/github_gateway.rb +115 -0
- data/lib/jirametrics/groupable_issue_chart.rb +12 -4
- data/lib/jirametrics/grouping_rules.rb +26 -4
- data/lib/jirametrics/html/aging_work_bar_chart.erb +8 -17
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +24 -5
- data/lib/jirametrics/html/aging_work_table.erb +13 -4
- 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 +41 -15
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
- data/lib/jirametrics/html/expedited_chart.erb +7 -24
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +81 -0
- data/lib/jirametrics/html/hierarchy_table.erb +1 -1
- data/lib/jirametrics/html/index.css +336 -62
- data/lib/jirametrics/html/index.erb +16 -21
- data/lib/jirametrics/html/index.js +164 -0
- data/lib/jirametrics/html/legacy_colors.css +174 -0
- data/lib/jirametrics/html/sprint_burndown.erb +18 -25
- data/lib/jirametrics/html/throughput_chart.erb +43 -21
- data/lib/jirametrics/html/time_based_histogram.erb +123 -0
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +16 -21
- 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 +83 -76
- data/lib/jirametrics/issue.rb +499 -91
- data/lib/jirametrics/issue_collection.rb +33 -0
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +96 -16
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +374 -130
- 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/rules.rb +2 -2
- data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
- data/lib/jirametrics/settings.json +10 -2
- data/lib/jirametrics/sprint.rb +13 -0
- data/lib/jirametrics/sprint_burndown.rb +47 -39
- data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
- data/lib/jirametrics/status.rb +84 -19
- data/lib/jirametrics/status_collection.rb +83 -38
- 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/user.rb +12 -0
- data/lib/jirametrics/value_equality.rb +2 -2
- data/lib/jirametrics/wip_by_column_chart.rb +236 -0
- data/lib/jirametrics.rb +101 -66
- metadata +72 -16
- data/lib/jirametrics/cycletime_config.rb +0 -69
- data/lib/jirametrics/discard_changes_before.rb +0 -37
- data/lib/jirametrics/html/cycletime_histogram.erb +0 -47
- data/lib/jirametrics/html/data_quality_report.erb +0 -126
|
@@ -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
|
data/lib/jirametrics/rules.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module SelfOrIssueDispatcher
|
|
4
|
+
# rubocop:disable Style/ArgumentsForwarding
|
|
4
5
|
def method_missing method_name, *args, &block
|
|
5
6
|
raise "#{method_name} isn't a method on Issue or #{self.class}" unless ::Issue.method_defined? method_name.to_sym
|
|
6
7
|
|
|
@@ -8,6 +9,7 @@ module SelfOrIssueDispatcher
|
|
|
8
9
|
issue.__send__ method_name, *args, &block
|
|
9
10
|
end
|
|
10
11
|
end
|
|
12
|
+
# rubocop:enable Style/ArgumentsForwarding
|
|
11
13
|
|
|
12
14
|
def respond_to_missing?(method_name, include_all = false)
|
|
13
15
|
::Issue.method_defined?(method_name.to_sym) || super
|
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
"stalled_threshold_days": 5,
|
|
3
3
|
"stalled_statuses": [],
|
|
4
4
|
|
|
5
|
-
"blocked_link_text": [],
|
|
6
|
-
"blocked_statuses": []
|
|
5
|
+
"blocked_link_text": ["is blocked by"],
|
|
6
|
+
"blocked_statuses": [],
|
|
7
|
+
"flagged_means_blocked": true,
|
|
8
|
+
|
|
9
|
+
"expedited_priority_names": ["Critical", "Highest"],
|
|
10
|
+
"priority_order": ["Lowest", "Low", "Medium", "High", "Highest"],
|
|
11
|
+
|
|
12
|
+
"cache_cycletime_calculations": true,
|
|
13
|
+
|
|
14
|
+
"date_annotations": []
|
|
7
15
|
}
|
data/lib/jirametrics/sprint.rb
CHANGED
|
@@ -12,6 +12,8 @@ class Sprint
|
|
|
12
12
|
|
|
13
13
|
def id = @raw['id']
|
|
14
14
|
def active? = (@raw['state'] == 'active')
|
|
15
|
+
def closed? = (@raw['state'] == 'closed')
|
|
16
|
+
def future? = (@raw['state'] == 'future')
|
|
15
17
|
|
|
16
18
|
def completed_at? time
|
|
17
19
|
completed_at = completed_time
|
|
@@ -35,6 +37,17 @@ class Sprint
|
|
|
35
37
|
def goal = @raw['goal']
|
|
36
38
|
def name = @raw['name']
|
|
37
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
|
+
|
|
38
51
|
private
|
|
39
52
|
|
|
40
53
|
def parse_time time_string
|
|
@@ -18,7 +18,7 @@ class SprintBurndown < ChartBase
|
|
|
18
18
|
attr_accessor :board_id
|
|
19
19
|
|
|
20
20
|
def initialize
|
|
21
|
-
super
|
|
21
|
+
super
|
|
22
22
|
|
|
23
23
|
@summary_stats = {}
|
|
24
24
|
header_text 'Sprint burndown'
|
|
@@ -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?
|
|
@@ -121,12 +127,14 @@ class SprintBurndown < ChartBase
|
|
|
121
127
|
|
|
122
128
|
# select all the changes that are relevant for the sprint. If this issue never appears in this sprint then return [].
|
|
123
129
|
def changes_for_one_issue issue:, sprint:
|
|
124
|
-
|
|
130
|
+
estimate = 0.0
|
|
125
131
|
ever_in_sprint = false
|
|
126
132
|
currently_in_sprint = false
|
|
127
133
|
change_data = []
|
|
128
134
|
|
|
129
|
-
|
|
135
|
+
estimate_display_name = current_board.estimation_configuration.display_name
|
|
136
|
+
|
|
137
|
+
issue_completed_time = issue.started_stopped_times.last
|
|
130
138
|
completed_has_been_tracked = false
|
|
131
139
|
|
|
132
140
|
issue.changes.each do |change|
|
|
@@ -140,26 +148,26 @@ class SprintBurndown < ChartBase
|
|
|
140
148
|
if currently_in_sprint == false && in_change_item
|
|
141
149
|
action = :enter_sprint
|
|
142
150
|
ever_in_sprint = true
|
|
143
|
-
value =
|
|
151
|
+
value = estimate
|
|
144
152
|
elsif currently_in_sprint && in_change_item == false
|
|
145
153
|
action = :leave_sprint
|
|
146
|
-
value = -
|
|
154
|
+
value = -estimate
|
|
147
155
|
end
|
|
148
156
|
currently_in_sprint = in_change_item
|
|
149
|
-
elsif change.
|
|
157
|
+
elsif change.field == estimate_display_name && (issue_completed_time.nil? || change.time < issue_completed_time)
|
|
150
158
|
action = :story_points
|
|
151
|
-
|
|
152
|
-
value =
|
|
159
|
+
estimate = change.value.to_f
|
|
160
|
+
value = estimate - change.old_value.to_f
|
|
153
161
|
elsif completed_has_been_tracked == false && change.time == issue_completed_time
|
|
154
162
|
completed_has_been_tracked = true
|
|
155
163
|
action = :issue_stopped
|
|
156
|
-
value = -
|
|
164
|
+
value = -estimate
|
|
157
165
|
end
|
|
158
166
|
|
|
159
167
|
next unless action
|
|
160
168
|
|
|
161
169
|
change_data << SprintIssueChangeData.new(
|
|
162
|
-
time: change.time, issue: issue, action: action, value: value,
|
|
170
|
+
time: change.time, issue: issue, action: action, value: value, estimate: estimate
|
|
163
171
|
)
|
|
164
172
|
end
|
|
165
173
|
|
|
@@ -172,11 +180,11 @@ class SprintBurndown < ChartBase
|
|
|
172
180
|
change_item.raw['to'].split(/\s*,\s*/).any? { |id| id.to_i == sprint.id }
|
|
173
181
|
end
|
|
174
182
|
|
|
175
|
-
def data_set_by_story_points sprint:, change_data_for_sprint:
|
|
183
|
+
def data_set_by_story_points sprint:, change_data_for_sprint: # rubocop:disable Metrics/CyclomaticComplexity
|
|
176
184
|
summary_stats = SprintSummaryStats.new
|
|
177
185
|
summary_stats.completed = 0.0
|
|
178
186
|
|
|
179
|
-
|
|
187
|
+
estimate = 0.0
|
|
180
188
|
start_data_written = false
|
|
181
189
|
data_set = []
|
|
182
190
|
|
|
@@ -185,11 +193,11 @@ class SprintBurndown < ChartBase
|
|
|
185
193
|
change_data_for_sprint.each do |change_data|
|
|
186
194
|
if start_data_written == false && change_data.time >= sprint.start_time
|
|
187
195
|
data_set << {
|
|
188
|
-
y:
|
|
196
|
+
y: estimate,
|
|
189
197
|
x: chart_format(sprint.start_time),
|
|
190
|
-
title: "Sprint started with #{
|
|
198
|
+
title: "Sprint started with #{estimate} points"
|
|
191
199
|
}
|
|
192
|
-
summary_stats.started =
|
|
200
|
+
summary_stats.started = estimate
|
|
193
201
|
start_data_written = true
|
|
194
202
|
end
|
|
195
203
|
|
|
@@ -198,12 +206,12 @@ class SprintBurndown < ChartBase
|
|
|
198
206
|
case change_data.action
|
|
199
207
|
when :enter_sprint
|
|
200
208
|
issues_currently_in_sprint << change_data.issue.key
|
|
201
|
-
|
|
209
|
+
estimate += change_data.estimate
|
|
202
210
|
when :leave_sprint
|
|
203
211
|
issues_currently_in_sprint.delete change_data.issue.key
|
|
204
|
-
|
|
212
|
+
estimate -= change_data.estimate
|
|
205
213
|
when :story_points
|
|
206
|
-
|
|
214
|
+
estimate += change_data.value if issues_currently_in_sprint.include? change_data.issue.key
|
|
207
215
|
end
|
|
208
216
|
|
|
209
217
|
next unless change_data.time >= sprint.start_time
|
|
@@ -213,26 +221,26 @@ class SprintBurndown < ChartBase
|
|
|
213
221
|
when :story_points
|
|
214
222
|
next unless issues_currently_in_sprint.include? change_data.issue.key
|
|
215
223
|
|
|
216
|
-
|
|
217
|
-
message = "Story points changed from #{
|
|
224
|
+
old_estimate = change_data.estimate - change_data.value
|
|
225
|
+
message = "Story points changed from #{old_estimate} points to #{change_data.estimate} points"
|
|
218
226
|
summary_stats.points_values_changed = true
|
|
219
227
|
when :enter_sprint
|
|
220
|
-
message = "Added to sprint with #{change_data.
|
|
221
|
-
summary_stats.added += change_data.
|
|
228
|
+
message = "Added to sprint with #{change_data.estimate || 'no'} points"
|
|
229
|
+
summary_stats.added += change_data.estimate
|
|
222
230
|
when :issue_stopped
|
|
223
|
-
|
|
224
|
-
message = "Completed with #{change_data.
|
|
231
|
+
estimate -= change_data.estimate
|
|
232
|
+
message = "Completed with #{change_data.estimate || 'no'} points"
|
|
225
233
|
issues_currently_in_sprint.delete change_data.issue.key
|
|
226
|
-
summary_stats.completed += change_data.
|
|
234
|
+
summary_stats.completed += change_data.estimate
|
|
227
235
|
when :leave_sprint
|
|
228
|
-
message = "Removed from sprint with #{change_data.
|
|
229
|
-
summary_stats.removed += change_data.
|
|
236
|
+
message = "Removed from sprint with #{change_data.estimate || 'no'} points"
|
|
237
|
+
summary_stats.removed += change_data.estimate
|
|
230
238
|
else
|
|
231
239
|
raise "Unexpected action: #{change_data.action}"
|
|
232
240
|
end
|
|
233
241
|
|
|
234
242
|
data_set << {
|
|
235
|
-
y:
|
|
243
|
+
y: estimate,
|
|
236
244
|
x: chart_format(change_data.time),
|
|
237
245
|
title: "#{change_data.issue.key} #{message}"
|
|
238
246
|
}
|
|
@@ -241,27 +249,27 @@ class SprintBurndown < ChartBase
|
|
|
241
249
|
unless start_data_written
|
|
242
250
|
# There was nothing that triggered us to write the sprint started block so do it now.
|
|
243
251
|
data_set << {
|
|
244
|
-
y:
|
|
252
|
+
y: estimate,
|
|
245
253
|
x: chart_format(sprint.start_time),
|
|
246
|
-
title: "Sprint started with #{
|
|
254
|
+
title: "Sprint started with #{estimate} points"
|
|
247
255
|
}
|
|
248
|
-
summary_stats.started =
|
|
256
|
+
summary_stats.started = estimate
|
|
249
257
|
end
|
|
250
258
|
|
|
251
259
|
if sprint.completed_time
|
|
252
260
|
data_set << {
|
|
253
|
-
y:
|
|
261
|
+
y: estimate,
|
|
254
262
|
x: chart_format(sprint.completed_time),
|
|
255
|
-
title: "Sprint ended with #{
|
|
263
|
+
title: "Sprint ended with #{estimate} points unfinished"
|
|
256
264
|
}
|
|
257
|
-
summary_stats.remaining =
|
|
265
|
+
summary_stats.remaining = estimate
|
|
258
266
|
end
|
|
259
267
|
|
|
260
268
|
unless sprint.completed_at?(time_range.end)
|
|
261
269
|
data_set << {
|
|
262
|
-
y:
|
|
270
|
+
y: estimate,
|
|
263
271
|
x: chart_format(time_range.end),
|
|
264
|
-
title: "Sprint still active. #{
|
|
272
|
+
title: "Sprint still active. #{estimate} points still in progress."
|
|
265
273
|
}
|
|
266
274
|
end
|
|
267
275
|
|
|
@@ -4,14 +4,14 @@ require 'jirametrics/value_equality'
|
|
|
4
4
|
|
|
5
5
|
class SprintIssueChangeData
|
|
6
6
|
include ValueEquality
|
|
7
|
-
attr_reader :time, :action, :value, :issue, :
|
|
7
|
+
attr_reader :time, :action, :value, :issue, :estimate
|
|
8
8
|
|
|
9
|
-
def initialize time:, action:, value:, issue:,
|
|
9
|
+
def initialize time:, action:, value:, issue:, estimate:
|
|
10
10
|
@time = time
|
|
11
11
|
@action = action
|
|
12
12
|
@value = value
|
|
13
13
|
@issue = issue
|
|
14
|
-
@
|
|
14
|
+
@estimate = estimate
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def inspect
|
data/lib/jirametrics/status.rb
CHANGED
|
@@ -3,30 +3,65 @@
|
|
|
3
3
|
require 'jirametrics/value_equality'
|
|
4
4
|
|
|
5
5
|
class Status
|
|
6
|
-
|
|
7
|
-
attr_reader :id, :category_name, :category_id, :project_id
|
|
6
|
+
attr_reader :id, :project_id, :category
|
|
8
7
|
attr_accessor :name
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
class Category
|
|
10
|
+
attr_reader :id, :name, :key
|
|
11
|
+
|
|
12
|
+
def initialize id:, name:, key:
|
|
13
|
+
@id = id
|
|
14
|
+
@name = name
|
|
15
|
+
@key = key
|
|
16
|
+
end
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
def to_s
|
|
19
|
+
"#{name.inspect}:#{id.inspect}"
|
|
20
|
+
end
|
|
18
21
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
def <=> other
|
|
23
|
+
id <=> other.id
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def == other
|
|
27
|
+
id == other.id
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def eql?(other) = id.eql?(other.id)
|
|
31
|
+
def hash = id.hash
|
|
32
|
+
|
|
33
|
+
def new? = (@key == 'new')
|
|
34
|
+
def indeterminate? = (@key == 'indeterminate')
|
|
35
|
+
def done? = (@key == 'done')
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.from_raw raw
|
|
39
|
+
raise 'raw cannot be nil' if raw.nil?
|
|
22
40
|
|
|
23
41
|
category_config = raw['statusCategory']
|
|
24
|
-
|
|
25
|
-
|
|
42
|
+
raise "statusCategory can't be nil in #{category_config.inspect}" if category_config.nil?
|
|
43
|
+
|
|
44
|
+
Status.new(
|
|
45
|
+
name: raw['name'],
|
|
46
|
+
id: raw['id'].to_i,
|
|
47
|
+
category_name: category_config['name'],
|
|
48
|
+
category_id: category_config['id'].to_i,
|
|
49
|
+
category_key: category_config['key'],
|
|
50
|
+
project_id: raw['scope']&.[]('project')&.[]('id'),
|
|
51
|
+
artificial: false
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def initialize name:, id:, category_name:, category_id:, category_key:, project_id: nil, artificial: true
|
|
56
|
+
# These checks are needed because nils used to be possible and now they aren't.
|
|
57
|
+
raise 'id cannot be nil' if id.nil?
|
|
58
|
+
raise 'category_id cannot be nil' if category_id.nil?
|
|
26
59
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
@
|
|
60
|
+
@name = name
|
|
61
|
+
@id = id
|
|
62
|
+
@category = Category.new id: category_id, name: category_name, key: category_key
|
|
63
|
+
@project_id = project_id
|
|
64
|
+
@artificial = artificial
|
|
30
65
|
end
|
|
31
66
|
|
|
32
67
|
def project_scoped?
|
|
@@ -38,8 +73,38 @@ class Status
|
|
|
38
73
|
end
|
|
39
74
|
|
|
40
75
|
def to_s
|
|
41
|
-
"
|
|
42
|
-
|
|
76
|
+
"#{name.inspect}:#{id.inspect}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def artificial?
|
|
80
|
+
@artificial
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def == other
|
|
84
|
+
return false unless other.is_a? Status
|
|
85
|
+
|
|
86
|
+
@id == other.id && @name == other.name && @category.id == other.category.id && @category.name == other.category.name
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def eql?(other)
|
|
90
|
+
self == other
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def <=> other
|
|
94
|
+
result = @name.casecmp(other.name)
|
|
95
|
+
result = @id <=> other.id if result.zero?
|
|
96
|
+
result
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def inspect
|
|
100
|
+
result = []
|
|
101
|
+
result << "Status(name: #{@name.inspect}"
|
|
102
|
+
result << "id: #{@id.inspect}"
|
|
103
|
+
result << "project_id: #{@project_id}" if @project_id
|
|
104
|
+
category = self.category
|
|
105
|
+
result << "category: {name:#{category.name.inspect}, id: #{category.id.inspect}, key: #{category.key.inspect}}"
|
|
106
|
+
result << 'artificial' if artificial?
|
|
107
|
+
result.join(', ') << ')'
|
|
43
108
|
end
|
|
44
109
|
|
|
45
110
|
def value_equality_ignored_variables
|