jirametrics 2.13 → 2.22

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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aging_work_bar_chart.rb +176 -134
  3. data/lib/jirametrics/anonymizer.rb +8 -6
  4. data/lib/jirametrics/atlassian_document_format.rb +8 -4
  5. data/lib/jirametrics/bar_chart_range.rb +17 -0
  6. data/lib/jirametrics/board.rb +4 -0
  7. data/lib/jirametrics/board_config.rb +4 -1
  8. data/lib/jirametrics/change_item.rb +11 -4
  9. data/lib/jirametrics/chart_base.rb +36 -2
  10. data/lib/jirametrics/cycletime_config.rb +22 -4
  11. data/lib/jirametrics/cycletime_histogram.rb +3 -1
  12. data/lib/jirametrics/cycletime_scatterplot.rb +36 -17
  13. data/lib/jirametrics/daily_view.rb +49 -42
  14. data/lib/jirametrics/daily_wip_by_age_chart.rb +3 -4
  15. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +13 -3
  16. data/lib/jirametrics/daily_wip_chart.rb +1 -1
  17. data/lib/jirametrics/data_quality_report.rb +8 -3
  18. data/lib/jirametrics/dependency_chart.rb +4 -1
  19. data/lib/jirametrics/downloader.rb +34 -99
  20. data/lib/jirametrics/downloader_for_cloud.rb +202 -0
  21. data/lib/jirametrics/downloader_for_data_center.rb +94 -0
  22. data/lib/jirametrics/examples/standard_project.rb +9 -9
  23. data/lib/jirametrics/expedited_chart.rb +1 -1
  24. data/lib/jirametrics/exporter.rb +12 -5
  25. data/lib/jirametrics/file_system.rb +24 -1
  26. data/lib/jirametrics/fix_version.rb +13 -0
  27. data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
  28. data/lib/jirametrics/groupable_issue_chart.rb +7 -1
  29. data/lib/jirametrics/html/aging_work_bar_chart.erb +2 -1
  30. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +3 -1
  31. data/lib/jirametrics/html/aging_work_table.erb +2 -0
  32. data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
  33. data/lib/jirametrics/html/cycletime_histogram.erb +4 -2
  34. data/lib/jirametrics/html/cycletime_scatterplot.erb +6 -6
  35. data/lib/jirametrics/html/daily_wip_chart.erb +2 -0
  36. data/lib/jirametrics/html/estimate_accuracy_chart.erb +2 -0
  37. data/lib/jirametrics/html/expedited_chart.erb +3 -1
  38. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +2 -0
  39. data/lib/jirametrics/html/index.css +21 -9
  40. data/lib/jirametrics/html/index.erb +3 -35
  41. data/lib/jirametrics/html/index.js +114 -0
  42. data/lib/jirametrics/html/sprint_burndown.erb +11 -3
  43. data/lib/jirametrics/html/throughput_chart.erb +2 -2
  44. data/lib/jirametrics/html_generator.rb +31 -0
  45. data/lib/jirametrics/html_report_config.rb +8 -25
  46. data/lib/jirametrics/issue.rb +127 -22
  47. data/lib/jirametrics/jira_gateway.rb +55 -17
  48. data/lib/jirametrics/project_config.rb +42 -5
  49. data/lib/jirametrics/raw_javascript.rb +13 -0
  50. data/lib/jirametrics/settings.json +3 -1
  51. data/lib/jirametrics/sprint.rb +12 -0
  52. data/lib/jirametrics/sprint_burndown.rb +6 -2
  53. data/lib/jirametrics/stitcher.rb +75 -0
  54. data/lib/jirametrics.rb +26 -70
  55. metadata +10 -3
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DownloaderForCloud < Downloader
4
+ def jira_instance_type
5
+ 'Jira Cloud'
6
+ end
7
+
8
+ def search_for_issues jql:, board_id:, path:
9
+ log " JQL: #{jql}"
10
+ escaped_jql = CGI.escape jql
11
+
12
+ hash = {}
13
+ max_results = 5_000 # The maximum allowed by Jira
14
+ next_page_token = nil
15
+ issue_count = 0
16
+
17
+ loop do
18
+ relative_url = +''
19
+ relative_url << '/rest/api/3/search/jql'
20
+ relative_url << "?jql=#{escaped_jql}&maxResults=#{max_results}"
21
+ relative_url << "&nextPageToken=#{next_page_token}" if next_page_token
22
+ relative_url << '&fields=updated'
23
+
24
+ json = @jira_gateway.call_url relative_url: relative_url
25
+ next_page_token = json['nextPageToken']
26
+
27
+ json['issues'].each do |i|
28
+ key = i['key']
29
+ data = DownloadIssueData.new key: key
30
+ data.key = key
31
+ data.last_modified = Time.parse i['fields']['updated']
32
+ data.found_in_primary_query = true
33
+ data.cache_path = File.join(path, "#{key}-#{board_id}.json")
34
+ data.up_to_date = last_modified(filename: data.cache_path) == data.last_modified
35
+ hash[key] = data
36
+ issue_count += 1
37
+ end
38
+
39
+ message = " Found #{issue_count} issues"
40
+ log message, both: true
41
+
42
+ break unless next_page_token
43
+ end
44
+ hash
45
+ end
46
+
47
+ def bulk_fetch_issues issue_datas:, board:, in_initial_query:
48
+ # We used to use the expand option to pull in the changelog directly. Unfortunately
49
+ # that only returns the "recent" changes, not all of them. So now we get the issue
50
+ # without changes and then make a second call for that changes. Then we insert it
51
+ # into the raw issue as if it had been there all along.
52
+ log " Downloading #{issue_datas.size} issues", both: true
53
+ payload = {
54
+ 'fields' => ['*all'],
55
+ 'issueIdsOrKeys' => issue_datas.collect(&:key)
56
+ }
57
+ response = @jira_gateway.post_request(
58
+ relative_url: '/rest/api/3/issue/bulkfetch',
59
+ payload: JSON.generate(payload)
60
+ )
61
+
62
+ attach_changelog_to_issues issue_datas: issue_datas, issue_jsons: response['issues']
63
+
64
+ response['issues'].each do |issue_json|
65
+ issue_json['exporter'] = {
66
+ 'in_initial_query' => in_initial_query
67
+ }
68
+ issue = Issue.new(raw: issue_json, board: board)
69
+ data = issue_datas.find { |d| d.key == issue.key }
70
+ data.up_to_date = true
71
+ data.last_modified = issue.updated
72
+ data.issue = issue
73
+ end
74
+
75
+ issue_datas
76
+ end
77
+
78
+ def attach_changelog_to_issues issue_datas:, issue_jsons:
79
+ max_results = 10_000 # The max jira accepts is 10K
80
+ payload = {
81
+ 'issueIdsOrKeys' => issue_datas.collect(&:key),
82
+ 'maxResults' => max_results
83
+ }
84
+ loop do
85
+ response = @jira_gateway.post_request(
86
+ relative_url: '/rest/api/3/changelog/bulkfetch',
87
+ payload: JSON.generate(payload)
88
+ )
89
+
90
+ response['issueChangeLogs'].each do |issue_change_log|
91
+ issue_id = issue_change_log['issueId']
92
+ json = issue_jsons.find { |json| json['id'] == issue_id }
93
+
94
+ unless json['changelog']
95
+ # If this is our first time in, there won't be a changelog section
96
+ json['changelog'] = {
97
+ 'startAt' => 0,
98
+ 'maxResults' => max_results,
99
+ 'total' => 0,
100
+ 'histories' => []
101
+ }
102
+ end
103
+
104
+ new_changes = issue_change_log['changeHistories']
105
+ json['changelog']['total'] += new_changes.size
106
+ json['changelog']['histories'] += new_changes
107
+ end
108
+
109
+ next_page_token = response['nextPageToken']
110
+ payload['nextPageToken'] = next_page_token
111
+ break if next_page_token.nil?
112
+ end
113
+ end
114
+
115
+ def download_issues board:
116
+ log " Downloading primary issues for board #{board.id} from #{jira_instance_type}", both: true
117
+ path = File.join(@target_path, "#{file_prefix}_issues/")
118
+ unless @file_system.dir_exist?(path)
119
+ log " Creating path #{path}"
120
+ @file_system.mkdir(path)
121
+ end
122
+
123
+ filter_id = @board_id_to_filter_id[board.id]
124
+ jql = make_jql(filter_id: filter_id)
125
+ intercept_jql = @download_config.project_config.settings['intercept_jql']
126
+ jql = intercept_jql.call jql if intercept_jql
127
+
128
+ issue_data_hash = search_for_issues jql: jql, board_id: board.id, path: path
129
+
130
+ loop do
131
+ related_issue_keys = Set.new
132
+ issue_data_hash
133
+ .values
134
+ .reject { |data| data.up_to_date }
135
+ .each_slice(100) do |slice|
136
+ slice = bulk_fetch_issues(
137
+ issue_datas: slice, board: board, in_initial_query: true
138
+ )
139
+ slice.each do |data|
140
+ @file_system.save_json(
141
+ json: data.issue.raw, filename: data.cache_path
142
+ )
143
+ # Set the timestamp on the file to match the updated one so that we don't have
144
+ # to parse the file just to find the timestamp
145
+ @file_system.utime time: data.issue.updated, file: data.cache_path
146
+
147
+ issue = data.issue
148
+ next unless issue
149
+
150
+ parent_key = issue.parent_key(project_config: @download_config.project_config)
151
+ related_issue_keys << parent_key if parent_key
152
+
153
+ # Sub-tasks
154
+ issue.raw['fields']['subtasks']&.each do |raw_subtask|
155
+ related_issue_keys << raw_subtask['key']
156
+ end
157
+ end
158
+ end
159
+
160
+ # Remove all the ones we already downloaded
161
+ related_issue_keys.reject! { |key| issue_data_hash[key] }
162
+
163
+ related_issue_keys.each do |key|
164
+ data = DownloadIssueData.new key: key
165
+ data.found_in_primary_query = false
166
+ data.up_to_date = false
167
+ data.cache_path = File.join(path, "#{key}-#{board.id}.json")
168
+ issue_data_hash[key] = data
169
+ end
170
+ break if related_issue_keys.empty?
171
+
172
+ log " Downloading linked issues for board #{board.id}", both: true
173
+ end
174
+
175
+ delete_issues_from_cache_that_are_not_in_server(
176
+ issue_data_hash: issue_data_hash, path: path
177
+ )
178
+ end
179
+
180
+ def delete_issues_from_cache_that_are_not_in_server issue_data_hash:, path:
181
+ # The gotcha with deleted issues is that they just stop being returned in queries
182
+ # and we have no way to know that they should be removed from our local cache.
183
+ # With the new approach, we ask for every issue that Jira knows about (within
184
+ # the parameters of the query) and then delete anything that's in our local cache
185
+ # but wasn't returned.
186
+ @file_system.foreach path do |file|
187
+ next if file.start_with? '.'
188
+ unless /^(?<key>\w+-\d+)-\d+\.json$/ =~ file
189
+ raise "Unexpected filename in #{path}: #{file}"
190
+ end
191
+ next if issue_data_hash[key] # Still in Jira
192
+
193
+ file_to_delete = File.join(path, file)
194
+ log " Removing #{file_to_delete} from local cache"
195
+ file_system.unlink file_to_delete
196
+ end
197
+ end
198
+
199
+ def last_modified filename:
200
+ File.mtime(filename) if File.exist?(filename)
201
+ end
202
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DownloaderForDataCenter < Downloader
4
+ def jira_instance_type
5
+ 'Jira DataCenter'
6
+ end
7
+
8
+ def download_issues board:
9
+ log " Downloading primary issues for board #{board.id}", both: true
10
+ path = File.join(@target_path, "#{file_prefix}_issues/")
11
+ unless Dir.exist?(path)
12
+ log " Creating path #{path}"
13
+ Dir.mkdir(path)
14
+ end
15
+
16
+ filter_id = board_id_to_filter_id[board.id]
17
+ jql = make_jql(filter_id: filter_id)
18
+ jira_search_by_jql(jql: jql, initial_query: true, board: board, path: path)
19
+
20
+ log " Downloading linked issues for board #{board.id}", both: true
21
+ loop do
22
+ @issue_keys_pending_download.reject! { |key| @issue_keys_downloaded_in_current_run.include? key }
23
+ break if @issue_keys_pending_download.empty?
24
+
25
+ keys_to_request = @issue_keys_pending_download[0..99]
26
+ @issue_keys_pending_download.reject! { |key| keys_to_request.include? key }
27
+ jql = "key in (#{keys_to_request.join(', ')})"
28
+ jira_search_by_jql(jql: jql, initial_query: false, board: board, path: path)
29
+ end
30
+ end
31
+
32
+ def jira_search_by_jql jql:, initial_query:, board:, path:
33
+ intercept_jql = @download_config.project_config.settings['intercept_jql']
34
+ jql = intercept_jql.call jql if intercept_jql
35
+
36
+ log " JQL: #{jql}"
37
+ escaped_jql = CGI.escape jql
38
+
39
+ max_results = 100
40
+ start_at = 0
41
+ total = 1
42
+ while start_at < total
43
+ json = @jira_gateway.call_url relative_url: '/rest/api/2/search' \
44
+ "?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
45
+
46
+ json['issues'].each do |issue_json|
47
+ issue_json['exporter'] = {
48
+ 'in_initial_query' => initial_query
49
+ }
50
+ identify_other_issues_to_be_downloaded raw_issue: issue_json, board: board
51
+ file = "#{issue_json['key']}-#{board.id}.json"
52
+
53
+ @file_system.save_json(json: issue_json, filename: File.join(path, file))
54
+ end
55
+
56
+ total = json['total'].to_i
57
+ max_results = json['maxResults']
58
+
59
+ message = " Downloaded #{start_at + 1}-#{[start_at + max_results, total].min} of #{total} issues to #{path} "
60
+ log message, both: true
61
+
62
+ start_at += json['issues'].size
63
+ end
64
+ end
65
+
66
+ def make_jql filter_id:, today: Date.today
67
+ segments = []
68
+ segments << "filter=#{filter_id}"
69
+
70
+ start_date = @download_config.start_date today: today
71
+
72
+ if start_date
73
+ @download_date_range = start_date..today.to_date
74
+
75
+ # For an incremental download, we want to query from the end of the previous one, not from the
76
+ # beginning of the full range.
77
+ @start_date_in_query = metadata['date_end'] || @download_date_range.begin
78
+ log " Incremental download only. Pulling from #{@start_date_in_query}", both: true if metadata['date_end']
79
+
80
+ # Catch-all to pick up anything that's been around since before the range started but hasn't
81
+ # had an update during the range.
82
+ catch_all = '((status changed OR Sprint is not EMPTY) AND statusCategory != Done)'
83
+
84
+ # Pick up any issues that had a status change in the range
85
+ start_date_text = @start_date_in_query.strftime '%Y-%m-%d'
86
+ # find_in_range = %((status changed DURING ("#{start_date_text} 00:00","#{end_date_text} 23:59")))
87
+ find_in_range = %(updated >= "#{start_date_text} 00:00")
88
+
89
+ segments << "(#{find_in_range} OR #{catch_all})"
90
+ end
91
+
92
+ segments.join ' AND '
93
+ end
94
+ end
@@ -15,15 +15,6 @@ class Exporter
15
15
  self.anonymize if anonymize
16
16
  self.settings.merge! settings
17
17
 
18
- status_category_mappings.each do |status, category|
19
- status_category_mapping status: status, category: category
20
- end
21
-
22
- download do
23
- self.rolling_date_count(rolling_date_count) if rolling_date_count
24
- self.no_earlier_than(no_earlier_than) if no_earlier_than
25
- end
26
-
27
18
  boards.each_key do |board_id|
28
19
  block = boards[board_id]
29
20
  if block == :default
@@ -37,6 +28,15 @@ class Exporter
37
28
  end
38
29
  end
39
30
 
31
+ status_category_mappings.each do |status, category|
32
+ status_category_mapping status: status, category: category
33
+ end
34
+
35
+ download do
36
+ self.rolling_date_count(rolling_date_count) if rolling_date_count
37
+ self.no_earlier_than(no_earlier_than) if no_earlier_than
38
+ end
39
+
40
40
  issues.reject! do |issue|
41
41
  ignore_types.include? issue.type
42
42
  end
@@ -48,7 +48,7 @@ class ExpeditedChart < ChartBase
48
48
  end
49
49
 
50
50
  if data_sets.empty?
51
- '<h1>Expedited work</h1>There is no expedited work in this time period.'
51
+ '<h1 class="foldable">Expedited work</h1><p>There is no expedited work in this time period.</p>'
52
52
  else
53
53
  wrap_and_render(binding, __FILE__)
54
54
  end
@@ -50,24 +50,27 @@ class Exporter
50
50
  end
51
51
 
52
52
  project.download_config.run
53
- downloader = Downloader.new(
53
+ gateway = JiraGateway.new(
54
+ file_system: file_system, jira_config: project.jira_config, settings: project.settings
55
+ )
56
+ downloader = Downloader.create(
54
57
  download_config: project.download_config,
55
58
  file_system: file_system,
56
- jira_gateway: JiraGateway.new(file_system: file_system)
59
+ jira_gateway: gateway
57
60
  )
58
61
  downloader.run
59
62
  end
60
63
  puts "Full output from downloader in #{file_system.logfile_name}"
61
64
  end
62
65
 
63
- def info keys, name_filter:
66
+ def info key, name_filter:
64
67
  selected = []
65
68
  each_project_config(name_filter: name_filter) do |project|
66
69
  project.evaluate_next_level
67
70
 
68
71
  project.run load_only: true
69
72
  project.issues.each do |issue|
70
- selected << [project, issue] if keys.include? issue.key
73
+ selected << [project, issue] if key == issue.key
71
74
  end
72
75
  rescue => e # rubocop:disable Style/RescueStandardError
73
76
  # This happens when we're attempting to load an aggregated project because it hasn't been
@@ -76,7 +79,7 @@ class Exporter
76
79
  end
77
80
 
78
81
  if selected.empty?
79
- file_system.log "No issues found to match #{keys.collect(&:inspect).join(', ')}"
82
+ file_system.log "No issues found to match #{key.inspect}"
80
83
  else
81
84
  selected.each do |project, issue|
82
85
  file_system.log "\nProject #{project.name}", also_write_to_stderr: true
@@ -85,6 +88,10 @@ class Exporter
85
88
  end
86
89
  end
87
90
 
91
+ def stitch stitch_file
92
+ Stitcher.new(file_system: file_system).run(stitch_file: stitch_file)
93
+ end
94
+
88
95
  def each_project_config name_filter:
89
96
  @project_configs.each do |project|
90
97
  yield project if project.name.nil? || File.fnmatch(name_filter, project.name)
@@ -5,6 +5,13 @@ require 'json'
5
5
  class FileSystem
6
6
  attr_accessor :logfile, :logfile_name
7
7
 
8
+ def initialize
9
+ # In almost all cases, this will be immediately replaced in the Exporter
10
+ # but if we fail before we get that far, this will at least let a useful
11
+ # error show up on the console.
12
+ @logfile = $stdout
13
+ end
14
+
8
15
  # Effectively the same as File.read except it forces the encoding to UTF-8
9
16
  def load filename, supress_deprecation: false
10
17
  if filename.end_with?('.json') && !supress_deprecation
@@ -31,6 +38,14 @@ class FileSystem
31
38
  File.write(filename, content)
32
39
  end
33
40
 
41
+ def mkdir path
42
+ FileUtils.mkdir_p path
43
+ end
44
+
45
+ def utime file:, time:
46
+ File.utime time, time, file
47
+ end
48
+
34
49
  def warning message, more: nil
35
50
  log "Warning: #{message}", more: more, also_write_to_stderr: true
36
51
  end
@@ -66,7 +81,15 @@ class FileSystem
66
81
  end
67
82
 
68
83
  def file_exist? filename
69
- File.exist? filename
84
+ File.exist?(filename) && File.file?(filename)
85
+ end
86
+
87
+ def dir_exist? path
88
+ File.exist?(path) && File.directory?(path)
89
+ end
90
+
91
+ def unlink filename
92
+ File.unlink filename
70
93
  end
71
94
 
72
95
  def deprecated message:, date:, depth: 2
@@ -11,11 +11,24 @@ class FixVersion
11
11
  @raw['name']
12
12
  end
13
13
 
14
+ def description
15
+ @raw['description']
16
+ end
17
+
14
18
  def id
15
19
  @raw['id'].to_i
16
20
  end
17
21
 
22
+ def release_date
23
+ text = @raw['releaseDate']
24
+ text.nil? ? nil : Date.parse(text)
25
+ end
26
+
18
27
  def released?
19
28
  @raw['released']
20
29
  end
30
+
31
+ def archived?
32
+ @raw['archived']
33
+ end
21
34
  end
@@ -60,7 +60,7 @@ class FlowEfficiencyScatterplot < ChartBase
60
60
  create_dataset(issues: issues, label: rules.label, color: rules.color)
61
61
  end
62
62
 
63
- return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
63
+ return "<h1 class='foldable'>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
64
64
 
65
65
  wrap_and_render(binding, __FILE__)
66
66
  end
@@ -15,14 +15,20 @@ module GroupableIssueChart
15
15
 
16
16
  def group_issues completed_issues
17
17
  result = {}
18
+ ignored_issues = []
18
19
  completed_issues.each do |issue|
19
20
  rules = GroupingRules.new
20
21
  @group_by_block.call(issue, rules)
21
- next if rules.ignored?
22
+ if rules.ignored?
23
+ ignored_issues << issue
24
+ next
25
+ end
22
26
 
23
27
  (result[rules] ||= []) << issue
24
28
  end
25
29
 
30
+ completed_issues.reject! { |issue| ignored_issues.include? issue }
31
+
26
32
  result.each_key do |rules|
27
33
  rules.color = random_color if rules.color.nil?
28
34
  end
@@ -1,3 +1,4 @@
1
+ <%= seam_start %>
1
2
  <div class="chart">
2
3
  <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
3
4
  </div>
@@ -66,4 +67,4 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
66
67
  }
67
68
  });
68
69
  </script>
69
-
70
+ <%= seam_end %>
@@ -1,3 +1,4 @@
1
+ <%= seam_start %>
1
2
  <div class="chart">
2
3
  <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
3
4
  </div>
@@ -40,7 +41,7 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
40
41
  color: <%= CssVariable['--grid-line-color'].to_json %>,
41
42
  z: 1 // draw the grid lines on top of the bars
42
43
  },
43
- stacked: true,
44
+ stacked: false,
44
45
  max: <%= (@max_age * 1.1).to_i %>
45
46
  }
46
47
  },
@@ -73,3 +74,4 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
73
74
  }
74
75
  });
75
76
  </script>
77
+ <%= seam_end %>
@@ -1,3 +1,4 @@
1
+ <%= seam_start %>
1
2
  <table class='standard'>
2
3
  <thead>
3
4
  <tr>
@@ -54,3 +55,4 @@
54
55
  <% end %>
55
56
  </tbody>
56
57
  </table>
58
+ <%= seam_end %>
@@ -1,5 +1,5 @@
1
- [<a id='<%= link_id %>' href="#" onclick='expand_collapse("<%= link_id %>", "<%= issues_id %>"); return false;'>Show details</a>]
2
- <table class='standard' id='<%= issues_id %>' style='display: none;'>
1
+ <div class='foldable startFolded'>Show details</div>
2
+ <table class='standard' id='<%= issues_id %>'>
3
3
  <thead>
4
4
  <tr>
5
5
  <th>Issue</th>
@@ -1,3 +1,4 @@
1
+ <%= seam_start %>
1
2
  <div class="chart">
2
3
  <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
3
4
  </div>
@@ -6,8 +7,8 @@ if show_stats
6
7
  link_id = next_id
7
8
  issues_id = next_id
8
9
  %>
9
- [<a id='<%= link_id %>' href="#" onclick='expand_collapse("<%= link_id %>", "<%= issues_id %>"); return false;'>Show details</a>]
10
- <div id="<%= issues_id %>" style="display: none;">
10
+ <div class='foldable' style="padding-left: 1em;">Statistics</div>
11
+ <div id="<%= issues_id %>" style="padding-left: 1em;">
11
12
  <div>
12
13
  <table class="standard">
13
14
  <tr>
@@ -119,3 +120,4 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
119
120
  }
120
121
  });
121
122
  </script>
123
+ <%= seam_end %>
@@ -1,3 +1,4 @@
1
+ <%= seam_start %>
1
2
  <div class="chart">
2
3
  <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
3
4
  </div>
@@ -10,15 +11,14 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
10
11
  options: {
11
12
  title: {
12
13
  display: true,
13
- text: "Cycletime Scatterplot"
14
+ text: "<%= @header_text %>"
14
15
  },
15
16
  responsive: <%= canvas_responsive? %>, // If responsive is true then it fills the screen
16
17
  scales: {
17
18
  x: {
18
19
  type: "time",
19
20
  scaleLabel: {
20
- display: true,
21
- labelString: 'Date Completed'
21
+ display: true
22
22
  },
23
23
  grid: {
24
24
  color: <%= CssVariable['--grid-line-color'].to_json %>
@@ -29,13 +29,12 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
29
29
  y: {
30
30
  scaleLabel: {
31
31
  display: true,
32
- labelString: 'Days',
33
32
  min: 0,
34
- max: <%= @highest_cycletime %>
33
+ max: <%= @highest_y_value %>
35
34
  },
36
35
  title: {
37
36
  display: true,
38
- text: 'Cycle time in days'
37
+ text: '<%= y_axis_heading %>'
39
38
  },
40
39
  grid: {
41
40
  color: <%= CssVariable['--grid-line-color'].to_json %>
@@ -98,3 +97,4 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
98
97
  }
99
98
  });
100
99
  </script>
100
+ <%= seam_end %>
@@ -1,3 +1,4 @@
1
+ <%= seam_start %>
1
2
  <div class="chart">
2
3
  <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
3
4
  </div>
@@ -65,3 +66,4 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
65
66
  }
66
67
  });
67
68
  </script>
69
+ <%= seam_end %>
@@ -1,3 +1,4 @@
1
+ <%= seam_start %>
1
2
  <div class="chart">
2
3
  <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
3
4
  </div>
@@ -60,3 +61,4 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
60
61
  }
61
62
  });
62
63
  </script>
64
+ <%= seam_end %>
@@ -1,3 +1,4 @@
1
+ <%= seam_start %>
1
2
  <div class="chart">
2
3
  <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
3
4
  </div>
@@ -61,4 +62,5 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
61
62
  }
62
63
  }
63
64
  });
64
- </script>
65
+ </script>
66
+ <%= seam_end %>
@@ -1,3 +1,4 @@
1
+ <%= seam_start %>
1
2
  <div class="chart">
2
3
  <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
3
4
  </div>
@@ -83,3 +84,4 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
83
84
  }
84
85
  });
85
86
  </script>
87
+ <%= seam_start %>