jirametrics 2.25 → 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/aging_work_bar_chart.rb +10 -8
- data/lib/jirametrics/aging_work_in_progress_chart.rb +43 -11
- data/lib/jirametrics/aging_work_table.rb +5 -2
- data/lib/jirametrics/board.rb +9 -1
- data/lib/jirametrics/cfd_data_builder.rb +5 -0
- data/lib/jirametrics/chart_base.rb +14 -2
- data/lib/jirametrics/cumulative_flow_diagram.rb +8 -0
- data/lib/jirametrics/cycletime_scatterplot.rb +4 -0
- data/lib/jirametrics/daily_view.rb +5 -4
- data/lib/jirametrics/data_quality_report.rb +3 -1
- data/lib/jirametrics/dependency_chart.rb +1 -1
- data/lib/jirametrics/downloader.rb +18 -7
- data/lib/jirametrics/downloader_for_cloud.rb +68 -22
- data/lib/jirametrics/downloader_for_data_center.rb +1 -1
- data/lib/jirametrics/examples/aggregated_project.rb +1 -1
- data/lib/jirametrics/examples/standard_project.rb +5 -2
- data/lib/jirametrics/exporter.rb +12 -1
- data/lib/jirametrics/file_config.rb +9 -11
- data/lib/jirametrics/file_system.rb +31 -2
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
- data/lib/jirametrics/github_gateway.rb +13 -4
- data/lib/jirametrics/groupable_issue_chart.rb +2 -0
- data/lib/jirametrics/grouping_rules.rb +5 -1
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +7 -8
- data/lib/jirametrics/html/index.css +139 -88
- data/lib/jirametrics/html/index.erb +1 -0
- data/lib/jirametrics/html/index.js +1 -1
- data/lib/jirametrics/html/legacy_colors.css +174 -0
- data/lib/jirametrics/html/time_based_scatterplot.erb +8 -3
- data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
- data/lib/jirametrics/html_generator.rb +2 -1
- data/lib/jirametrics/html_report_config.rb +33 -27
- data/lib/jirametrics/issue.rb +99 -6
- data/lib/jirametrics/jira_gateway.rb +26 -7
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +20 -1
- data/lib/jirametrics/pull_request_cycle_time_histogram.rb +2 -2
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +13 -6
- data/lib/jirametrics/sprint_burndown.rb +1 -1
- data/lib/jirametrics/stitcher.rb +5 -0
- data/lib/jirametrics/throughput_chart.rb +18 -2
- data/lib/jirametrics/time_based_scatterplot.rb +9 -2
- data/lib/jirametrics/wip_by_column_chart.rb +236 -0
- data/lib/jirametrics.rb +58 -0
- metadata +36 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a1f64f63f13e8cb59d3b18fb1e1ad90f77ca06d0e2f59a75ff7b7bae4db1870f
|
|
4
|
+
data.tar.gz: 9b7d6b8759102d7590e86c114d2ac8b1b2e7c4cc9f45a168002752196a6bf797
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8ec0bee468f8c34c001ea9151b0d78b1018d246cc86f9c2588a70ee55e3940b6263f69032884ec5c2dc596d3182909829f6ee63fe51b6c65444ce667bf70a6ca
|
|
7
|
+
data.tar.gz: 9b9f5337d0fc671639f9f651977cb2ecc60b2d27105cdce1b75a2a4188c093c111ba29ed880adccb515d548b8bd9209b4cf703482a0ede4ccea6b182212a3a57
|
data/bin/jirametrics-mcp
ADDED
|
@@ -15,7 +15,7 @@ class AgingWorkBarChart < ChartBase
|
|
|
15
15
|
newest at the bottom.
|
|
16
16
|
</p>
|
|
17
17
|
<p>
|
|
18
|
-
There are <%= current_board.scrum? ? 'four' : 'three' %> bars for each issue, and hovering over any of the bars will provide more details.
|
|
18
|
+
There are <%= (aggregated_project? || current_board.scrum?) ? 'four' : 'three' %> bars for each issue, and hovering over any of the bars will provide more details.
|
|
19
19
|
<ol>
|
|
20
20
|
<li>Status: The status the issue was in at any time. The colour indicates the
|
|
21
21
|
status category, which will be one of #{color_block '--status-category-todo-color'} To Do,
|
|
@@ -25,7 +25,7 @@ class AgingWorkBarChart < ChartBase
|
|
|
25
25
|
or #{color_block '--stalled-color'} stalled.</li>
|
|
26
26
|
<li>Priority: This shows the priority over time. If one of these priorities is considered expedited
|
|
27
27
|
then it will be drawn with diagonal lines.</li>
|
|
28
|
-
<% if current_board.scrum? %>
|
|
28
|
+
<% if aggregated_project? || current_board.scrum? %>
|
|
29
29
|
<li>Sprints: The sprints that the issue was in.</li>
|
|
30
30
|
<% end %>
|
|
31
31
|
</ol>
|
|
@@ -84,7 +84,7 @@ class AgingWorkBarChart < ChartBase
|
|
|
84
84
|
['blocked', collect_blocked_stalled_ranges(issue: issue, issue_start_time: issue_start_time)],
|
|
85
85
|
['priority', collect_priority_ranges(issue: issue)]
|
|
86
86
|
]
|
|
87
|
-
bar_data << ['sprints', collect_sprint_ranges(issue: issue)] if current_board.scrum?
|
|
87
|
+
bar_data << ['sprints', collect_sprint_ranges(issue: issue)] if aggregated_project? || current_board.scrum?
|
|
88
88
|
|
|
89
89
|
bar_data.each { |entry| clip_ranges_to_start_time(ranges: entry.last, issue_start_time: issue_start_time) }
|
|
90
90
|
|
|
@@ -113,7 +113,7 @@ class AgingWorkBarChart < ChartBase
|
|
|
113
113
|
def grow_chart_height_if_too_many_issues aging_issue_count:
|
|
114
114
|
px_per_bar = 10
|
|
115
115
|
bars_per_issue = 3
|
|
116
|
-
bars_per_issue += 1 if current_board.scrum?
|
|
116
|
+
bars_per_issue += 1 if aggregated_project? || current_board.scrum?
|
|
117
117
|
|
|
118
118
|
preferred_height = aging_issue_count * px_per_bar * bars_per_issue
|
|
119
119
|
@canvas_height = preferred_height if @canvas_height.nil? || @canvas_height < preferred_height
|
|
@@ -235,10 +235,12 @@ class AgingWorkBarChart < ChartBase
|
|
|
235
235
|
previous_change = change
|
|
236
236
|
end
|
|
237
237
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
238
|
+
if previous_change
|
|
239
|
+
results << create_range_for_priority(
|
|
240
|
+
previous_change: previous_change, stop_time: time_range.end,
|
|
241
|
+
expedited_priority_names: expedited_priority_names
|
|
242
|
+
)
|
|
243
|
+
end
|
|
242
244
|
results
|
|
243
245
|
end
|
|
244
246
|
|
|
@@ -6,6 +6,7 @@ require 'jirametrics/board_movement_calculator'
|
|
|
6
6
|
|
|
7
7
|
class AgingWorkInProgressChart < ChartBase
|
|
8
8
|
include GroupableIssueChart
|
|
9
|
+
|
|
9
10
|
attr_accessor :possible_statuses, :board_id
|
|
10
11
|
attr_reader :board_columns
|
|
11
12
|
|
|
@@ -55,7 +56,7 @@ class AgingWorkInProgressChart < ChartBase
|
|
|
55
56
|
def run
|
|
56
57
|
determine_board_columns
|
|
57
58
|
|
|
58
|
-
@header_text += " on board: #{
|
|
59
|
+
@header_text += " on board: #{current_board.name}"
|
|
59
60
|
data_sets = make_data_sets
|
|
60
61
|
|
|
61
62
|
adjust_visibility_of_unmapped_status_column data_sets: data_sets
|
|
@@ -76,7 +77,7 @@ class AgingWorkInProgressChart < ChartBase
|
|
|
76
77
|
|
|
77
78
|
@fake_column = BoardColumn.new({
|
|
78
79
|
'name' => '[Unmapped Statuses]',
|
|
79
|
-
'statuses' => unmapped_statuses.collect { |id| { 'id' => id.to_s } }.uniq
|
|
80
|
+
'statuses' => unmapped_statuses.collect { |id| { 'id' => id.to_s } }.uniq
|
|
80
81
|
})
|
|
81
82
|
@board_columns = columns + [@fake_column]
|
|
82
83
|
end
|
|
@@ -114,14 +115,7 @@ class AgingWorkInProgressChart < ChartBase
|
|
|
114
115
|
|
|
115
116
|
calculator = BoardMovementCalculator.new board: @all_boards[@board_id], issues: issues, today: date_range.end
|
|
116
117
|
|
|
117
|
-
column_indexes_to_remove =
|
|
118
|
-
unless @show_all_columns
|
|
119
|
-
column_indexes_to_remove = indexes_of_leading_and_trailing_zeros(calculator.age_data_for(percentage: 100))
|
|
120
|
-
|
|
121
|
-
column_indexes_to_remove.reverse_each do |index|
|
|
122
|
-
@board_columns.delete_at index
|
|
123
|
-
end
|
|
124
|
-
end
|
|
118
|
+
column_indexes_to_remove = trim_board_columns data_sets: data_sets, calculator: calculator
|
|
125
119
|
|
|
126
120
|
@row_index_offset = data_sets.size
|
|
127
121
|
|
|
@@ -177,6 +171,44 @@ class AgingWorkInProgressChart < ChartBase
|
|
|
177
171
|
result
|
|
178
172
|
end
|
|
179
173
|
|
|
174
|
+
def trim_board_columns data_sets:, calculator:
|
|
175
|
+
return [] if @show_all_columns
|
|
176
|
+
|
|
177
|
+
columns_with_aging_items = data_sets.flat_map do |set|
|
|
178
|
+
set['data'].filter_map { |d| d['x'] if d.is_a? Hash }
|
|
179
|
+
end.uniq
|
|
180
|
+
|
|
181
|
+
# @fake_column is always the last element and is handled separately.
|
|
182
|
+
real_column_count = @board_columns.size - 1
|
|
183
|
+
|
|
184
|
+
# The last visible column always has artificially inflated age_data because
|
|
185
|
+
# ages_of_issues_when_leaving_column uses `today` as end_date when there is no
|
|
186
|
+
# next column. Exclude it from the right-boundary search so it is only kept when
|
|
187
|
+
# it has current aging items (handled by the last_aging fallback below).
|
|
188
|
+
age_data = calculator.age_data_for(percentage: 100)
|
|
189
|
+
last_data = (0...(real_column_count - 1)).to_a.reverse.find { |i| !age_data[i].zero? }
|
|
190
|
+
|
|
191
|
+
in_current = ->(i) { columns_with_aging_items.include?(@board_columns[i].name) }
|
|
192
|
+
first_aging = (0...real_column_count).find(&in_current)
|
|
193
|
+
last_aging = (0...real_column_count).to_a.reverse.find(&in_current)
|
|
194
|
+
|
|
195
|
+
# Combine: include any column with age_data (up to but not including the last visible
|
|
196
|
+
# column) and any column with current aging items.
|
|
197
|
+
first_data = (0...real_column_count).find { |i| !age_data[i].zero? }
|
|
198
|
+
left_bound = [first_data, first_aging].compact.min
|
|
199
|
+
right_bound = [last_data, last_aging].compact.max
|
|
200
|
+
|
|
201
|
+
indexes_to_remove =
|
|
202
|
+
if left_bound && right_bound
|
|
203
|
+
(0...left_bound).to_a + ((right_bound + 1)...real_column_count).to_a
|
|
204
|
+
else
|
|
205
|
+
(0...real_column_count).to_a
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
indexes_to_remove.reverse_each { |index| @board_columns.delete_at index }
|
|
209
|
+
indexes_to_remove
|
|
210
|
+
end
|
|
211
|
+
|
|
180
212
|
def column_for issue:
|
|
181
213
|
@board_columns.find do |board_column|
|
|
182
214
|
board_column.status_ids.include? issue.status.id
|
|
@@ -192,7 +224,7 @@ class AgingWorkInProgressChart < ChartBase
|
|
|
192
224
|
end
|
|
193
225
|
end
|
|
194
226
|
|
|
195
|
-
if has_unmapped
|
|
227
|
+
if has_unmapped && @description_text
|
|
196
228
|
@description_text += "<p>The items shown in #{column_name.inspect} are not visible on the " \
|
|
197
229
|
'board but are still active. Most likely everyone has forgotten about them.</p>'
|
|
198
230
|
else
|
|
@@ -45,7 +45,9 @@ class AgingWorkTable < ChartBase
|
|
|
45
45
|
# This is its own method simply so the tests can initialize the calculator without doing a full run.
|
|
46
46
|
def initialize_calculator
|
|
47
47
|
@today = date_range.end
|
|
48
|
-
@
|
|
48
|
+
@calculators = @all_boards.transform_values do |board|
|
|
49
|
+
BoardMovementCalculator.new board: board, issues: issues, today: @today
|
|
50
|
+
end
|
|
49
51
|
end
|
|
50
52
|
|
|
51
53
|
def expedited_but_not_started
|
|
@@ -123,7 +125,8 @@ class AgingWorkTable < ChartBase
|
|
|
123
125
|
due = issue.due_date
|
|
124
126
|
message = nil
|
|
125
127
|
|
|
126
|
-
|
|
128
|
+
calculator = @calculators[issue.board.id]
|
|
129
|
+
days_remaining, error = calculator.forecasted_days_remaining_and_message issue: issue, today: @today
|
|
127
130
|
|
|
128
131
|
unless error
|
|
129
132
|
if due
|
data/lib/jirametrics/board.rb
CHANGED
|
@@ -72,7 +72,7 @@ class Board
|
|
|
72
72
|
return true if board_type == 'scrum'
|
|
73
73
|
return false unless board_type == 'simple'
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
has_sprints_feature?
|
|
76
76
|
end
|
|
77
77
|
|
|
78
78
|
def kanban?
|
|
@@ -82,6 +82,14 @@ class Board
|
|
|
82
82
|
!scrum?
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
+
def team_managed_kanban?
|
|
86
|
+
board_type == 'simple' && !has_sprints_feature?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def has_sprints_feature?
|
|
90
|
+
@features.any? { |f| f.name == 'jsw.agility.sprints' && f.enabled? }
|
|
91
|
+
end
|
|
92
|
+
|
|
85
93
|
def id
|
|
86
94
|
@raw['id'].to_i
|
|
87
95
|
end
|
|
@@ -31,12 +31,17 @@ class CfdDataBuilder
|
|
|
31
31
|
|
|
32
32
|
# Returns { hwm_timeline: [[date, hwm_value], ...], correction_windows: [...] }
|
|
33
33
|
def process_issue issue, column_map
|
|
34
|
+
start_time = issue.started_stopped_times.first
|
|
35
|
+
return { hwm_timeline: [], correction_windows: [] } if start_time.nil?
|
|
36
|
+
|
|
34
37
|
high_water_mark = nil
|
|
35
38
|
correction_open_since = nil
|
|
36
39
|
correction_windows = []
|
|
37
40
|
hwm_timeline = [] # sorted chronologically by date
|
|
38
41
|
|
|
39
42
|
issue.status_changes.each do |change|
|
|
43
|
+
next if change.time < start_time
|
|
44
|
+
|
|
40
45
|
col_index = column_map[change.value_id]
|
|
41
46
|
next if col_index.nil?
|
|
42
47
|
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class ChartBase
|
|
4
|
+
# Okabe-Ito palette — perceptually distinct under the most common forms of colour blindness.
|
|
5
|
+
# Ordered from most- to least-commonly useful for chart series.
|
|
6
|
+
OKABE_ITO_PALETTE = %w[
|
|
7
|
+
#0072B2
|
|
8
|
+
#E69F00
|
|
9
|
+
#009E73
|
|
10
|
+
#56B4E9
|
|
11
|
+
#D55E00
|
|
12
|
+
#CC79A7
|
|
13
|
+
#F0E442
|
|
14
|
+
].freeze
|
|
4
15
|
attr_accessor :timezone_offset, :board_id, :all_boards, :date_range,
|
|
5
16
|
:time_range, :data_quality, :holiday_dates, :settings, :issues, :file_system,
|
|
6
17
|
:atlassian_document_format, :x_axis_title, :y_axis_title, :fix_versions
|
|
@@ -328,7 +339,8 @@ class ChartBase
|
|
|
328
339
|
end
|
|
329
340
|
|
|
330
341
|
def random_color
|
|
331
|
-
|
|
342
|
+
@palette_index = (@palette_index || -1) + 1
|
|
343
|
+
OKABE_ITO_PALETTE[@palette_index % OKABE_ITO_PALETTE.size]
|
|
332
344
|
end
|
|
333
345
|
|
|
334
346
|
def canvas width:, height:, responsive: true
|
|
@@ -377,7 +389,7 @@ class ChartBase
|
|
|
377
389
|
end
|
|
378
390
|
|
|
379
391
|
def seam_start type = 'chart'
|
|
380
|
-
"\n<!-- seam-start | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type}
|
|
392
|
+
"\n<!-- seam-start | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type} -->\n"
|
|
381
393
|
end
|
|
382
394
|
|
|
383
395
|
def seam_end type = 'chart'
|
|
@@ -64,6 +64,14 @@ class CumulativeFlowDiagram < ChartBase
|
|
|
64
64
|
<b>average throughput (TP)</b> (items completed per day). Use the checkbox above the chart to toggle
|
|
65
65
|
between the triangle and the normal data tooltips.
|
|
66
66
|
</div>
|
|
67
|
+
<div class="p">
|
|
68
|
+
CT and TP require a future point C where cumulative completions catch up to current arrivals.
|
|
69
|
+
When the cursor is near the right edge and that point falls outside the visible date range,
|
|
70
|
+
CT and TP cannot be calculated and are hidden; only WIP is shown.
|
|
71
|
+
</div>
|
|
72
|
+
<div class="p">
|
|
73
|
+
See also: This article on <a href="https://blog.mikebowler.ca/2026/03/27/cumulative-flow-diagram/">how to read a CFD</a>.
|
|
74
|
+
</div>
|
|
67
75
|
HTML
|
|
68
76
|
instance_eval(&block)
|
|
69
77
|
end
|
|
@@ -35,6 +35,10 @@ class CycletimeScatterplot < TimeBasedScatterplot
|
|
|
35
35
|
end
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
def minimum_y_value
|
|
39
|
+
1 # Values under 1 day are data quality problems; they're flagged in the quality report instead
|
|
40
|
+
end
|
|
41
|
+
|
|
38
42
|
def all_items
|
|
39
43
|
completed_issues_in_range include_unstarted: false
|
|
40
44
|
end
|
|
@@ -24,7 +24,7 @@ class DailyView < ChartBase
|
|
|
24
24
|
def run
|
|
25
25
|
aging_issues = select_aging_issues
|
|
26
26
|
|
|
27
|
-
return "<h1 class='foldable'>#{@header_text}</h1>There are no items currently in progress" if aging_issues.empty?
|
|
27
|
+
return "<h1 class='foldable'>#{@header_text}</h1><div>There are no items currently in progress</div>" if aging_issues.empty?
|
|
28
28
|
|
|
29
29
|
result = +''
|
|
30
30
|
result << render_top_text(binding)
|
|
@@ -87,13 +87,14 @@ class DailyView < ChartBase
|
|
|
87
87
|
lines << ["#{marker} Blocked by flag"] if blocked_stalled.flag
|
|
88
88
|
lines << ["#{marker} Blocked by status: #{blocked_stalled.status}"] if blocked_stalled.blocked_by_status?
|
|
89
89
|
blocked_stalled.blocking_issue_keys&.each do |key|
|
|
90
|
-
blocking_issue = issues.
|
|
90
|
+
blocking_issue = issues.find_by_key key: key, include_hidden: true
|
|
91
91
|
if blocking_issue
|
|
92
|
-
lines << "<section><div class=\"foldable startFolded\">#{marker} Blocked by issue:
|
|
92
|
+
lines << "<section><div class=\"foldable startFolded\">#{marker} Blocked by issue: " \
|
|
93
|
+
"#{make_issue_label issue: blocking_issue, done: blocking_issue.done?}</div>"
|
|
93
94
|
lines << blocking_issue
|
|
94
95
|
lines << '</section>'
|
|
95
96
|
else
|
|
96
|
-
lines << ["#{marker} Blocked by issue: #{key}"]
|
|
97
|
+
lines << ["#{marker} Blocked by issue: #{key} (no description found)"]
|
|
97
98
|
end
|
|
98
99
|
end
|
|
99
100
|
elsif blocked_stalled.stalled_by_status?
|
|
@@ -434,8 +434,10 @@ class DataQualityReport < ChartBase
|
|
|
434
434
|
end
|
|
435
435
|
|
|
436
436
|
def render_issue_not_visible_on_board problems
|
|
437
|
+
unique_issue_count = problems.map(&:first).uniq.size
|
|
437
438
|
<<-HTML
|
|
438
|
-
#{
|
|
439
|
+
#{problems.size} #{'time'.then { |w| problems.size == 1 ? w : "#{w}s" }} across #{label_issues unique_issue_count},
|
|
440
|
+
an item was not visible on the board. This may impact
|
|
439
441
|
timings as the work was likely to have been forgotten if it wasn't visible. An issue can be not visible
|
|
440
442
|
for two reasons: the issue was in a status that is not mapped to any visible column on the board
|
|
441
443
|
(look in "unmapped statuses" on your board), or for scrum boards, the issue was not in an active sprint.
|
|
@@ -57,7 +57,7 @@ class DependencyChart < ChartBase
|
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
svg = execute_graphviz(dot_graph.join("\n"))
|
|
60
|
-
"<h1>#{@header_text}</h1><div>#{@description_text}
|
|
60
|
+
"<h1 class='foldable'>#{@header_text}</h1><div>#{@description_text}#{shrink_svg svg}</div>"
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
def link_rules &block
|
|
@@ -74,6 +74,7 @@ class Downloader
|
|
|
74
74
|
download_statuses
|
|
75
75
|
find_board_ids.each do |id|
|
|
76
76
|
board = download_board_configuration board_id: id
|
|
77
|
+
board.project_config = @download_config.project_config
|
|
77
78
|
download_issues board: board
|
|
78
79
|
end
|
|
79
80
|
download_users
|
|
@@ -86,6 +87,23 @@ class Downloader
|
|
|
86
87
|
@file_system.log text, also_write_to_stderr: both
|
|
87
88
|
end
|
|
88
89
|
|
|
90
|
+
def log_start text
|
|
91
|
+
@file_system.log_start text
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def start_progress
|
|
95
|
+
@file_system.start_progress
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def progress_dot message = nil
|
|
99
|
+
@file_system.log message if message
|
|
100
|
+
@file_system.progress_dot
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def end_progress
|
|
104
|
+
@file_system.end_progress
|
|
105
|
+
end
|
|
106
|
+
|
|
89
107
|
def find_board_ids
|
|
90
108
|
ids = @download_config.project_config.board_configs.collect(&:id)
|
|
91
109
|
raise 'Board ids must be specified' if ids.empty?
|
|
@@ -232,13 +250,6 @@ class Downloader
|
|
|
232
250
|
@metadata[key] = value
|
|
233
251
|
end
|
|
234
252
|
|
|
235
|
-
# If rolling_date_count has changed, we may be missing data outside the previous range,
|
|
236
|
-
# so force a full re-download.
|
|
237
|
-
if @metadata['rolling_date_count'] != @download_config.rolling_date_count
|
|
238
|
-
log ' rolling_date_count has changed. Forcing a full download.', both: true
|
|
239
|
-
@cached_data_format_is_current = false
|
|
240
|
-
@metadata = {}
|
|
241
|
-
end
|
|
242
253
|
end
|
|
243
254
|
|
|
244
255
|
# Even if this is the old format, we want to obey this one tag
|
|
@@ -53,6 +53,7 @@ class DownloaderForCloud < Downloader
|
|
|
53
53
|
next_page_token = nil
|
|
54
54
|
issue_count = 0
|
|
55
55
|
|
|
56
|
+
start_progress
|
|
56
57
|
loop do
|
|
57
58
|
relative_url = +''
|
|
58
59
|
relative_url << '/rest/api/3/search/jql'
|
|
@@ -75,11 +76,12 @@ class DownloaderForCloud < Downloader
|
|
|
75
76
|
issue_count += 1
|
|
76
77
|
end
|
|
77
78
|
|
|
78
|
-
|
|
79
|
-
log message, both: true
|
|
79
|
+
progress_dot " Found #{issue_count} issues"
|
|
80
80
|
|
|
81
81
|
break unless next_page_token
|
|
82
82
|
end
|
|
83
|
+
end_progress
|
|
84
|
+
|
|
83
85
|
hash
|
|
84
86
|
end
|
|
85
87
|
|
|
@@ -88,7 +90,7 @@ class DownloaderForCloud < Downloader
|
|
|
88
90
|
# that only returns the "recent" changes, not all of them. So now we get the issue
|
|
89
91
|
# without changes and then make a second call for that changes. Then we insert it
|
|
90
92
|
# into the raw issue as if it had been there all along.
|
|
91
|
-
log " Downloading #{issue_datas.size} issues"
|
|
93
|
+
log " Downloading #{issue_datas.size} issues"
|
|
92
94
|
payload = {
|
|
93
95
|
'fields' => ['*all'],
|
|
94
96
|
'issueIdsOrKeys' => issue_datas.collect(&:key)
|
|
@@ -106,11 +108,24 @@ class DownloaderForCloud < Downloader
|
|
|
106
108
|
}
|
|
107
109
|
issue = Issue.new(raw: issue_json, board: board)
|
|
108
110
|
data = issue_datas.find { |d| d.key == issue.key }
|
|
111
|
+
unless data
|
|
112
|
+
log " Skipping #{issue.key}: returned by Jira but key not in request (issue may have been moved)"
|
|
113
|
+
next
|
|
114
|
+
end
|
|
109
115
|
data.up_to_date = true
|
|
110
116
|
data.last_modified = issue.updated
|
|
111
117
|
data.issue = issue
|
|
112
118
|
end
|
|
113
119
|
|
|
120
|
+
# Mark any unmatched requests as up_to_date to prevent infinite re-fetching.
|
|
121
|
+
# This happens when Jira returns a different key (moved issue) leaving the original unmatched.
|
|
122
|
+
issue_datas.each do |data|
|
|
123
|
+
next if data.up_to_date
|
|
124
|
+
|
|
125
|
+
log " Skipping #{data.key}: not returned by Jira (issue may have been deleted or moved)"
|
|
126
|
+
data.up_to_date = true
|
|
127
|
+
end
|
|
128
|
+
|
|
114
129
|
issue_datas
|
|
115
130
|
end
|
|
116
131
|
|
|
@@ -166,16 +181,20 @@ class DownloaderForCloud < Downloader
|
|
|
166
181
|
|
|
167
182
|
issue_data_hash = search_for_issues jql: jql, board_id: board.id, path: path
|
|
168
183
|
|
|
184
|
+
checked_for_related = Set.new
|
|
185
|
+
in_related_phase = false
|
|
186
|
+
|
|
169
187
|
loop do
|
|
170
188
|
related_issue_keys = Set.new
|
|
171
|
-
issue_data_hash
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
.each_slice(100) do |slice|
|
|
175
|
-
slice = bulk_fetch_issues(
|
|
176
|
-
|
|
177
|
-
)
|
|
189
|
+
stale = issue_data_hash.values.reject { |data| data.up_to_date }
|
|
190
|
+
unless stale.empty?
|
|
191
|
+
log_start ' Downloading more issues ' unless in_related_phase
|
|
192
|
+
stale.each_slice(100) do |slice|
|
|
193
|
+
slice = bulk_fetch_issues(issue_datas: slice, board: board, in_initial_query: true)
|
|
194
|
+
progress_dot
|
|
178
195
|
slice.each do |data|
|
|
196
|
+
next unless data.issue
|
|
197
|
+
|
|
179
198
|
@file_system.save_json(
|
|
180
199
|
json: data.issue.raw, filename: data.cache_path
|
|
181
200
|
)
|
|
@@ -183,20 +202,25 @@ class DownloaderForCloud < Downloader
|
|
|
183
202
|
# to parse the file just to find the timestamp
|
|
184
203
|
@file_system.utime time: data.issue.updated, file: data.cache_path
|
|
185
204
|
|
|
186
|
-
issue
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
parent_key = issue.parent_key(project_config: @download_config.project_config)
|
|
190
|
-
related_issue_keys << parent_key if parent_key
|
|
191
|
-
|
|
192
|
-
# Sub-tasks
|
|
193
|
-
issue.raw['fields']['subtasks']&.each do |raw_subtask|
|
|
194
|
-
related_issue_keys << raw_subtask['key']
|
|
195
|
-
end
|
|
205
|
+
collect_related_issue_keys issue: data.issue, related_issue_keys: related_issue_keys
|
|
206
|
+
checked_for_related << data.key
|
|
196
207
|
end
|
|
197
208
|
end
|
|
209
|
+
end_progress unless in_related_phase
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Also scan up-to-date cached issues we haven't checked yet — they may reference
|
|
213
|
+
# related issues that are not in the primary query result.
|
|
214
|
+
issue_data_hash.each_value do |data|
|
|
215
|
+
next if checked_for_related.include?(data.key)
|
|
216
|
+
next unless @file_system.file_exist?(data.cache_path)
|
|
198
217
|
|
|
199
|
-
|
|
218
|
+
checked_for_related << data.key
|
|
219
|
+
raw = @file_system.load_json(data.cache_path)
|
|
220
|
+
collect_related_issue_keys issue: Issue.new(raw: raw, board: board), related_issue_keys: related_issue_keys
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Remove all the ones we already have
|
|
200
224
|
related_issue_keys.reject! { |key| issue_data_hash[key] }
|
|
201
225
|
|
|
202
226
|
related_issue_keys.each do |key|
|
|
@@ -208,9 +232,15 @@ class DownloaderForCloud < Downloader
|
|
|
208
232
|
end
|
|
209
233
|
break if related_issue_keys.empty?
|
|
210
234
|
|
|
211
|
-
|
|
235
|
+
unless in_related_phase
|
|
236
|
+
in_related_phase = true
|
|
237
|
+
log " Identifying related issues (parents, subtasks, links) for board #{board.id}", both: true
|
|
238
|
+
log_start ' Downloading more issues '
|
|
239
|
+
end
|
|
212
240
|
end
|
|
213
241
|
|
|
242
|
+
end_progress if in_related_phase
|
|
243
|
+
|
|
214
244
|
delete_issues_from_cache_that_are_not_in_server(
|
|
215
245
|
issue_data_hash: issue_data_hash, path: path
|
|
216
246
|
)
|
|
@@ -235,6 +265,22 @@ class DownloaderForCloud < Downloader
|
|
|
235
265
|
end
|
|
236
266
|
end
|
|
237
267
|
|
|
268
|
+
def collect_related_issue_keys issue:, related_issue_keys:
|
|
269
|
+
parent_key = issue.parent_key(project_config: @download_config.project_config)
|
|
270
|
+
related_issue_keys << parent_key if parent_key
|
|
271
|
+
|
|
272
|
+
issue.raw['fields']['subtasks']&.each do |raw_subtask|
|
|
273
|
+
related_issue_keys << raw_subtask['key']
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
issue.raw['fields']['issuelinks']&.each do |link|
|
|
277
|
+
next if link['type']['name'] == 'Cloners'
|
|
278
|
+
|
|
279
|
+
linked = link['inwardIssue'] || link['outwardIssue']
|
|
280
|
+
related_issue_keys << linked['key'] if linked
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
238
284
|
def last_modified filename:
|
|
239
285
|
File.mtime(filename) if File.exist?(filename)
|
|
240
286
|
end
|
|
@@ -25,7 +25,7 @@ class DownloaderForDataCenter < Downloader
|
|
|
25
25
|
keys_to_request = @issue_keys_pending_download[0..99]
|
|
26
26
|
@issue_keys_pending_download.reject! { |key| keys_to_request.include? key }
|
|
27
27
|
jql = "key in (#{keys_to_request.join(', ')})"
|
|
28
|
-
jira_search_by_jql(jql: jql, initial_query:
|
|
28
|
+
jira_search_by_jql(jql: jql, initial_query: true, board: board, path: path)
|
|
29
29
|
end
|
|
30
30
|
end
|
|
31
31
|
|
|
@@ -9,7 +9,7 @@ class Exporter
|
|
|
9
9
|
show_experimental_charts: false, github_repos: nil
|
|
10
10
|
exporter = self
|
|
11
11
|
project name: name do
|
|
12
|
-
|
|
12
|
+
file_system.log name, also_write_to_stderr: true
|
|
13
13
|
file_prefix file_prefix
|
|
14
14
|
|
|
15
15
|
self.anonymize if anonymize
|
|
@@ -35,7 +35,7 @@ class Exporter
|
|
|
35
35
|
download do
|
|
36
36
|
self.rolling_date_count(rolling_date_count) if rolling_date_count
|
|
37
37
|
self.no_earlier_than(no_earlier_than) if no_earlier_than
|
|
38
|
-
github_repo github_repos if github_repos
|
|
38
|
+
github_repo *github_repos if github_repos
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
issues.reject! do |issue|
|
|
@@ -82,6 +82,9 @@ class Exporter
|
|
|
82
82
|
end
|
|
83
83
|
|
|
84
84
|
aging_work_in_progress_chart
|
|
85
|
+
wip_by_column_chart do
|
|
86
|
+
show_recommendations
|
|
87
|
+
end
|
|
85
88
|
aging_work_bar_chart
|
|
86
89
|
aging_work_table
|
|
87
90
|
daily_wip_by_age_chart
|
data/lib/jirametrics/exporter.rb
CHANGED
|
@@ -8,7 +8,13 @@ class Exporter
|
|
|
8
8
|
|
|
9
9
|
def self.configure &block
|
|
10
10
|
logfile_name = 'jirametrics.log'
|
|
11
|
-
logfile = File.open
|
|
11
|
+
logfile = File.open(logfile_name, 'w')
|
|
12
|
+
rescue Errno::EACCES
|
|
13
|
+
# FileSystem can't be used here — it hasn't been created yet (it depends on this logfile).
|
|
14
|
+
warn "Error: Cannot write to #{File.expand_path(logfile_name)}. " \
|
|
15
|
+
'Please ensure the current directory is writable.'
|
|
16
|
+
exit 1
|
|
17
|
+
else
|
|
12
18
|
file_system = FileSystem.new
|
|
13
19
|
file_system.logfile = logfile
|
|
14
20
|
file_system.logfile_name = logfile_name
|
|
@@ -67,18 +73,23 @@ class Exporter
|
|
|
67
73
|
|
|
68
74
|
def info key, name_filter:
|
|
69
75
|
selected = []
|
|
76
|
+
file_system.log_only = true
|
|
70
77
|
each_project_config(name_filter: name_filter) do |project|
|
|
71
78
|
project.evaluate_next_level
|
|
72
79
|
|
|
73
80
|
project.run load_only: true
|
|
74
81
|
project.issues.each do |issue|
|
|
75
82
|
selected << [project, issue] if key == issue.key
|
|
83
|
+
issue.subtasks.each do |subtask|
|
|
84
|
+
selected << [project, subtask] if key == subtask.key
|
|
85
|
+
end
|
|
76
86
|
end
|
|
77
87
|
rescue => e # rubocop:disable Style/RescueStandardError
|
|
78
88
|
# This happens when we're attempting to load an aggregated project because it hasn't been
|
|
79
89
|
# properly initialized. Since we don't care about aggregated projects, we just ignore it.
|
|
80
90
|
raise unless e.message.start_with? 'This is an aggregated project and issues should have been included'
|
|
81
91
|
end
|
|
92
|
+
file_system.log_only = false
|
|
82
93
|
|
|
83
94
|
if selected.empty?
|
|
84
95
|
file_system.log "No issues found to match #{key.inspect}"
|