jirametrics 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/bin/jirametrics +4 -0
- data/lib/jirametrics/aggregate_config.rb +89 -0
- data/lib/jirametrics/aging_work_bar_chart.rb +235 -0
- data/lib/jirametrics/aging_work_in_progress_chart.rb +148 -0
- data/lib/jirametrics/aging_work_table.rb +149 -0
- data/lib/jirametrics/anonymizer.rb +186 -0
- data/lib/jirametrics/blocked_stalled_change.rb +43 -0
- data/lib/jirametrics/board.rb +85 -0
- data/lib/jirametrics/board_column.rb +14 -0
- data/lib/jirametrics/board_config.rb +31 -0
- data/lib/jirametrics/change_item.rb +80 -0
- data/lib/jirametrics/chart_base.rb +239 -0
- data/lib/jirametrics/columns_config.rb +42 -0
- data/lib/jirametrics/cycletime_config.rb +69 -0
- data/lib/jirametrics/cycletime_histogram.rb +74 -0
- data/lib/jirametrics/cycletime_scatterplot.rb +128 -0
- data/lib/jirametrics/daily_wip_by_age_chart.rb +88 -0
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +77 -0
- data/lib/jirametrics/daily_wip_chart.rb +123 -0
- data/lib/jirametrics/data_quality_report.rb +278 -0
- data/lib/jirametrics/dependency_chart.rb +217 -0
- data/lib/jirametrics/discard_changes_before.rb +37 -0
- data/lib/jirametrics/download_config.rb +41 -0
- data/lib/jirametrics/downloader.rb +337 -0
- data/lib/jirametrics/examples/aggregated_project.rb +36 -0
- data/lib/jirametrics/examples/standard_project.rb +111 -0
- data/lib/jirametrics/expedited_chart.rb +169 -0
- data/lib/jirametrics/experimental/generator.rb +209 -0
- data/lib/jirametrics/experimental/info.rb +77 -0
- data/lib/jirametrics/exporter.rb +127 -0
- data/lib/jirametrics/file_config.rb +119 -0
- data/lib/jirametrics/fix_version.rb +21 -0
- data/lib/jirametrics/groupable_issue_chart.rb +44 -0
- data/lib/jirametrics/grouping_rules.rb +13 -0
- data/lib/jirametrics/hierarchy_table.rb +31 -0
- data/lib/jirametrics/html/aging_work_bar_chart.erb +72 -0
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +52 -0
- data/lib/jirametrics/html/aging_work_table.erb +60 -0
- data/lib/jirametrics/html/collapsible_issues_panel.erb +32 -0
- data/lib/jirametrics/html/cycletime_histogram.erb +41 -0
- data/lib/jirametrics/html/cycletime_scatterplot.erb +103 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +63 -0
- data/lib/jirametrics/html/data_quality_report.erb +126 -0
- data/lib/jirametrics/html/expedited_chart.erb +67 -0
- data/lib/jirametrics/html/hierarchy_table.erb +29 -0
- data/lib/jirametrics/html/index.erb +66 -0
- data/lib/jirametrics/html/sprint_burndown.erb +116 -0
- data/lib/jirametrics/html/story_point_accuracy_chart.erb +57 -0
- data/lib/jirametrics/html/throughput_chart.erb +65 -0
- data/lib/jirametrics/html_report_config.rb +217 -0
- data/lib/jirametrics/issue.rb +521 -0
- data/lib/jirametrics/issue_link.rb +60 -0
- data/lib/jirametrics/json_file_loader.rb +9 -0
- data/lib/jirametrics/project_config.rb +442 -0
- data/lib/jirametrics/rules.rb +34 -0
- data/lib/jirametrics/self_or_issue_dispatcher.rb +15 -0
- data/lib/jirametrics/sprint.rb +43 -0
- data/lib/jirametrics/sprint_burndown.rb +335 -0
- data/lib/jirametrics/sprint_issue_change_data.rb +31 -0
- data/lib/jirametrics/status.rb +26 -0
- data/lib/jirametrics/status_collection.rb +67 -0
- data/lib/jirametrics/story_point_accuracy_chart.rb +139 -0
- data/lib/jirametrics/throughput_chart.rb +91 -0
- data/lib/jirametrics/tree_organizer.rb +96 -0
- data/lib/jirametrics/trend_line_calculator.rb +74 -0
- data/lib/jirametrics.rb +85 -0
- metadata +167 -0
@@ -0,0 +1,278 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class DataQualityReport < ChartBase
|
4
|
+
attr_reader :original_issue_times # For testing purposes only
|
5
|
+
attr_accessor :board_id
|
6
|
+
|
7
|
+
class Entry
|
8
|
+
attr_reader :started, :stopped, :issue, :problems
|
9
|
+
|
10
|
+
def initialize started:, stopped:, issue:
|
11
|
+
@started = started
|
12
|
+
@stopped = stopped
|
13
|
+
@issue = issue
|
14
|
+
@problems = []
|
15
|
+
end
|
16
|
+
|
17
|
+
def report problem_key: nil, detail: nil
|
18
|
+
@problems << [problem_key, detail]
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def initialize original_issue_times
|
23
|
+
super()
|
24
|
+
|
25
|
+
@original_issue_times = original_issue_times
|
26
|
+
|
27
|
+
header_text 'Data Quality Report'
|
28
|
+
description_text <<-HTML
|
29
|
+
<p>
|
30
|
+
We have a tendency to assume that anything we see in a chart is 100% accurate, although that's
|
31
|
+
not always true. To understand the accuracy of the chart, we have to understand how accurate the
|
32
|
+
initial data was and also how much of the original data set was used in the chart. This section
|
33
|
+
will hopefully give you enough information to make that decision.
|
34
|
+
</p>
|
35
|
+
HTML
|
36
|
+
end
|
37
|
+
|
38
|
+
def run
|
39
|
+
initialize_entries
|
40
|
+
|
41
|
+
@entries.each do |entry|
|
42
|
+
board = entry.issue.board
|
43
|
+
backlog_statuses = board.backlog_statuses
|
44
|
+
|
45
|
+
scan_for_completed_issues_without_a_start_time entry: entry
|
46
|
+
scan_for_status_change_after_done entry: entry
|
47
|
+
scan_for_backwards_movement entry: entry, backlog_statuses: backlog_statuses
|
48
|
+
scan_for_issues_not_created_in_a_backlog_status entry: entry, backlog_statuses: backlog_statuses
|
49
|
+
scan_for_stopped_before_started entry: entry
|
50
|
+
scan_for_issues_not_started_with_subtasks_that_have entry: entry
|
51
|
+
scan_for_discarded_data entry: entry
|
52
|
+
end
|
53
|
+
|
54
|
+
scan_for_issues_on_multiple_boards entries: @entries
|
55
|
+
|
56
|
+
entries_with_problems = entries_with_problems()
|
57
|
+
return '' if entries_with_problems.empty?
|
58
|
+
|
59
|
+
wrap_and_render(binding, __FILE__)
|
60
|
+
end
|
61
|
+
|
62
|
+
def problems_for key
|
63
|
+
result = []
|
64
|
+
@entries.each do |entry|
|
65
|
+
entry.problems.each do |problem_key, detail|
|
66
|
+
result << [entry.issue, detail, key] if problem_key == key
|
67
|
+
end
|
68
|
+
end
|
69
|
+
result
|
70
|
+
end
|
71
|
+
|
72
|
+
# Return a format that's easier to assert against
|
73
|
+
def testable_entries
|
74
|
+
@entries.collect { |entry| [entry.started.to_s, entry.stopped.to_s, entry.issue] }
|
75
|
+
end
|
76
|
+
|
77
|
+
def entries_with_problems
|
78
|
+
@entries.reject { |entry| entry.problems.empty? }
|
79
|
+
end
|
80
|
+
|
81
|
+
def category_name_for status_name:, board:
|
82
|
+
board.possible_statuses.find { |status| status.name == status_name }&.category_name
|
83
|
+
end
|
84
|
+
|
85
|
+
def initialize_entries
|
86
|
+
@entries = @issues.collect do |issue|
|
87
|
+
cycletime = issue.board.cycletime
|
88
|
+
started = cycletime.started_time(issue)
|
89
|
+
stopped = cycletime.stopped_time(issue)
|
90
|
+
next if stopped && stopped < time_range.begin
|
91
|
+
next if started && started > time_range.end
|
92
|
+
|
93
|
+
Entry.new started: started, stopped: stopped, issue: issue
|
94
|
+
end.compact
|
95
|
+
|
96
|
+
@entries.sort! do |a, b|
|
97
|
+
a.issue.key =~ /.+-(\d+)$/
|
98
|
+
a_id = $1.to_i
|
99
|
+
|
100
|
+
b.issue.key =~ /.+-(\d+)$/
|
101
|
+
b_id = $1.to_i
|
102
|
+
|
103
|
+
a_id <=> b_id
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def scan_for_completed_issues_without_a_start_time entry:
|
108
|
+
return unless entry.stopped && entry.started.nil?
|
109
|
+
|
110
|
+
status_names = entry.issue.changes.collect do |change|
|
111
|
+
next unless change.status?
|
112
|
+
|
113
|
+
format_status change.value, board: entry.issue.board
|
114
|
+
end.compact
|
115
|
+
|
116
|
+
entry.report(
|
117
|
+
problem_key: :completed_but_not_started,
|
118
|
+
detail: "Status changes: #{status_names.join ' → '}"
|
119
|
+
)
|
120
|
+
end
|
121
|
+
|
122
|
+
def scan_for_status_change_after_done entry:
|
123
|
+
return unless entry.stopped
|
124
|
+
|
125
|
+
changes_after_done = entry.issue.changes.select do |change|
|
126
|
+
change.status? && change.time >= entry.stopped
|
127
|
+
end
|
128
|
+
done_status = changes_after_done.shift.value
|
129
|
+
|
130
|
+
return if changes_after_done.empty?
|
131
|
+
|
132
|
+
board = entry.issue.board
|
133
|
+
problem = "Completed on #{entry.stopped.to_date} with status #{format_status done_status, board: board}."
|
134
|
+
changes_after_done.each do |change|
|
135
|
+
problem << " Changed to #{format_status change.value, board: board} on #{change.time.to_date}."
|
136
|
+
end
|
137
|
+
entry.report(
|
138
|
+
problem_key: :status_changes_after_done,
|
139
|
+
detail: problem
|
140
|
+
)
|
141
|
+
end
|
142
|
+
|
143
|
+
def scan_for_backwards_movement entry:, backlog_statuses:
|
144
|
+
issue = entry.issue
|
145
|
+
|
146
|
+
# Moving backwards through statuses is bad. Moving backwards through status categories is almost always worse.
|
147
|
+
last_index = -1
|
148
|
+
issue.changes.each do |change|
|
149
|
+
next unless change.status?
|
150
|
+
|
151
|
+
board = entry.issue.board
|
152
|
+
index = entry.issue.board.visible_columns.find_index { |column| column.status_ids.include? change.value_id }
|
153
|
+
if index.nil?
|
154
|
+
# If it's a backlog status then ignore it. Not supposed to be visible.
|
155
|
+
next if entry.issue.board.backlog_statuses.include? change.value_id
|
156
|
+
|
157
|
+
detail = "Status #{format_status change.value, board: board} is not on the board"
|
158
|
+
if issue.board.possible_statuses.expand_statuses(change.value).empty?
|
159
|
+
detail = "Status #{format_status change.value, board: board} cannot be found at all. Was it deleted?"
|
160
|
+
end
|
161
|
+
|
162
|
+
# If it's been moved back to backlog then it's on a different report. Ignore it here.
|
163
|
+
detail = nil if backlog_statuses.any? { |s| s.name == change.value }
|
164
|
+
|
165
|
+
entry.report(problem_key: :status_not_on_board, detail: detail) unless detail.nil?
|
166
|
+
elsif change.old_value.nil?
|
167
|
+
# Do nothing
|
168
|
+
elsif index < last_index
|
169
|
+
new_category = category_name_for(status_name: change.value, board: board)
|
170
|
+
old_category = category_name_for(status_name: change.old_value, board: board)
|
171
|
+
|
172
|
+
if new_category == old_category
|
173
|
+
entry.report(
|
174
|
+
problem_key: :backwords_through_statuses,
|
175
|
+
detail: "Moved from #{format_status change.old_value, board: board}" \
|
176
|
+
" to #{format_status change.value, board: board}" \
|
177
|
+
" on #{change.time.to_date}"
|
178
|
+
)
|
179
|
+
else
|
180
|
+
entry.report(
|
181
|
+
problem_key: :backwards_through_status_categories,
|
182
|
+
detail: "Moved from #{format_status change.old_value, board: board}" \
|
183
|
+
" to #{format_status change.value, board: board}" \
|
184
|
+
" on #{change.time.to_date}, " \
|
185
|
+
" crossing from category #{format_status old_category, board: board, is_category: true}" \
|
186
|
+
" to #{format_status new_category, board: board, is_category: true}."
|
187
|
+
)
|
188
|
+
end
|
189
|
+
end
|
190
|
+
last_index = index || -1
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
def scan_for_issues_not_created_in_a_backlog_status entry:, backlog_statuses:
|
195
|
+
return if backlog_statuses.empty?
|
196
|
+
|
197
|
+
creation_change = entry.issue.changes.find { |issue| issue.status? }
|
198
|
+
|
199
|
+
return if backlog_statuses.any? { |status| status.id == creation_change.value_id }
|
200
|
+
|
201
|
+
status_string = backlog_statuses.collect { |s| format_status s.name, board: entry.issue.board }.join(', ')
|
202
|
+
entry.report(
|
203
|
+
problem_key: :created_in_wrong_status,
|
204
|
+
detail: "Created in #{format_status creation_change.value, board: entry.issue.board}, " \
|
205
|
+
"which is not one of the backlog statuses for this board: #{status_string}"
|
206
|
+
)
|
207
|
+
end
|
208
|
+
|
209
|
+
def scan_for_stopped_before_started entry:
|
210
|
+
return unless entry.stopped && entry.started && entry.stopped < entry.started
|
211
|
+
|
212
|
+
entry.report(
|
213
|
+
problem_key: :stopped_before_started,
|
214
|
+
detail: "The stopped time '#{entry.stopped}' is before the started time '#{entry.started}'"
|
215
|
+
)
|
216
|
+
end
|
217
|
+
|
218
|
+
def scan_for_issues_not_started_with_subtasks_that_have entry:
|
219
|
+
return if entry.started
|
220
|
+
|
221
|
+
started_subtasks = []
|
222
|
+
entry.issue.subtasks.each do |subtask|
|
223
|
+
started_subtasks << subtask if subtask.board.cycletime.started_time(subtask)
|
224
|
+
end
|
225
|
+
|
226
|
+
return if started_subtasks.empty?
|
227
|
+
|
228
|
+
subtask_labels = started_subtasks.collect do |subtask|
|
229
|
+
"Started subtask: #{link_to_issue(subtask)} (#{format_status subtask.status.name, board: entry.issue.board}) " \
|
230
|
+
"#{subtask.summary[..50].inspect}"
|
231
|
+
end
|
232
|
+
entry.report(
|
233
|
+
problem_key: :issue_not_started_but_subtasks_have,
|
234
|
+
detail: subtask_labels.join('<br />')
|
235
|
+
)
|
236
|
+
end
|
237
|
+
|
238
|
+
def label_issues number
|
239
|
+
return '1 item' if number == 1
|
240
|
+
|
241
|
+
"#{number} items"
|
242
|
+
end
|
243
|
+
|
244
|
+
def scan_for_discarded_data entry:
|
245
|
+
hash = @original_issue_times[entry.issue]
|
246
|
+
return if hash.nil?
|
247
|
+
|
248
|
+
old_start_time = hash[:started_time]
|
249
|
+
cutoff_time = hash[:cutoff_time]
|
250
|
+
|
251
|
+
old_start_date = old_start_time.to_date
|
252
|
+
cutoff_date = cutoff_time.to_date
|
253
|
+
|
254
|
+
days_ignored = (cutoff_date - old_start_date).to_i + 1
|
255
|
+
message = "Started: #{old_start_date}, Discarded: #{cutoff_date}, Ignored: #{label_days days_ignored}"
|
256
|
+
|
257
|
+
# If days_ignored is zero then we don't really care as it won't affect any of the calculations.
|
258
|
+
return if days_ignored == 1
|
259
|
+
|
260
|
+
entry.report(
|
261
|
+
problem_key: :discarded_changes,
|
262
|
+
detail: message
|
263
|
+
)
|
264
|
+
end
|
265
|
+
|
266
|
+
def scan_for_issues_on_multiple_boards entries:
|
267
|
+
grouped_entries = entries.group_by { |entry| entry.issue.key }
|
268
|
+
grouped_entries.each_value do |entry_list|
|
269
|
+
next if entry_list.size == 1
|
270
|
+
|
271
|
+
board_names = entry_list.collect { |entry| entry.issue.board.name.inspect }
|
272
|
+
entry_list.first.report(
|
273
|
+
problem_key: :issue_on_multiple_boards,
|
274
|
+
detail: "Found on boards: #{board_names.join(', ')}"
|
275
|
+
)
|
276
|
+
end
|
277
|
+
end
|
278
|
+
end
|
@@ -0,0 +1,217 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'jirametrics/chart_base'
|
4
|
+
require 'open3'
|
5
|
+
require 'jirametrics/rules'
|
6
|
+
|
7
|
+
class DependencyChart < ChartBase
|
8
|
+
class LinkRules < Rules
|
9
|
+
attr_accessor :line_color, :label
|
10
|
+
|
11
|
+
def merge_bidirectional keep: 'inward'
|
12
|
+
raise "Keep must be either inward or outward: #{keep}" unless %i[inward outward].include? keep.to_sym
|
13
|
+
|
14
|
+
@merge_bidirectional = keep.to_sym
|
15
|
+
end
|
16
|
+
|
17
|
+
def get_merge_bidirectional # rubocop:disable Naming/AccessorMethodName
|
18
|
+
@merge_bidirectional
|
19
|
+
end
|
20
|
+
|
21
|
+
def use_bidirectional_arrows
|
22
|
+
@use_bidirectional_arrows = true
|
23
|
+
end
|
24
|
+
|
25
|
+
def bidirectional_arrows?
|
26
|
+
@use_bidirectional_arrows
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
class IssueRules < Rules
|
31
|
+
attr_accessor :color, :label
|
32
|
+
end
|
33
|
+
|
34
|
+
def initialize rules_block
|
35
|
+
super()
|
36
|
+
|
37
|
+
header_text 'Dependencies'
|
38
|
+
description_text <<-HTML
|
39
|
+
<p>
|
40
|
+
These are all the "linked issues" as defined in Jira
|
41
|
+
</p>
|
42
|
+
HTML
|
43
|
+
|
44
|
+
@rules_block = rules_block
|
45
|
+
@link_rules_block = ->(link_name, link_rules) {}
|
46
|
+
|
47
|
+
issue_rules do |issue, rules|
|
48
|
+
key = issue.key
|
49
|
+
key = "<S>#{key} </S> " if issue.status.category_name == 'Done'
|
50
|
+
rules.label = "<#{key} [#{issue.type}]<BR/>#{word_wrap issue.summary}>"
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def run
|
55
|
+
instance_eval(&@rules_block) if @rules_block
|
56
|
+
|
57
|
+
dot_graph = build_dot_graph
|
58
|
+
return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if dot_graph.nil?
|
59
|
+
|
60
|
+
svg = execute_graphviz(dot_graph.join("\n"))
|
61
|
+
"<h1>#{@header_text}</h1><div>#{@description_text}</div>#{shrink_svg svg}"
|
62
|
+
end
|
63
|
+
|
64
|
+
def link_rules &block
|
65
|
+
@link_rules_block = block
|
66
|
+
end
|
67
|
+
|
68
|
+
def issue_rules &block
|
69
|
+
@issue_rules_block = block
|
70
|
+
end
|
71
|
+
|
72
|
+
def find_links
|
73
|
+
result = []
|
74
|
+
issues.each do |issue|
|
75
|
+
result += issue.issue_links
|
76
|
+
end
|
77
|
+
result
|
78
|
+
end
|
79
|
+
|
80
|
+
def make_dot_link issue_link:, link_rules:
|
81
|
+
result = String.new
|
82
|
+
result << issue_link.origin.key.inspect
|
83
|
+
result << ' -> '
|
84
|
+
result << issue_link.other_issue.key.inspect
|
85
|
+
result << '['
|
86
|
+
result << 'label=' << (link_rules.label || issue_link.label).inspect
|
87
|
+
result << ',color=' << (link_rules.line_color || 'black').inspect
|
88
|
+
result << ',dir=both' if link_rules.bidirectional_arrows?
|
89
|
+
result << '];'
|
90
|
+
result
|
91
|
+
end
|
92
|
+
|
93
|
+
def make_dot_issue issue:, issue_rules:
|
94
|
+
result = String.new
|
95
|
+
result << issue.key.inspect
|
96
|
+
result << '['
|
97
|
+
label = issue_rules.label || "#{issue.key}|#{issue.type}"
|
98
|
+
label = label.inspect unless label =~ /^<.+>$/
|
99
|
+
result << "label=#{label}"
|
100
|
+
result << ',shape=Mrecord'
|
101
|
+
tooltip = "#{issue.key}: #{issue.summary}"
|
102
|
+
result << ",tooltip=#{tooltip[0..80].inspect}"
|
103
|
+
unless issue_rules.color == :none
|
104
|
+
result << %(,style=filled,fillcolor="#{issue_rules.color || color_for(type: issue.type, shade: :light)}")
|
105
|
+
end
|
106
|
+
result << ']'
|
107
|
+
result
|
108
|
+
end
|
109
|
+
|
110
|
+
def build_dot_graph
|
111
|
+
issue_links = find_links
|
112
|
+
|
113
|
+
visible_issues = {}
|
114
|
+
link_graph = []
|
115
|
+
links_to_ignore = []
|
116
|
+
|
117
|
+
issue_links.each do |link|
|
118
|
+
next if links_to_ignore.include? link
|
119
|
+
|
120
|
+
link_rules = LinkRules.new
|
121
|
+
@link_rules_block.call link, link_rules
|
122
|
+
|
123
|
+
next if link_rules.ignored?
|
124
|
+
|
125
|
+
if link_rules.get_merge_bidirectional
|
126
|
+
opposite = issue_links.find do |l|
|
127
|
+
l.name == link.name && l.origin.key == link.other_issue.key && l.other_issue.key == link.origin.key
|
128
|
+
end
|
129
|
+
if opposite
|
130
|
+
# rubocop:disable Style/GuardClause
|
131
|
+
if link_rules.get_merge_bidirectional.to_sym == link.direction
|
132
|
+
# We keep this one and discard the opposite
|
133
|
+
links_to_ignore << opposite
|
134
|
+
else
|
135
|
+
# We keep the opposite and discard this one
|
136
|
+
next
|
137
|
+
end
|
138
|
+
# rubocop:enable Style/GuardClause
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
link_graph << make_dot_link(issue_link: link, link_rules: link_rules)
|
143
|
+
|
144
|
+
visible_issues[link.origin.key] = link.origin
|
145
|
+
visible_issues[link.other_issue.key] = link.other_issue
|
146
|
+
end
|
147
|
+
|
148
|
+
dot_graph = []
|
149
|
+
dot_graph << 'digraph mygraph {'
|
150
|
+
dot_graph << 'rankdir=LR'
|
151
|
+
|
152
|
+
# Sort the keys so they are proccessed in a deterministic order.
|
153
|
+
visible_issues.values.sort_by(&:key_as_i).each do |issue|
|
154
|
+
rules = IssueRules.new
|
155
|
+
@issue_rules_block.call(issue, rules)
|
156
|
+
dot_graph << make_dot_issue(issue: issue, issue_rules: rules)
|
157
|
+
end
|
158
|
+
|
159
|
+
dot_graph += link_graph
|
160
|
+
dot_graph << '}'
|
161
|
+
|
162
|
+
return nil if visible_issues.empty?
|
163
|
+
dot_graph
|
164
|
+
end
|
165
|
+
|
166
|
+
def execute_graphviz dot_graph
|
167
|
+
Open3.popen3('dot -Tsvg') do |stdin, stdout, _stderr, _wait_thread|
|
168
|
+
stdin.write dot_graph
|
169
|
+
stdin.close
|
170
|
+
return stdout.read
|
171
|
+
end
|
172
|
+
rescue # rubocop:disable Style/RescueStandardError
|
173
|
+
message = "Unable to execute the command 'dot' which is part of graphviz. " \
|
174
|
+
'Ensure that graphviz is installed and that dot is in your path.'
|
175
|
+
puts message
|
176
|
+
message
|
177
|
+
end
|
178
|
+
|
179
|
+
def default_color_for_issue issue
|
180
|
+
{
|
181
|
+
'Story' => '#90EE90',
|
182
|
+
'Task' => '#87CEFA',
|
183
|
+
'Bug' => '#f08080',
|
184
|
+
'Defect' => '#f08080',
|
185
|
+
'Epic' => '#fafad2',
|
186
|
+
'Spike' => '#7fffd4',
|
187
|
+
'Sub-task' => '#dcdcdc'
|
188
|
+
}[issue.type]
|
189
|
+
end
|
190
|
+
|
191
|
+
def shrink_svg svg
|
192
|
+
scale = 0.8
|
193
|
+
svg.sub(/width="([\d.]+)pt" height="([\d.]+)pt"/) do
|
194
|
+
width = $1.to_i * scale
|
195
|
+
height = $2.to_i * scale
|
196
|
+
"width=\"#{width.to_i}pt\" height=\"#{height.to_i}pt\""
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
def word_wrap text, max_width: 50, separator: '<BR/>'
|
201
|
+
text.chomp.lines.collect do |line|
|
202
|
+
line.chomp!
|
203
|
+
|
204
|
+
# The following characters all cause problems when passed to graphviz
|
205
|
+
line.gsub!(/[{<]/, '[')
|
206
|
+
line.gsub!(/[}>]/, ']')
|
207
|
+
line.gsub!(/\s*&\s*/, ' and ')
|
208
|
+
line.gsub!('|', '')
|
209
|
+
|
210
|
+
if line.length > max_width
|
211
|
+
line.gsub(/(.{1,#{max_width}})(\s+|$)/, "\\1#{separator}").strip
|
212
|
+
else
|
213
|
+
line
|
214
|
+
end
|
215
|
+
end.join(separator)
|
216
|
+
end
|
217
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DiscardChangesBefore
|
4
|
+
def discard_changes_before status_becomes: nil, &block
|
5
|
+
if status_becomes
|
6
|
+
status_becomes = [status_becomes] unless status_becomes.is_a? Array
|
7
|
+
|
8
|
+
block = lambda do |issue|
|
9
|
+
trigger_statuses = status_becomes.collect do |status_name|
|
10
|
+
if status_name == :backlog
|
11
|
+
issue.board.backlog_statuses.collect(&:name)
|
12
|
+
else
|
13
|
+
status_name
|
14
|
+
end
|
15
|
+
end.flatten
|
16
|
+
|
17
|
+
time = nil
|
18
|
+
issue.changes.each do |change|
|
19
|
+
time = change.time if change.status? && trigger_statuses.include?(change.value) && change.artificial? == false
|
20
|
+
end
|
21
|
+
time
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
issues_cutoff_times = []
|
26
|
+
issues.each do |issue|
|
27
|
+
cutoff_time = block.call(issue)
|
28
|
+
issues_cutoff_times << [issue, cutoff_time] if cutoff_time
|
29
|
+
end
|
30
|
+
|
31
|
+
discard_changes_before_hook issues_cutoff_times
|
32
|
+
|
33
|
+
issues_cutoff_times.each do |issue, cutoff_time|
|
34
|
+
issue.changes.reject! { |change| change.status? && change.time <= cutoff_time && change.artificial? == false }
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'date'
|
4
|
+
|
5
|
+
class DownloadConfig
|
6
|
+
attr_reader :project_config
|
7
|
+
|
8
|
+
def initialize project_config:, block:
|
9
|
+
@project_config = project_config
|
10
|
+
@block = block
|
11
|
+
@board_ids = []
|
12
|
+
end
|
13
|
+
|
14
|
+
def run
|
15
|
+
instance_eval(&@block)
|
16
|
+
end
|
17
|
+
|
18
|
+
def project_key _key = nil
|
19
|
+
raise 'project, filter, and jql directives are no longer supported. See ' \
|
20
|
+
'https://github.com/mikebowler/jira-export/wiki/Deprecated#project-filter-and-jql-are-no-longer-supported-in-the-download-section'
|
21
|
+
end
|
22
|
+
|
23
|
+
def board_ids *ids
|
24
|
+
deprecated message: 'board_ids in the download block are deprecated. See https://github.com/mikebowler/jira-export/wiki/Deprecated'
|
25
|
+
@board_ids = ids unless ids.empty?
|
26
|
+
@board_ids
|
27
|
+
end
|
28
|
+
|
29
|
+
def filter_name _filter = nil
|
30
|
+
project_key
|
31
|
+
end
|
32
|
+
|
33
|
+
def jql _query = nil
|
34
|
+
project_key
|
35
|
+
end
|
36
|
+
|
37
|
+
def rolling_date_count count = nil
|
38
|
+
@rolling_date_count = count unless count.nil?
|
39
|
+
@rolling_date_count
|
40
|
+
end
|
41
|
+
end
|