jirametrics 2.6 → 2.7.1
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/lib/jirametrics/aggregate_config.rb +6 -1
- data/lib/jirametrics/aging_work_bar_chart.rb +6 -6
- data/lib/jirametrics/aging_work_in_progress_chart.rb +1 -1
- data/lib/jirametrics/aging_work_table.rb +4 -5
- data/lib/jirametrics/blocked_stalled_change.rb +1 -1
- data/lib/jirametrics/board.rb +14 -12
- data/lib/jirametrics/chart_base.rb +16 -10
- data/lib/jirametrics/cycletime_config.rb +26 -7
- data/lib/jirametrics/cycletime_histogram.rb +1 -1
- data/lib/jirametrics/cycletime_scatterplot.rb +3 -6
- data/lib/jirametrics/daily_wip_by_age_chart.rb +2 -4
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +2 -2
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +0 -4
- data/lib/jirametrics/daily_wip_chart.rb +7 -9
- data/lib/jirametrics/data_quality_report.rb +166 -7
- data/lib/jirametrics/dependency_chart.rb +3 -4
- data/lib/jirametrics/discard_changes_before.rb +1 -1
- data/lib/jirametrics/downloader.rb +14 -13
- data/lib/jirametrics/estimate_accuracy_chart.rb +1 -2
- data/lib/jirametrics/examples/aggregated_project.rb +1 -3
- data/lib/jirametrics/examples/standard_project.rb +10 -9
- data/lib/jirametrics/expedited_chart.rb +1 -2
- data/lib/jirametrics/exporter.rb +25 -0
- data/lib/jirametrics/file_system.rb +1 -1
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +111 -0
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +85 -0
- data/lib/jirametrics/html/index.css +10 -3
- data/lib/jirametrics/html_report_config.rb +2 -1
- data/lib/jirametrics/issue.rb +63 -11
- data/lib/jirametrics/jira_gateway.rb +1 -1
- data/lib/jirametrics/project_config.rb +27 -19
- data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
- data/lib/jirametrics/sprint_burndown.rb +2 -2
- data/lib/jirametrics/status.rb +1 -1
- data/lib/jirametrics/status_collection.rb +4 -0
- data/lib/jirametrics/throughput_chart.rb +1 -1
- data/lib/jirametrics.rb +15 -5
- metadata +8 -7
- data/lib/jirametrics/html/data_quality_report.erb +0 -126
@@ -41,8 +41,8 @@ class Downloader
|
|
41
41
|
remove_old_files
|
42
42
|
download_statuses
|
43
43
|
find_board_ids.each do |id|
|
44
|
-
download_board_configuration board_id: id
|
45
|
-
download_issues
|
44
|
+
board = download_board_configuration board_id: id
|
45
|
+
download_issues board: board
|
46
46
|
end
|
47
47
|
|
48
48
|
save_metadata
|
@@ -64,19 +64,19 @@ class Downloader
|
|
64
64
|
ids
|
65
65
|
end
|
66
66
|
|
67
|
-
def download_issues
|
68
|
-
log " Downloading primary issues for board #{
|
67
|
+
def download_issues board:
|
68
|
+
log " Downloading primary issues for board #{board.id}", both: true
|
69
69
|
path = "#{@target_path}#{@download_config.project_config.file_prefix}_issues/"
|
70
70
|
unless Dir.exist?(path)
|
71
71
|
log " Creating path #{path}"
|
72
72
|
Dir.mkdir(path)
|
73
73
|
end
|
74
74
|
|
75
|
-
filter_id = @board_id_to_filter_id[
|
75
|
+
filter_id = @board_id_to_filter_id[board.id]
|
76
76
|
jql = make_jql(filter_id: filter_id)
|
77
|
-
jira_search_by_jql(jql: jql, initial_query: true,
|
77
|
+
jira_search_by_jql(jql: jql, initial_query: true, board: board, path: path)
|
78
78
|
|
79
|
-
log " Downloading linked issues for board #{
|
79
|
+
log " Downloading linked issues for board #{board.id}", both: true
|
80
80
|
loop do
|
81
81
|
@issue_keys_pending_download.reject! { |key| @issue_keys_downloaded_in_current_run.include? key }
|
82
82
|
break if @issue_keys_pending_download.empty?
|
@@ -84,11 +84,11 @@ class Downloader
|
|
84
84
|
keys_to_request = @issue_keys_pending_download[0..99]
|
85
85
|
@issue_keys_pending_download.reject! { |key| keys_to_request.include? key }
|
86
86
|
jql = "key in (#{keys_to_request.join(', ')})"
|
87
|
-
jira_search_by_jql(jql: jql, initial_query: false,
|
87
|
+
jira_search_by_jql(jql: jql, initial_query: false, board: board, path: path)
|
88
88
|
end
|
89
89
|
end
|
90
90
|
|
91
|
-
def jira_search_by_jql jql:, initial_query:,
|
91
|
+
def jira_search_by_jql jql:, initial_query:, board:, path:
|
92
92
|
intercept_jql = @download_config.project_config.settings['intercept_jql']
|
93
93
|
jql = intercept_jql.call jql if intercept_jql
|
94
94
|
|
@@ -108,8 +108,8 @@ class Downloader
|
|
108
108
|
issue_json['exporter'] = {
|
109
109
|
'in_initial_query' => initial_query
|
110
110
|
}
|
111
|
-
identify_other_issues_to_be_downloaded issue_json
|
112
|
-
file = "#{issue_json['key']}-#{
|
111
|
+
identify_other_issues_to_be_downloaded raw_issue: issue_json, board: board
|
112
|
+
file = "#{issue_json['key']}-#{board.id}.json"
|
113
113
|
|
114
114
|
@file_system.save_json(json: issue_json, filename: File.join(path, file))
|
115
115
|
end
|
@@ -124,8 +124,8 @@ class Downloader
|
|
124
124
|
end
|
125
125
|
end
|
126
126
|
|
127
|
-
def identify_other_issues_to_be_downloaded raw_issue
|
128
|
-
issue = Issue.new raw: raw_issue, board:
|
127
|
+
def identify_other_issues_to_be_downloaded raw_issue:, board:
|
128
|
+
issue = Issue.new raw: raw_issue, board: board
|
129
129
|
@issue_keys_downloaded_in_current_run << issue.key
|
130
130
|
|
131
131
|
# Parent
|
@@ -171,6 +171,7 @@ class Downloader
|
|
171
171
|
@board_id_to_filter_id[board_id] = json['filter']['id'].to_i
|
172
172
|
|
173
173
|
download_sprints board_id: board_id if json['type'] == 'scrum'
|
174
|
+
Board.new raw: json
|
174
175
|
end
|
175
176
|
|
176
177
|
def download_sprints board_id:
|
@@ -83,8 +83,7 @@ class EstimateAccuracyChart < ChartBase
|
|
83
83
|
|
84
84
|
issues.each do |issue|
|
85
85
|
cycletime = issue.board.cycletime
|
86
|
-
start_time = cycletime.
|
87
|
-
stop_time = cycletime.stopped_time(issue)
|
86
|
+
start_time, stop_time = cycletime.started_stopped_times(issue)
|
88
87
|
|
89
88
|
next unless start_time
|
90
89
|
|
@@ -3,8 +3,6 @@
|
|
3
3
|
# This file is really intended to give you ideas about how you might configure your own reports, not
|
4
4
|
# as a complete setup that will work in every case.
|
5
5
|
#
|
6
|
-
# See https://github.com/mikebowler/jirametrics/wiki/Examples-folder for more details
|
7
|
-
#
|
8
6
|
# The point of an AGGREGATED report is that we're now looking at a higher level. We might use this in a
|
9
7
|
# S2 meeting (Scrum of Scrums) to talk about the things that are happening across teams, not within a
|
10
8
|
# single team. For that reason, we look at slightly different things that we would on a single team board.
|
@@ -33,7 +31,7 @@ class Exporter
|
|
33
31
|
html '<h1>Boards included in this report</h1><ul>', type: :header
|
34
32
|
board_lines = []
|
35
33
|
included_projects.each do |project|
|
36
|
-
project.all_boards.
|
34
|
+
project.all_boards.each_value do |board|
|
37
35
|
board_lines << "<a href='#{project.file_prefix}.html'>#{board.name}</a> from project #{project.name}"
|
38
36
|
end
|
39
37
|
end
|
@@ -2,12 +2,11 @@
|
|
2
2
|
|
3
3
|
# This file is really intended to give you ideas about how you might configure your own reports, not
|
4
4
|
# as a complete setup that will work in every case.
|
5
|
-
#
|
6
|
-
# See https://github.com/mikebowler/jirametrics/wiki/Examples-folder for more
|
7
5
|
class Exporter
|
8
6
|
def standard_project name:, file_prefix:, ignore_issues: nil, starting_status: nil, boards: {},
|
9
7
|
default_board: nil, anonymize: false, settings: {}, status_category_mappings: {},
|
10
|
-
rolling_date_count: 90, no_earlier_than: nil
|
8
|
+
rolling_date_count: 90, no_earlier_than: nil, ignore_types: %w[Sub-task Subtask Epic],
|
9
|
+
show_experimental_charts: false
|
11
10
|
|
12
11
|
project name: name do
|
13
12
|
puts name
|
@@ -38,11 +37,14 @@ class Exporter
|
|
38
37
|
end
|
39
38
|
end
|
40
39
|
|
40
|
+
issues.reject! do |issue|
|
41
|
+
ignore_types.include? issue.type
|
42
|
+
end
|
43
|
+
|
44
|
+
discard_changes_before status_becomes: (starting_status || :backlog) # rubocop:disable Style/RedundantParentheses
|
45
|
+
|
41
46
|
file do
|
42
47
|
file_suffix '.html'
|
43
|
-
issues.reject! do |issue|
|
44
|
-
%w[Sub-task Epic].include? issue.type
|
45
|
-
end
|
46
48
|
|
47
49
|
issues.reject! { |issue| ignore_issues.include? issue.key } if ignore_issues
|
48
50
|
|
@@ -52,12 +54,10 @@ class Exporter
|
|
52
54
|
html "<H1>#{name}</H1>", type: :header
|
53
55
|
boards.each_key do |id|
|
54
56
|
board = find_board id
|
55
|
-
html "<div><a href='#{board.url}'>#{id} #{board.name}</a
|
57
|
+
html "<div><a href='#{board.url}'>#{id} #{board.name}</a> (#{board.board_type})</div>",
|
56
58
|
type: :header
|
57
59
|
end
|
58
60
|
|
59
|
-
discard_changes_before status_becomes: (starting_status || :backlog) # rubocop:disable Style/RedundantParentheses
|
60
|
-
|
61
61
|
cycletime_scatterplot do
|
62
62
|
show_trend_lines
|
63
63
|
end
|
@@ -84,6 +84,7 @@ class Exporter
|
|
84
84
|
daily_wip_by_age_chart
|
85
85
|
daily_wip_by_blocked_stalled_chart
|
86
86
|
daily_wip_by_parent_chart
|
87
|
+
flow_efficiency_scatterplot if show_experimental_charts
|
87
88
|
expedited_chart
|
88
89
|
sprint_burndown
|
89
90
|
estimate_accuracy_chart
|
@@ -109,8 +109,7 @@ class ExpeditedChart < ChartBase
|
|
109
109
|
|
110
110
|
def make_expedite_lines_data_set issue:, expedite_data:
|
111
111
|
cycletime = issue.board.cycletime
|
112
|
-
started_time = cycletime.
|
113
|
-
stopped_time = cycletime.stopped_time(issue)
|
112
|
+
started_time, stopped_time = cycletime.started_stopped_times(issue)
|
114
113
|
|
115
114
|
expedite_data << [started_time, :issue_started] if started_time
|
116
115
|
expedite_data << [stopped_time, :issue_stopped] if stopped_time
|
data/lib/jirametrics/exporter.rb
CHANGED
@@ -72,6 +72,31 @@ class Exporter
|
|
72
72
|
puts "Full output from downloader in #{file_system.logfile_name}"
|
73
73
|
end
|
74
74
|
|
75
|
+
def info keys, name_filter:
|
76
|
+
selected = []
|
77
|
+
each_project_config(name_filter: name_filter) do |project|
|
78
|
+
project.evaluate_next_level
|
79
|
+
# next if project.aggregated_project?
|
80
|
+
|
81
|
+
project.run load_only: true
|
82
|
+
project.board_configs.each do |board_config|
|
83
|
+
board_config.run
|
84
|
+
end
|
85
|
+
project.issues.each do |issue|
|
86
|
+
selected << [project, issue] if keys.include? issue.key
|
87
|
+
end
|
88
|
+
rescue => e # rubocop:disable Style/RescueStandardError
|
89
|
+
# This happens when we're attempting to load an aggregated project because it hasn't been
|
90
|
+
# properly initialized. Since we don't care about aggregated projects, we just ignore it.
|
91
|
+
raise unless e.message.start_with? 'This is an aggregated project and issues should have been included'
|
92
|
+
end
|
93
|
+
|
94
|
+
selected.each do |project, issue|
|
95
|
+
puts "\nProject #{project.name}"
|
96
|
+
puts issue.dump
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
75
100
|
def each_project_config name_filter:
|
76
101
|
@project_configs.each do |project|
|
77
102
|
yield project if project.name.nil? || File.fnmatch(name_filter, project.name)
|
@@ -29,7 +29,7 @@ class FileSystem
|
|
29
29
|
|
30
30
|
def log message, also_write_to_stderr: false
|
31
31
|
logfile.puts message
|
32
|
-
$stderr.puts message if also_write_to_stderr
|
32
|
+
$stderr.puts message if also_write_to_stderr # rubocop:disable Style/StderrPuts
|
33
33
|
end
|
34
34
|
|
35
35
|
# In some Jira instances, a sizeable portion of the JSON is made up of empty fields. I've seen
|
@@ -0,0 +1,111 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'jirametrics/groupable_issue_chart'
|
4
|
+
|
5
|
+
class FlowEfficiencyScatterplot < ChartBase
|
6
|
+
include GroupableIssueChart
|
7
|
+
|
8
|
+
attr_accessor :possible_statuses
|
9
|
+
|
10
|
+
def initialize block
|
11
|
+
super()
|
12
|
+
|
13
|
+
header_text 'Flow Efficiency'
|
14
|
+
description_text <<-HTML
|
15
|
+
<div class="p">
|
16
|
+
This chart shows the active time against the the total time spent on a ticket.
|
17
|
+
<a href="https://improvingflow.com/2024/07/06/flow-efficiency.html">Flow efficiency</a> is the ratio
|
18
|
+
between these two numbers.
|
19
|
+
</div>
|
20
|
+
<div class="p">
|
21
|
+
<math>
|
22
|
+
<mn>Flow efficiency (%)</mn>
|
23
|
+
<mo>=</mo>
|
24
|
+
<mfrac>
|
25
|
+
<mrow><mn>Time adding value</mn></mrow>
|
26
|
+
<mrow><mn>Total time</mn></mrow>
|
27
|
+
</mfrac>
|
28
|
+
</math>
|
29
|
+
</div>
|
30
|
+
<div style="background: yellow">Note that for this calculation to be accurate, we must be moving items into a
|
31
|
+
blocked or stalled state the moment we stop working on it, and most teams don't do that.
|
32
|
+
So be aware that your team may have to change their behaviours if you want this chart to be useful.
|
33
|
+
</div>
|
34
|
+
HTML
|
35
|
+
|
36
|
+
init_configuration_block block do
|
37
|
+
grouping_rules do |issue, rule|
|
38
|
+
active_time, total_time = issue.flow_efficiency_numbers end_time: time_range.end
|
39
|
+
flow_efficiency = active_time * 100.0 / total_time
|
40
|
+
|
41
|
+
if flow_efficiency > 99.0
|
42
|
+
rule.label = '~100%'
|
43
|
+
rule.color = 'green'
|
44
|
+
elsif flow_efficiency < 30.0
|
45
|
+
rule.label = '< 30%'
|
46
|
+
rule.color = 'orange'
|
47
|
+
else
|
48
|
+
rule.label = 'The rest'
|
49
|
+
rule.color = 'black'
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
@percentage_lines = []
|
55
|
+
@highest_cycletime = 0
|
56
|
+
end
|
57
|
+
|
58
|
+
def run
|
59
|
+
data_sets = group_issues(completed_issues_in_range include_unstarted: false).filter_map do |rules, issues|
|
60
|
+
create_dataset(issues: issues, label: rules.label, color: rules.color)
|
61
|
+
end
|
62
|
+
|
63
|
+
return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
|
64
|
+
|
65
|
+
wrap_and_render(binding, __FILE__)
|
66
|
+
end
|
67
|
+
|
68
|
+
def to_days seconds
|
69
|
+
seconds / 60 / 60 / 24
|
70
|
+
end
|
71
|
+
|
72
|
+
def create_dataset issues:, label:, color:
|
73
|
+
return nil if issues.empty?
|
74
|
+
|
75
|
+
data = issues.filter_map do |issue|
|
76
|
+
active_time, total_time = issue.flow_efficiency_numbers(
|
77
|
+
end_time: time_range.end, settings: settings
|
78
|
+
)
|
79
|
+
|
80
|
+
active_days = to_days(active_time)
|
81
|
+
total_days = to_days(total_time)
|
82
|
+
flow_efficiency = active_time * 100.0 / total_time
|
83
|
+
|
84
|
+
if flow_efficiency.nan?
|
85
|
+
# If this happens then something is probably misconfigured. We've seen it in production though
|
86
|
+
# so we have to handle it.
|
87
|
+
file_system.log(
|
88
|
+
"Issue(#{issue.key}) flow_efficiency: NaN, active_time: #{active_time}, total_time: #{total_time}"
|
89
|
+
)
|
90
|
+
flow_efficiency = 0.0
|
91
|
+
end
|
92
|
+
|
93
|
+
{
|
94
|
+
y: active_days,
|
95
|
+
x: total_days,
|
96
|
+
title: [
|
97
|
+
"#{issue.key} : #{issue.summary}, flow efficiency: #{flow_efficiency.to_i}%," \
|
98
|
+
" total: #{total_days.round(1)} days," \
|
99
|
+
" active: #{active_days.round(1)} days"
|
100
|
+
]
|
101
|
+
}
|
102
|
+
end
|
103
|
+
{
|
104
|
+
label: label,
|
105
|
+
data: data,
|
106
|
+
fill: false,
|
107
|
+
showLine: false,
|
108
|
+
backgroundColor: color
|
109
|
+
}
|
110
|
+
end
|
111
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
<div class="chart">
|
2
|
+
<canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
|
3
|
+
</div>
|
4
|
+
<script>
|
5
|
+
new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
6
|
+
type: 'scatter',
|
7
|
+
data: {
|
8
|
+
datasets: <%= JSON.generate(data_sets) %>
|
9
|
+
},
|
10
|
+
options: {
|
11
|
+
title: {
|
12
|
+
display: true,
|
13
|
+
text: "Cycletime Scatterplot"
|
14
|
+
},
|
15
|
+
responsive: <%= canvas_responsive? %>, // If responsive is true then it fills the screen
|
16
|
+
scales: {
|
17
|
+
x: {
|
18
|
+
scaleLabel: {
|
19
|
+
display: true,
|
20
|
+
labelString: 'Days'
|
21
|
+
},
|
22
|
+
title: {
|
23
|
+
display: true,
|
24
|
+
text: 'Total time (days)'
|
25
|
+
},
|
26
|
+
grid: {
|
27
|
+
color: <%= CssVariable['--grid-line-color'].to_json %>
|
28
|
+
},
|
29
|
+
|
30
|
+
},
|
31
|
+
y: {
|
32
|
+
scaleLabel: {
|
33
|
+
display: true,
|
34
|
+
labelString: 'Percentage',
|
35
|
+
min: 0,
|
36
|
+
max: <%= @highest_cycletime %>
|
37
|
+
},
|
38
|
+
title: {
|
39
|
+
display: true,
|
40
|
+
text: 'Time adding value (days)'
|
41
|
+
},
|
42
|
+
grid: {
|
43
|
+
color: <%= CssVariable['--grid-line-color'].to_json %>
|
44
|
+
},
|
45
|
+
}
|
46
|
+
},
|
47
|
+
plugins: {
|
48
|
+
tooltip: {
|
49
|
+
callbacks: {
|
50
|
+
label: function(context) {
|
51
|
+
return context.dataset.data[context.dataIndex].title
|
52
|
+
}
|
53
|
+
}
|
54
|
+
},
|
55
|
+
autocolors: false,
|
56
|
+
legend: {
|
57
|
+
onClick: (evt, legendItem, legend) => {
|
58
|
+
// Find the datasetMeta that corresponds to the item clicked
|
59
|
+
var i = 0
|
60
|
+
while(legendItem.text != legend.chart.getDatasetMeta(i).label) {
|
61
|
+
i++;
|
62
|
+
}
|
63
|
+
nextVisibility = !!legend.chart.getDatasetMeta(i).hidden;
|
64
|
+
|
65
|
+
// Hide/show the 85% line for that dataset
|
66
|
+
legend.chart.options.plugins.annotation.annotations["line"+(i/2)].display = nextVisibility;
|
67
|
+
|
68
|
+
// Hide/show the trendline for this dataset, if they were enabled. The trendline is always
|
69
|
+
// there but not always visible.
|
70
|
+
legend.chart.setDatasetVisibility(i+1, <%= !!@show_trend_lines %> && nextVisibility);
|
71
|
+
|
72
|
+
// Still run the default behaviour
|
73
|
+
Chart.defaults.plugins.legend.onClick(evt, legendItem, legend);
|
74
|
+
},
|
75
|
+
labels: {
|
76
|
+
filter: function(item, chart) {
|
77
|
+
// Logic to remove a particular legend item goes here
|
78
|
+
return !item.text.includes('Trendline');
|
79
|
+
}
|
80
|
+
}
|
81
|
+
}
|
82
|
+
}
|
83
|
+
}
|
84
|
+
});
|
85
|
+
</script>
|
@@ -99,9 +99,6 @@ table.standard {
|
|
99
99
|
background-color: #eee;
|
100
100
|
}
|
101
101
|
}
|
102
|
-
.quality_note_bullet {
|
103
|
-
color: red;
|
104
|
-
}
|
105
102
|
|
106
103
|
.chart {
|
107
104
|
background-color: white;
|
@@ -119,6 +116,16 @@ div.color_block {
|
|
119
116
|
border: 1px solid black;
|
120
117
|
}
|
121
118
|
|
119
|
+
ul.quality_report {
|
120
|
+
list-style-type: '⮕';
|
121
|
+
::marker {
|
122
|
+
color: red;
|
123
|
+
}
|
124
|
+
li {
|
125
|
+
padding: 0.2em;
|
126
|
+
}
|
127
|
+
}
|
128
|
+
|
122
129
|
@media screen and (prefers-color-scheme: dark) {
|
123
130
|
:root {
|
124
131
|
--non-working-days-color: #2f2f2f;
|
@@ -33,6 +33,7 @@ class HtmlReportConfig
|
|
33
33
|
define_chart name: 'cycletime_histogram', classname: 'CycletimeHistogram'
|
34
34
|
define_chart name: 'estimate_accuracy_chart', classname: 'EstimateAccuracyChart'
|
35
35
|
define_chart name: 'hierarchy_table', classname: 'HierarchyTable'
|
36
|
+
define_chart name: 'flow_efficiency_scatterplot', classname: 'FlowEfficiencyScatterplot'
|
36
37
|
|
37
38
|
define_chart name: 'daily_wip_by_type', classname: 'DailyWipChart',
|
38
39
|
deprecated_warning: 'This is the same as daily_wip_chart. Please use that one', deprecated_date: '2024-05-23'
|
@@ -145,7 +146,7 @@ class HtmlReportConfig
|
|
145
146
|
|
146
147
|
@original_issue_times = {}
|
147
148
|
issues_cutoff_times.each do |issue, cutoff_time|
|
148
|
-
started = issue.board.cycletime.
|
149
|
+
started = issue.board.cycletime.started_stopped_times(issue).first
|
149
150
|
if started && started <= cutoff_time
|
150
151
|
# We only need to log this if data was discarded
|
151
152
|
@original_issue_times[issue] = { cutoff_time: cutoff_time, started_time: started }
|
data/lib/jirametrics/issue.rb
CHANGED
@@ -13,6 +13,7 @@ class Issue
|
|
13
13
|
@changes = []
|
14
14
|
@board = board
|
15
15
|
|
16
|
+
raise "No board for issue #{key}" if board.nil?
|
16
17
|
return unless @raw['changelog']
|
17
18
|
|
18
19
|
load_history_into_changes
|
@@ -140,7 +141,7 @@ class Issue
|
|
140
141
|
@board.project_config.file_system.log(
|
141
142
|
"Warning: Status name #{name.inspect} for issue #{key} not found in" \
|
142
143
|
" #{board.possible_statuses.collect(&:name).inspect}" \
|
143
|
-
"\n See
|
144
|
+
"\n See https://jirametrics.org/faq/#q1\n",
|
144
145
|
also_write_to_stderr: true
|
145
146
|
)
|
146
147
|
status = Status.new(name: name, category_name: 'In Progress')
|
@@ -259,7 +260,7 @@ class Issue
|
|
259
260
|
|
260
261
|
blocked_link_texts = settings['blocked_link_text']
|
261
262
|
stalled_threshold = settings['stalled_threshold_days']
|
262
|
-
flagged_means_blocked = !!settings['flagged_means_blocked']
|
263
|
+
flagged_means_blocked = !!settings['flagged_means_blocked'] # rubocop:disable Style/DoubleNegation
|
263
264
|
|
264
265
|
blocking_issue_keys = []
|
265
266
|
|
@@ -373,12 +374,11 @@ class Issue
|
|
373
374
|
|
374
375
|
# return [number of active seconds, total seconds] that this issue had up to the end_time.
|
375
376
|
# It does not include data before issue start or after issue end
|
376
|
-
def flow_efficiency_numbers end_time:, settings:
|
377
|
-
issue_start = @board.cycletime.
|
377
|
+
def flow_efficiency_numbers end_time:, settings: @board.project_config.settings
|
378
|
+
issue_start, issue_stop = @board.cycletime.started_stopped_times(self)
|
378
379
|
return [0.0, 0.0] if !issue_start || issue_start > end_time
|
379
380
|
|
380
381
|
value_add_time = 0.0
|
381
|
-
issue_stop = @board.cycletime.stopped_time(self)
|
382
382
|
end_time = issue_stop if issue_stop && issue_stop < end_time
|
383
383
|
|
384
384
|
active_start = nil
|
@@ -542,6 +542,20 @@ class Issue
|
|
542
542
|
comparison
|
543
543
|
end
|
544
544
|
|
545
|
+
def discard_changes_before cutoff_time
|
546
|
+
rejected_any = false
|
547
|
+
@changes.reject! do |change|
|
548
|
+
reject = change.status? && change.time <= cutoff_time && change.artificial? == false
|
549
|
+
if reject
|
550
|
+
(@discarded_changes ||= []) << change
|
551
|
+
rejected_any = true
|
552
|
+
end
|
553
|
+
reject
|
554
|
+
end
|
555
|
+
|
556
|
+
(@discarded_change_times ||= []) << cutoff_time if rejected_any
|
557
|
+
end
|
558
|
+
|
545
559
|
def dump
|
546
560
|
result = +''
|
547
561
|
result << "#{key} (#{type}): #{compact_text summary, 200}\n"
|
@@ -549,21 +563,59 @@ class Issue
|
|
549
563
|
assignee = raw['fields']['assignee']
|
550
564
|
result << " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n" unless assignee.nil?
|
551
565
|
|
552
|
-
raw['fields']['issuelinks']
|
566
|
+
raw['fields']['issuelinks']&.each do |link|
|
553
567
|
result << " [link] #{link['type']['outward']} #{link['outwardIssue']['key']}\n" if link['outwardIssue']
|
554
568
|
result << " [link] #{link['type']['inward']} #{link['inwardIssue']['key']}\n" if link['inwardIssue']
|
555
569
|
end
|
556
|
-
|
570
|
+
history = [] # time, type, detail
|
571
|
+
|
572
|
+
started_at, stopped_at = board.cycletime.started_stopped_times(self)
|
573
|
+
history << [started_at, nil, '↓↓↓↓ Started here ↓↓↓↓', true] if started_at
|
574
|
+
history << [stopped_at, nil, '↑↑↑↑ Finished here ↑↑↑↑', true] if stopped_at
|
575
|
+
|
576
|
+
@discarded_change_times&.each do |time|
|
577
|
+
history << [time, nil, '↑↑↑↑ Changes discarded ↑↑↑↑', true]
|
578
|
+
end
|
579
|
+
|
580
|
+
(changes + (@discarded_changes || [])).each do |change|
|
557
581
|
value = change.value
|
558
582
|
old_value = change.old_value
|
559
583
|
|
560
|
-
message =
|
584
|
+
message = +''
|
561
585
|
message << "#{compact_text(old_value).inspect} -> " unless old_value.nil? || old_value.empty?
|
562
586
|
message << compact_text(value).inspect
|
563
|
-
|
564
|
-
|
565
|
-
|
587
|
+
if change.artificial?
|
588
|
+
message << ' (Artificial entry)' if change.artificial?
|
589
|
+
else
|
590
|
+
message << " (Author: #{change.author})"
|
591
|
+
end
|
592
|
+
history << [change.time, change.field, message, change.artificial?]
|
593
|
+
end
|
594
|
+
|
595
|
+
result << " History:\n"
|
596
|
+
type_width = history.collect { |_time, type, _detail, _artificial| type&.length || 0 }.max
|
597
|
+
history.sort! do |a, b|
|
598
|
+
if a[0] == b[0]
|
599
|
+
if a[1].nil?
|
600
|
+
1
|
601
|
+
elsif b[1].nil?
|
602
|
+
-1
|
603
|
+
else
|
604
|
+
a[1] <=> b[1]
|
605
|
+
end
|
606
|
+
else
|
607
|
+
a[0] <=> b[0]
|
608
|
+
end
|
566
609
|
end
|
610
|
+
history.each do |time, type, detail, _artificial|
|
611
|
+
if type.nil?
|
612
|
+
type = '-' * type_width
|
613
|
+
else
|
614
|
+
type = (' ' * (type_width - type.length)) << type
|
615
|
+
end
|
616
|
+
result << " #{time.strftime '%Y-%m-%d %H:%M:%S %z'} [#{type}] #{detail}\n"
|
617
|
+
end
|
618
|
+
|
567
619
|
result
|
568
620
|
end
|
569
621
|
|
@@ -16,7 +16,7 @@ class JiraGateway
|
|
16
16
|
result = call_command command
|
17
17
|
JSON.parse result
|
18
18
|
rescue => e # rubocop:disable Style/RescueStandardError
|
19
|
-
|
19
|
+
raise "Error #{e.message.inspect} when parsing result: #{result.inspect}"
|
20
20
|
end
|
21
21
|
|
22
22
|
def call_command command
|