jirametrics 1.0.0
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 +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
|