jirametrics 2.6 → 2.7
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 +5 -7
- 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 +45 -6
- 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 -1
- data/lib/jirametrics/examples/standard_project.rb +10 -7
- 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 +113 -0
- data/lib/jirametrics/html/data_quality_report.erb +12 -0
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +85 -0
- data/lib/jirametrics/html_report_config.rb +2 -1
- data/lib/jirametrics/issue.rb +62 -10
- 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 +5 -3
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,113 @@
|
|
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
|
+
<mo>x</mo>
|
29
|
+
<mn>100%</mn>
|
30
|
+
</math>
|
31
|
+
</div>
|
32
|
+
<div style="background: yellow">Note that for this calculation to be accurate, we must be moving items into a
|
33
|
+
blocked or stalled state the moment we stop working on it, and most teams don't do that.
|
34
|
+
So be aware that your team may have to change their behaviours if you want this chart to be useful.
|
35
|
+
</div>
|
36
|
+
HTML
|
37
|
+
|
38
|
+
init_configuration_block block do
|
39
|
+
grouping_rules do |issue, rule|
|
40
|
+
active_time, total_time = issue.flow_efficiency_numbers end_time: time_range.end
|
41
|
+
flow_efficiency = active_time * 100.0 / total_time
|
42
|
+
|
43
|
+
if flow_efficiency > 99.0
|
44
|
+
rule.label = '~100%'
|
45
|
+
rule.color = 'green'
|
46
|
+
elsif flow_efficiency < 30.0
|
47
|
+
rule.label = '< 30%'
|
48
|
+
rule.color = 'orange'
|
49
|
+
else
|
50
|
+
rule.label = 'The rest'
|
51
|
+
rule.color = 'black'
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
@percentage_lines = []
|
57
|
+
@highest_cycletime = 0
|
58
|
+
end
|
59
|
+
|
60
|
+
def run
|
61
|
+
data_sets = group_issues(completed_issues_in_range include_unstarted: false).filter_map do |rules, issues|
|
62
|
+
create_dataset(issues: issues, label: rules.label, color: rules.color)
|
63
|
+
end
|
64
|
+
|
65
|
+
return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
|
66
|
+
|
67
|
+
wrap_and_render(binding, __FILE__)
|
68
|
+
end
|
69
|
+
|
70
|
+
def to_days seconds
|
71
|
+
seconds / 60 / 60 / 24
|
72
|
+
end
|
73
|
+
|
74
|
+
def create_dataset issues:, label:, color:
|
75
|
+
return nil if issues.empty?
|
76
|
+
|
77
|
+
data = issues.filter_map do |issue|
|
78
|
+
active_time, total_time = issue.flow_efficiency_numbers(
|
79
|
+
end_time: time_range.end, settings: settings
|
80
|
+
)
|
81
|
+
|
82
|
+
active_days = to_days(active_time)
|
83
|
+
total_days = to_days(total_time)
|
84
|
+
flow_efficiency = active_time * 100.0 / total_time
|
85
|
+
|
86
|
+
if flow_efficiency.nan?
|
87
|
+
# If this happens then something is probably misconfigured. We've seen it in production though
|
88
|
+
# so we have to handle it.
|
89
|
+
file_system.log(
|
90
|
+
"Issue(#{issue.key}) flow_efficiency: NaN, active_time: #{active_time}, total_time: #{total_time}"
|
91
|
+
)
|
92
|
+
flow_efficiency = 0.0
|
93
|
+
end
|
94
|
+
|
95
|
+
{
|
96
|
+
y: active_days,
|
97
|
+
x: total_days,
|
98
|
+
title: [
|
99
|
+
"#{issue.key} : #{issue.summary}, flow efficiency: #{flow_efficiency.to_i}%," \
|
100
|
+
" total: #{total_days.round(1)} days," \
|
101
|
+
" active: #{active_days.round(1)} days"
|
102
|
+
]
|
103
|
+
}
|
104
|
+
end
|
105
|
+
{
|
106
|
+
label: label,
|
107
|
+
data: data,
|
108
|
+
fill: false,
|
109
|
+
showLine: false,
|
110
|
+
backgroundColor: color
|
111
|
+
}
|
112
|
+
end
|
113
|
+
end
|
@@ -113,6 +113,18 @@
|
|
113
113
|
end
|
114
114
|
%>
|
115
115
|
|
116
|
+
<%
|
117
|
+
problems = problems_for :incomplete_subtasks_when_issue_done
|
118
|
+
unless problems.empty?
|
119
|
+
%>
|
120
|
+
<p>
|
121
|
+
<span class="quality_note_bullet">⮕</span> <%= label_issues problems.size %> issues were marked as done while subtasks were still not done.
|
122
|
+
<%= collapsible_issues_panel problems %>
|
123
|
+
</p>
|
124
|
+
<%
|
125
|
+
end
|
126
|
+
%>
|
127
|
+
|
116
128
|
<%
|
117
129
|
problems = problems_for :issue_on_multiple_boards
|
118
130
|
unless problems.empty?
|
@@ -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>
|
@@ -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
|
@@ -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
|
@@ -24,22 +24,30 @@ class ProjectConfig
|
|
24
24
|
@all_boards = {}
|
25
25
|
@settings = load_settings
|
26
26
|
@id = id
|
27
|
+
@has_loaded_data = false
|
27
28
|
end
|
28
29
|
|
29
30
|
def evaluate_next_level
|
30
31
|
instance_eval(&@block) if @block
|
31
32
|
end
|
32
33
|
|
33
|
-
def
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
34
|
+
def load_data
|
35
|
+
return if @has_loaded_data
|
36
|
+
|
37
|
+
@has_loaded_data = true
|
38
|
+
load_all_boards
|
39
|
+
@id = guess_project_id
|
40
|
+
load_status_category_mappings
|
41
|
+
load_project_metadata
|
42
|
+
load_sprints
|
43
|
+
end
|
44
|
+
|
45
|
+
def run load_only: false
|
46
|
+
load_data unless aggregated_project?
|
41
47
|
anonymize_data if @anonymizer_needed
|
42
48
|
|
49
|
+
return if load_only
|
50
|
+
|
43
51
|
@board_configs.each do |board_config|
|
44
52
|
board_config.run
|
45
53
|
end
|
@@ -117,7 +125,7 @@ class ProjectConfig
|
|
117
125
|
board_id = $1.to_i
|
118
126
|
load_board board_id: board_id, filename: "#{@target_path}#{file}"
|
119
127
|
end
|
120
|
-
raise "No boards found for #{@file_prefix} in #{@target_path.inspect}" if @all_boards.empty?
|
128
|
+
raise "No boards found for #{@file_prefix.inspect} in #{@target_path.inspect}" if @all_boards.empty?
|
121
129
|
end
|
122
130
|
|
123
131
|
def load_board board_id:, filename:
|
@@ -232,7 +240,7 @@ class ProjectConfig
|
|
232
240
|
end
|
233
241
|
|
234
242
|
def load_project_metadata
|
235
|
-
filename = "#{
|
243
|
+
filename = File.join @target_path, "#{file_prefix}_meta.json"
|
236
244
|
json = JSON.parse(file_system.load(filename))
|
237
245
|
|
238
246
|
@data_version = json['version'] || 1
|
@@ -243,7 +251,7 @@ class ProjectConfig
|
|
243
251
|
|
244
252
|
@jira_url = json['jira_url']
|
245
253
|
rescue Errno::ENOENT
|
246
|
-
|
254
|
+
file_system.log "Can't load #{filename}. Have you done a download?", also_write_to_stderr: true
|
247
255
|
raise
|
248
256
|
end
|
249
257
|
|
@@ -259,7 +267,7 @@ class ProjectConfig
|
|
259
267
|
unless all_boards&.size == 1
|
260
268
|
message = "If the board_id isn't set then we look for all board configurations in the target" \
|
261
269
|
' directory. '
|
262
|
-
if all_boards.
|
270
|
+
if all_boards.empty?
|
263
271
|
message += ' In this case, we couldn\'t find any configuration files in the target directory.'
|
264
272
|
else
|
265
273
|
message += 'If there is only one, we use that. In this case we found configurations for' \
|
@@ -291,21 +299,21 @@ class ProjectConfig
|
|
291
299
|
end
|
292
300
|
|
293
301
|
def issues
|
294
|
-
raise "issues are being loaded before boards in project #{name.inspect}" if all_boards.nil? && !aggregated_project?
|
295
|
-
|
296
302
|
unless @issues
|
297
|
-
if
|
303
|
+
if aggregated_project?
|
298
304
|
raise 'This is an aggregated project and issues should have been included with the include_issues_from ' \
|
299
305
|
'declaration but none are here. Check your config.'
|
300
306
|
end
|
307
|
+
load_data if all_boards.empty?
|
301
308
|
|
302
309
|
timezone_offset = exporter.timezone_offset
|
303
310
|
|
304
|
-
issues_path =
|
311
|
+
issues_path = File.join @target_path, "#{file_prefix}_issues"
|
305
312
|
if File.exist?(issues_path) && File.directory?(issues_path)
|
306
313
|
issues = load_issues_from_issues_directory path: issues_path, timezone_offset: timezone_offset
|
307
314
|
else
|
308
|
-
|
315
|
+
file_system.log "Can't find directory #{issues_path}. Has a download been done?", also_write_to_stderr: true
|
316
|
+
return []
|
309
317
|
end
|
310
318
|
|
311
319
|
# Attach related issues
|
@@ -351,8 +359,8 @@ class ProjectConfig
|
|
351
359
|
raise "No boards found for project #{name.inspect}" if all_boards.empty?
|
352
360
|
|
353
361
|
if all_boards.size != 1
|
354
|
-
|
355
|
-
"Picked #{default_board.name.inspect} to attach issues to."
|
362
|
+
file_system.log "Multiple boards are in use for project #{name.inspect}. " \
|
363
|
+
"Picked #{default_board.name.inspect} to attach issues to.", also_write_to_stderr: true
|
356
364
|
end
|
357
365
|
default_board
|
358
366
|
end
|
@@ -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
|
@@ -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'
|
@@ -126,7 +126,7 @@ class SprintBurndown < ChartBase
|
|
126
126
|
currently_in_sprint = false
|
127
127
|
change_data = []
|
128
128
|
|
129
|
-
issue_completed_time = issue.board.cycletime.
|
129
|
+
issue_completed_time = issue.board.cycletime.started_stopped_times(issue).last
|
130
130
|
completed_has_been_tracked = false
|
131
131
|
|
132
132
|
issue.changes.each do |change|
|
data/lib/jirametrics/status.rb
CHANGED
@@ -82,7 +82,7 @@ class ThroughputChart < ChartBase
|
|
82
82
|
def throughput_dataset periods:, completed_issues:
|
83
83
|
periods.collect do |period|
|
84
84
|
closed_issues = completed_issues.filter_map do |issue|
|
85
|
-
stop_date = issue.board.cycletime.
|
85
|
+
stop_date = issue.board.cycletime.started_stopped_times(issue).last&.to_date
|
86
86
|
[stop_date, issue] if stop_date && period.include?(stop_date)
|
87
87
|
end
|
88
88
|
|
data/lib/jirametrics.rb
CHANGED
@@ -3,9 +3,13 @@
|
|
3
3
|
require 'thor'
|
4
4
|
|
5
5
|
class JiraMetrics < Thor
|
6
|
+
def self.exit_on_failure?
|
7
|
+
true
|
8
|
+
end
|
9
|
+
|
6
10
|
option :config
|
7
11
|
option :name
|
8
|
-
desc 'export
|
12
|
+
desc 'export', "Export data into either reports or CSV's as per the configuration"
|
9
13
|
def export
|
10
14
|
load_config options[:config]
|
11
15
|
Exporter.instance.export(name_filter: options[:name] || '*')
|
@@ -13,7 +17,7 @@ class JiraMetrics < Thor
|
|
13
17
|
|
14
18
|
option :config
|
15
19
|
option :name
|
16
|
-
desc 'download
|
20
|
+
desc 'download', 'Download data from Jira'
|
17
21
|
def download
|
18
22
|
load_config options[:config]
|
19
23
|
Exporter.instance.download(name_filter: options[:name] || '*')
|
@@ -21,7 +25,7 @@ class JiraMetrics < Thor
|
|
21
25
|
|
22
26
|
option :config
|
23
27
|
option :name
|
24
|
-
desc '
|
28
|
+
desc 'go', 'Same as running download, followed by export'
|
25
29
|
def go
|
26
30
|
load_config options[:config]
|
27
31
|
Exporter.instance.download(name_filter: options[:name] || '*')
|
@@ -30,6 +34,13 @@ class JiraMetrics < Thor
|
|
30
34
|
Exporter.instance.export(name_filter: options[:name] || '*')
|
31
35
|
end
|
32
36
|
|
37
|
+
option :config
|
38
|
+
desc 'info', 'Dump information about one issue'
|
39
|
+
def info keys
|
40
|
+
load_config options[:config]
|
41
|
+
Exporter.instance.info(keys, name_filter: options[:name] || '*')
|
42
|
+
end
|
43
|
+
|
33
44
|
private
|
34
45
|
|
35
46
|
def load_config config_file
|
@@ -69,6 +80,7 @@ class JiraMetrics < Thor
|
|
69
80
|
require 'jirametrics/daily_wip_by_parent_chart'
|
70
81
|
require 'jirametrics/aging_work_in_progress_chart'
|
71
82
|
require 'jirametrics/cycletime_scatterplot'
|
83
|
+
require 'jirametrics/flow_efficiency_scatterplot'
|
72
84
|
require 'jirametrics/sprint_issue_change_data'
|
73
85
|
require 'jirametrics/cycletime_histogram'
|
74
86
|
require 'jirametrics/daily_wip_by_blocked_stalled_chart'
|
@@ -97,6 +109,4 @@ class JiraMetrics < Thor
|
|
97
109
|
require 'jirametrics/board'
|
98
110
|
load config_file
|
99
111
|
end
|
100
|
-
|
101
|
-
# Dir.foreach('lib/jirametrics') {|file| puts "require 'jirametrics/#{$1}'" if file =~ /^(.+)\.rb$/}
|
102
112
|
end
|