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,209 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'random-word'
|
4
|
+
require 'require_all'
|
5
|
+
require_all 'lib'
|
6
|
+
|
7
|
+
def to_time date
|
8
|
+
Time.new date.year, date.month, date.day, rand(0..23), rand(0..59), rand(0..59)
|
9
|
+
end
|
10
|
+
|
11
|
+
class FakeIssue
|
12
|
+
@@issue_number = 1
|
13
|
+
attr_reader :effort, :raw, :worker
|
14
|
+
|
15
|
+
def initialize date:, type:, worker:
|
16
|
+
@raw = {
|
17
|
+
key: "FAKE-#{@@issue_number += 1}",
|
18
|
+
changelog: {
|
19
|
+
histories: []
|
20
|
+
},
|
21
|
+
fields: {
|
22
|
+
created: to_time(date),
|
23
|
+
updated: to_time(date),
|
24
|
+
creator: {
|
25
|
+
displayName: 'George Jetson'
|
26
|
+
},
|
27
|
+
issuetype: {
|
28
|
+
name: type
|
29
|
+
},
|
30
|
+
status: {
|
31
|
+
name: 'To Do',
|
32
|
+
id: 1,
|
33
|
+
statusCategory: {
|
34
|
+
id: 2,
|
35
|
+
name: 'To Do'
|
36
|
+
}
|
37
|
+
},
|
38
|
+
priority: {
|
39
|
+
name: ''
|
40
|
+
},
|
41
|
+
summary: RandomWord.phrases.next.gsub(/_/, ' '),
|
42
|
+
issuelinks: [],
|
43
|
+
fixVersions: []
|
44
|
+
}
|
45
|
+
}
|
46
|
+
|
47
|
+
@workers = [worker]
|
48
|
+
@effort = case type
|
49
|
+
when 'Story'
|
50
|
+
[1, 2, 3, 3, 3, 3, 4, 4, 4, 5, 6].sample
|
51
|
+
else
|
52
|
+
[1, 2, 3].sample
|
53
|
+
end
|
54
|
+
unblock
|
55
|
+
@done = false
|
56
|
+
@last_status = 'To Do'
|
57
|
+
@last_status_id = 1
|
58
|
+
change_status new_status: 'In Progress', new_status_id: 3, date: date
|
59
|
+
end
|
60
|
+
|
61
|
+
def blocked? = @blocked
|
62
|
+
def block = @blocked = true
|
63
|
+
def unblock = @blocked = false
|
64
|
+
|
65
|
+
def key = @raw[:key]
|
66
|
+
|
67
|
+
def do_work date:, effort:
|
68
|
+
raise 'Already done' if done?
|
69
|
+
|
70
|
+
@effort -= effort
|
71
|
+
return unless done?
|
72
|
+
|
73
|
+
change_status new_status: 'Done', new_status_id: 5, date: date
|
74
|
+
# fix_change_timestamps
|
75
|
+
end
|
76
|
+
|
77
|
+
def fix_change_timestamps
|
78
|
+
# since the timestamps have random hours, it's possible for them to be issued out of order. Sort them now
|
79
|
+
changes = @raw[:changelog][:histories]
|
80
|
+
times = [@raw[:fields][:created]] + changes.collect { |change| change[:created] }
|
81
|
+
times.sort!
|
82
|
+
|
83
|
+
@raw[:fields][:created] = times.shift
|
84
|
+
@raw[:fields][:updated] = times[-1]
|
85
|
+
changes.each do |change|
|
86
|
+
change[:created] = times.shift
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def done? = @effort <= 0
|
91
|
+
|
92
|
+
def change_status date:, new_status:, new_status_id:
|
93
|
+
@raw[:changelog][:histories] << {
|
94
|
+
author: {
|
95
|
+
emailAddress: 'george@jetson.com',
|
96
|
+
displayName: 'George Jetson'
|
97
|
+
},
|
98
|
+
created: to_time(date),
|
99
|
+
items: [
|
100
|
+
{
|
101
|
+
field: 'status',
|
102
|
+
fieldtype: 'jira',
|
103
|
+
fieldId: 'status',
|
104
|
+
from: @last_status_id,
|
105
|
+
fromString: @last_status,
|
106
|
+
to: new_status_id,
|
107
|
+
toString: new_status
|
108
|
+
}
|
109
|
+
]
|
110
|
+
}
|
111
|
+
|
112
|
+
@last_status = new_status
|
113
|
+
@last_status_id = new_status_id
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
class Worker
|
118
|
+
attr_accessor :issue
|
119
|
+
end
|
120
|
+
|
121
|
+
class Generator
|
122
|
+
def initialize
|
123
|
+
@random = Random.new
|
124
|
+
@file_prefix = 'fake'
|
125
|
+
@target_path = 'target/'
|
126
|
+
|
127
|
+
# @probability_work_will_be_pushed = 20
|
128
|
+
@probability_unblocked_work_becomes_blocked = 20
|
129
|
+
@probability_blocked_work_becomes_unblocked = 20
|
130
|
+
@date_range = (Date.today - 500)..Date.today
|
131
|
+
@issues = []
|
132
|
+
@workers = []
|
133
|
+
5.times { @workers << Worker.new }
|
134
|
+
end
|
135
|
+
|
136
|
+
def run
|
137
|
+
remove_old_files
|
138
|
+
@date_range.each_with_index do |date, day|
|
139
|
+
yield date, day if block_given?
|
140
|
+
process_date(date, day) if (1..5).include? date.wday # Weekday
|
141
|
+
end
|
142
|
+
|
143
|
+
@issues.each do |issue|
|
144
|
+
issue.fix_change_timestamps
|
145
|
+
File.open "target/fake_issues/#{issue.key}.json", 'w' do |file|
|
146
|
+
file.puts JSON.pretty_generate(issue.raw)
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
File.write 'target/fake_meta.json', JSON.pretty_generate({
|
151
|
+
time_start: (@date_range.end - 90).to_time,
|
152
|
+
time_end: @date_range.end.to_time,
|
153
|
+
'no-download': true
|
154
|
+
})
|
155
|
+
puts "Created #{@issues.size} fake issues"
|
156
|
+
end
|
157
|
+
|
158
|
+
def remove_old_files
|
159
|
+
path = "#{@target_path}#{@file_prefix}_issues"
|
160
|
+
Dir.foreach path do |file|
|
161
|
+
next unless file =~ /-\d+\.json$/
|
162
|
+
|
163
|
+
filename = "#{path}/#{file}"
|
164
|
+
File.unlink filename
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
def lucky? probability
|
169
|
+
@random.rand(1..100) <= probability
|
170
|
+
end
|
171
|
+
|
172
|
+
def next_issue_for worker:, date:, type:
|
173
|
+
# First look for something I already started
|
174
|
+
issue = @issues.find { |i| i.worker == worker && !i.done? && !i.blocked? }
|
175
|
+
|
176
|
+
# Then look for something that someone else started
|
177
|
+
issue = @issues.find { |i| i.worker != worker && !i.done? && !i.blocked? } if issue.nil? && lucky?(40)
|
178
|
+
|
179
|
+
# Then start new work
|
180
|
+
issue = FakeIssue.new(date: date, type: type, worker: worker) if issue.nil?
|
181
|
+
|
182
|
+
issue
|
183
|
+
end
|
184
|
+
|
185
|
+
def process_date date, _simulation_day
|
186
|
+
@issues.each do |issue|
|
187
|
+
if issue.blocked?
|
188
|
+
issue.unblock if lucky? @probability_blocked_work_becomes_unblocked
|
189
|
+
elsif lucky? @probability_unblocked_work_becomes_blocked
|
190
|
+
issue.block
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
@workers.each do |worker|
|
195
|
+
worker_capacity = [0, 1, 1, 1, 2].sample
|
196
|
+
if worker.issue.nil? || worker.issue.done?
|
197
|
+
type = lucky?(89) ? 'Story' : 'Bug'
|
198
|
+
worker.issue = next_issue_for worker: worker, date: date, type: type
|
199
|
+
@issues << worker.issue
|
200
|
+
end
|
201
|
+
|
202
|
+
worker.issue = next_issue_for worker: worker, date: date, type: type if worker.issue.blocked?
|
203
|
+
worker.issue.do_work date: date, effort: worker_capacity
|
204
|
+
worker.issue = nil if worker.issue.done?
|
205
|
+
end
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
Generator.new.run if __FILE__ == $PROGRAM_NAME
|
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'require_all'
|
4
|
+
require_all 'lib'
|
5
|
+
|
6
|
+
class InfoDumper
|
7
|
+
def initialize
|
8
|
+
@target_dir = 'target/'
|
9
|
+
end
|
10
|
+
|
11
|
+
def run key
|
12
|
+
find_file_prefixes.each do |prefix|
|
13
|
+
path = "#{@target_dir}#{prefix}_issues/#{key}.json"
|
14
|
+
path = "#{@target_dir}#{prefix}_issues"
|
15
|
+
Dir.foreach path do |file|
|
16
|
+
if file =~ /^#{key}.+\.json$/
|
17
|
+
issue = Issue.new raw: JSON.parse(File.read(File.join(path, file))), board: nil
|
18
|
+
dump issue
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def find_file_prefixes
|
25
|
+
prefixes = []
|
26
|
+
Dir.foreach @target_dir do |file|
|
27
|
+
prefixes << $1 if file =~ /^(.+)_issues$/
|
28
|
+
end
|
29
|
+
prefixes
|
30
|
+
end
|
31
|
+
|
32
|
+
def dump issue
|
33
|
+
puts "#{issue.key} (#{issue.type}): #{compact_text issue.summary, 200}"
|
34
|
+
|
35
|
+
assignee = issue.raw['fields']['assignee']
|
36
|
+
puts " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>" unless assignee.nil?
|
37
|
+
|
38
|
+
issue.raw['fields']['issuelinks'].each do |link|
|
39
|
+
puts " [link] #{link['type']['outward']} #{link['outwardIssue']['key']}" if link['outwardIssue']
|
40
|
+
puts " [link] #{link['type']['inward']} #{link['inwardIssue']['key']}" if link['inwardIssue']
|
41
|
+
end
|
42
|
+
issue.changes.each do |change|
|
43
|
+
value = change.value
|
44
|
+
old_value = change.old_value
|
45
|
+
|
46
|
+
# Description fields get pretty verbose so reduce the clutter
|
47
|
+
if change.field == 'description' || change.field == 'summary'
|
48
|
+
value = compact_text value
|
49
|
+
old_value = compact_text old_value
|
50
|
+
end
|
51
|
+
|
52
|
+
author = change.author
|
53
|
+
author = "(#{author})" if author
|
54
|
+
message = " [change] #{change.time} [#{change.field}] "
|
55
|
+
message << "#{compact_text(old_value).inspect} -> " unless old_value.nil? || old_value.empty?
|
56
|
+
message << compact_text(value).inspect
|
57
|
+
message << " #{author}" if author
|
58
|
+
message << ' <<artificial entry>>' if change.artificial?
|
59
|
+
puts message
|
60
|
+
end
|
61
|
+
puts ''
|
62
|
+
end
|
63
|
+
|
64
|
+
def compact_text text, max = 60
|
65
|
+
return nil if text.nil?
|
66
|
+
|
67
|
+
text = text.gsub(/\s+/, ' ').strip
|
68
|
+
text = "#{text[0..max]}..." if text.length > max
|
69
|
+
text
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
if __FILE__ == $PROGRAM_NAME
|
74
|
+
ARGV.each do |key|
|
75
|
+
InfoDumper.new.run key
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,127 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'fileutils'
|
4
|
+
|
5
|
+
class Object
|
6
|
+
def deprecated message:, date: nil
|
7
|
+
text = String.new
|
8
|
+
text << 'Deprecated'
|
9
|
+
text << "(#{date})"
|
10
|
+
text << ': '
|
11
|
+
text << message
|
12
|
+
text << "\n-> Called from #{caller[0]}"
|
13
|
+
warn text
|
14
|
+
end
|
15
|
+
|
16
|
+
def assert_jira_behaviour_true condition, &block
|
17
|
+
block.call if ENV['RACK_ENV'] == 'test' # Always expand the block if we're running in a test
|
18
|
+
failed_jira_behaviour(block) unless condition
|
19
|
+
end
|
20
|
+
|
21
|
+
def assert_jira_behaviour_false condition, &block
|
22
|
+
block.call if ENV['RACK_ENV'] == 'test' # Always expand the block if we're running in a test
|
23
|
+
failed_jira_behaviour(block) if condition
|
24
|
+
end
|
25
|
+
|
26
|
+
def failed_jira_behaviour block
|
27
|
+
text = String.new
|
28
|
+
text << 'Jira not doing what we expected. Results may be incorrect: '
|
29
|
+
text << block.call
|
30
|
+
text << "\n-> Called from #{caller[1]}"
|
31
|
+
warn text
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
class Exporter
|
36
|
+
attr_reader :project_configs
|
37
|
+
|
38
|
+
def self.configure &block
|
39
|
+
exporter = Exporter.new
|
40
|
+
exporter.instance_eval(&block)
|
41
|
+
@@instance = exporter
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.instance = @@instance
|
45
|
+
|
46
|
+
def initialize
|
47
|
+
@project_configs = []
|
48
|
+
@timezone_offset = '+00:00'
|
49
|
+
@target_path = '.'
|
50
|
+
@holiday_dates = []
|
51
|
+
@downloading = false
|
52
|
+
end
|
53
|
+
|
54
|
+
def export
|
55
|
+
@project_configs.each do |project|
|
56
|
+
project.evaluate_next_level
|
57
|
+
project.run
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def download
|
62
|
+
@downloading = true
|
63
|
+
logfile_name = 'downloader.log'
|
64
|
+
File.open logfile_name, 'w' do |logfile|
|
65
|
+
@project_configs.each do |project|
|
66
|
+
project.evaluate_next_level
|
67
|
+
next if project.aggregated_project?
|
68
|
+
|
69
|
+
project.download_config.run
|
70
|
+
downloader = Downloader.new(download_config: project.download_config)
|
71
|
+
downloader.logfile = logfile
|
72
|
+
downloader.logfile_name = logfile_name
|
73
|
+
downloader.run
|
74
|
+
end
|
75
|
+
end
|
76
|
+
puts "Full output from downloader in #{logfile_name}"
|
77
|
+
end
|
78
|
+
|
79
|
+
def downloading?
|
80
|
+
@downloading
|
81
|
+
end
|
82
|
+
|
83
|
+
def project name: nil, &block
|
84
|
+
raise 'target_path was never set!' if @target_path.nil?
|
85
|
+
raise 'jira_config not set' if @jira_config.nil?
|
86
|
+
|
87
|
+
@project_configs << ProjectConfig.new(
|
88
|
+
exporter: self, target_path: @target_path, jira_config: @jira_config, block: block, name: name
|
89
|
+
)
|
90
|
+
end
|
91
|
+
|
92
|
+
def xproject *args; end
|
93
|
+
|
94
|
+
def target_path path = nil
|
95
|
+
unless path.nil?
|
96
|
+
@target_path = path
|
97
|
+
@target_path += File::SEPARATOR unless @target_path.end_with? File::SEPARATOR
|
98
|
+
FileUtils.mkdir_p @target_path
|
99
|
+
end
|
100
|
+
@target_path
|
101
|
+
end
|
102
|
+
|
103
|
+
def jira_config filename = nil
|
104
|
+
@jira_config = JsonFileLoader.new.load(filename) unless filename.nil?
|
105
|
+
@jira_config
|
106
|
+
end
|
107
|
+
|
108
|
+
def timezone_offset offset = nil
|
109
|
+
@timezone_offset = offset unless offset.nil?
|
110
|
+
@timezone_offset
|
111
|
+
end
|
112
|
+
|
113
|
+
def holiday_dates *args
|
114
|
+
unless args.empty?
|
115
|
+
dates = []
|
116
|
+
args.each do |arg|
|
117
|
+
if arg =~ /^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/
|
118
|
+
Date.parse($1).upto(Date.parse($2)).each { |date| dates << date }
|
119
|
+
else
|
120
|
+
dates << Date.parse(arg)
|
121
|
+
end
|
122
|
+
end
|
123
|
+
@holiday_dates = dates
|
124
|
+
end
|
125
|
+
@holiday_dates
|
126
|
+
end
|
127
|
+
end
|
@@ -0,0 +1,119 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'csv'
|
4
|
+
|
5
|
+
class FileConfig
|
6
|
+
attr_reader :project_config, :issues
|
7
|
+
|
8
|
+
def initialize project_config:, block:
|
9
|
+
@project_config = project_config
|
10
|
+
@block = block
|
11
|
+
@columns = nil
|
12
|
+
end
|
13
|
+
|
14
|
+
def run
|
15
|
+
@issues = project_config.issues.dup
|
16
|
+
instance_eval(&@block)
|
17
|
+
|
18
|
+
if @columns
|
19
|
+
all_lines = prepare_grid
|
20
|
+
|
21
|
+
File.open(output_filename, 'w') do |file|
|
22
|
+
all_lines.each do |output_line|
|
23
|
+
file.puts CSV.generate_line(output_line)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
elsif @html_report
|
27
|
+
@html_report.run
|
28
|
+
else
|
29
|
+
raise 'Must specify one of "columns" or "html_report"'
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def prepare_grid
|
34
|
+
@columns.run
|
35
|
+
|
36
|
+
all_lines = issues.collect do |issue|
|
37
|
+
line = []
|
38
|
+
@columns.columns.each do |type, _name, block|
|
39
|
+
# Invoke the block that will retrieve the result from Issue
|
40
|
+
result = instance_exec(issue, &block)
|
41
|
+
# Convert that result to the appropriate type
|
42
|
+
line << __send__(:"to_#{type}", result)
|
43
|
+
end
|
44
|
+
line
|
45
|
+
end
|
46
|
+
|
47
|
+
all_lines = all_lines.select(&@only_use_row_if) if @only_use_row_if
|
48
|
+
all_lines = sort_output(all_lines)
|
49
|
+
|
50
|
+
if @columns.write_headers
|
51
|
+
line = @columns.columns.collect { |_type, label, _proc| label }
|
52
|
+
all_lines.insert 0, line
|
53
|
+
end
|
54
|
+
|
55
|
+
all_lines
|
56
|
+
end
|
57
|
+
|
58
|
+
def output_filename
|
59
|
+
segments = []
|
60
|
+
segments << project_config.target_path
|
61
|
+
segments << project_config.file_prefix
|
62
|
+
segments << (@file_suffix || "-#{Date.today}.csv")
|
63
|
+
segments.join
|
64
|
+
end
|
65
|
+
|
66
|
+
# We'll probably make sorting configurable at some point but for now it's hard coded for our
|
67
|
+
# most common usecase - the Team Dashboard from FocusedObjective.com. The rule for that one
|
68
|
+
# is that all empty values in the first column should be at the bottom.
|
69
|
+
def sort_output all_lines
|
70
|
+
all_lines.sort do |a, b|
|
71
|
+
if a[0].nil?
|
72
|
+
1
|
73
|
+
elsif b[0].nil?
|
74
|
+
-1
|
75
|
+
else
|
76
|
+
a[0] <=> b[0]
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def columns &block
|
82
|
+
assert_only_one_filetype_config_set
|
83
|
+
@columns = ColumnsConfig.new file_config: self, block: block
|
84
|
+
end
|
85
|
+
|
86
|
+
def html_report &block
|
87
|
+
assert_only_one_filetype_config_set
|
88
|
+
@html_report = HtmlReportConfig.new file_config: self, block: block
|
89
|
+
end
|
90
|
+
|
91
|
+
def assert_only_one_filetype_config_set
|
92
|
+
raise 'Can only have one columns or html_report declaration inside a file' if @columns || @html_report
|
93
|
+
end
|
94
|
+
|
95
|
+
def only_use_row_if &block
|
96
|
+
@only_use_row_if = block
|
97
|
+
end
|
98
|
+
|
99
|
+
def to_date object
|
100
|
+
to_datetime(object)&.to_date
|
101
|
+
end
|
102
|
+
|
103
|
+
def to_datetime object
|
104
|
+
return nil if object.nil?
|
105
|
+
|
106
|
+
object = object.to_datetime
|
107
|
+
object = object.new_offset(@timezone_offset) if @timezone_offset
|
108
|
+
object
|
109
|
+
end
|
110
|
+
|
111
|
+
def to_string object
|
112
|
+
object.to_s
|
113
|
+
end
|
114
|
+
|
115
|
+
def file_suffix suffix = nil
|
116
|
+
@file_suffix = suffix unless suffix.nil?
|
117
|
+
@file_suffix
|
118
|
+
end
|
119
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class FixVersion
|
4
|
+
attr_reader :raw
|
5
|
+
|
6
|
+
def initialize raw
|
7
|
+
@raw = raw
|
8
|
+
end
|
9
|
+
|
10
|
+
def name
|
11
|
+
@raw['name']
|
12
|
+
end
|
13
|
+
|
14
|
+
def id
|
15
|
+
@raw['id'].to_i
|
16
|
+
end
|
17
|
+
|
18
|
+
def released?
|
19
|
+
@raw['released']
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'jirametrics/rules'
|
4
|
+
require 'jirametrics/grouping_rules'
|
5
|
+
|
6
|
+
module GroupableIssueChart
|
7
|
+
def init_configuration_block user_provided_block, &default_block
|
8
|
+
# The user provided a block but it's using the old deprecated style
|
9
|
+
if user_provided_block && user_provided_block.arity == 1
|
10
|
+
deprecated date: '2022-10-02', message: "#{self.class}: Use the new grouping_rules syntax"
|
11
|
+
grouping_rules do |issue, rules|
|
12
|
+
rules.label, rules.color = user_provided_block.call(issue)
|
13
|
+
end
|
14
|
+
return
|
15
|
+
end
|
16
|
+
|
17
|
+
if user_provided_block
|
18
|
+
instance_eval(&user_provided_block)
|
19
|
+
return if @group_by_block
|
20
|
+
end
|
21
|
+
|
22
|
+
instance_eval(&default_block)
|
23
|
+
end
|
24
|
+
|
25
|
+
def grouping_rules &block
|
26
|
+
@group_by_block = block
|
27
|
+
end
|
28
|
+
|
29
|
+
def group_issues completed_issues
|
30
|
+
result = {}
|
31
|
+
completed_issues.each do |issue|
|
32
|
+
rules = GroupingRules.new
|
33
|
+
@group_by_block.call(issue, rules)
|
34
|
+
next if rules.ignored?
|
35
|
+
|
36
|
+
(result[rules] ||= []) << issue
|
37
|
+
end
|
38
|
+
|
39
|
+
result.each_key do |rules|
|
40
|
+
rules.color = random_color if rules.color.nil?
|
41
|
+
end
|
42
|
+
result
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'jirametrics/chart_base'
|
4
|
+
|
5
|
+
class HierarchyTable < ChartBase
|
6
|
+
def initialize block = nil
|
7
|
+
super()
|
8
|
+
|
9
|
+
header_text 'Hierarchy Table'
|
10
|
+
description_text <<-HTML
|
11
|
+
<p>content goes here</p>
|
12
|
+
HTML
|
13
|
+
|
14
|
+
instance_eval(&block) if block
|
15
|
+
end
|
16
|
+
|
17
|
+
def run
|
18
|
+
tree_organizer = TreeOrganizer.new issues: @issues
|
19
|
+
unless tree_organizer.cyclical_links.empty?
|
20
|
+
message = String.new
|
21
|
+
message << '<p>Found cyclical links in the parent hierarchy. This is an error and should be '
|
22
|
+
message << 'fixed.</p><ul>'
|
23
|
+
tree_organizer.cyclical_links.each do |link|
|
24
|
+
message << '<li>' << link.join(' > ') << '</ul>'
|
25
|
+
end
|
26
|
+
message << '</ul>'
|
27
|
+
@description_text += message
|
28
|
+
end
|
29
|
+
wrap_and_render(binding, __FILE__)
|
30
|
+
end
|
31
|
+
end
|