jirametrics 2.10 → 2.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/bin/jirametrics-mcp +5 -0
  3. data/lib/jirametrics/aggregate_config.rb +10 -2
  4. data/lib/jirametrics/aging_work_bar_chart.rb +191 -133
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +138 -42
  6. data/lib/jirametrics/aging_work_table.rb +62 -17
  7. data/lib/jirametrics/anonymizer.rb +81 -6
  8. data/lib/jirametrics/atlassian_document_format.rb +160 -0
  9. data/lib/jirametrics/bar_chart_range.rb +17 -0
  10. data/lib/jirametrics/blocked_stalled_change.rb +5 -3
  11. data/lib/jirametrics/board.rb +63 -11
  12. data/lib/jirametrics/board_config.rb +5 -1
  13. data/lib/jirametrics/board_feature.rb +14 -0
  14. data/lib/jirametrics/board_movement_calculator.rb +155 -0
  15. data/lib/jirametrics/cfd_data_builder.rb +108 -0
  16. data/lib/jirametrics/change_item.rb +49 -19
  17. data/lib/jirametrics/chart_base.rb +147 -7
  18. data/lib/jirametrics/css_variable.rb +2 -2
  19. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  20. data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +22 -5
  21. data/lib/jirametrics/cycletime_histogram.rb +15 -101
  22. data/lib/jirametrics/cycletime_scatterplot.rb +17 -83
  23. data/lib/jirametrics/daily_view.rb +306 -0
  24. data/lib/jirametrics/daily_wip_by_age_chart.rb +4 -5
  25. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +14 -4
  26. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
  27. data/lib/jirametrics/daily_wip_chart.rb +30 -8
  28. data/lib/jirametrics/data_quality_report.rb +43 -12
  29. data/lib/jirametrics/dependency_chart.rb +6 -3
  30. data/lib/jirametrics/download_config.rb +15 -0
  31. data/lib/jirametrics/downloader.rb +128 -71
  32. data/lib/jirametrics/downloader_for_cloud.rb +287 -0
  33. data/lib/jirametrics/downloader_for_data_center.rb +95 -0
  34. data/lib/jirametrics/estimate_accuracy_chart.rb +74 -12
  35. data/lib/jirametrics/estimation_configuration.rb +25 -0
  36. data/lib/jirametrics/examples/aggregated_project.rb +2 -2
  37. data/lib/jirametrics/examples/standard_project.rb +42 -27
  38. data/lib/jirametrics/expedited_chart.rb +3 -1
  39. data/lib/jirametrics/exporter.rb +28 -8
  40. data/lib/jirametrics/file_config.rb +10 -12
  41. data/lib/jirametrics/file_system.rb +59 -3
  42. data/lib/jirametrics/fix_version.rb +13 -0
  43. data/lib/jirametrics/flow_efficiency_scatterplot.rb +6 -2
  44. data/lib/jirametrics/github_gateway.rb +115 -0
  45. data/lib/jirametrics/groupable_issue_chart.rb +11 -1
  46. data/lib/jirametrics/grouping_rules.rb +26 -4
  47. data/lib/jirametrics/html/aging_work_bar_chart.erb +5 -5
  48. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +24 -5
  49. data/lib/jirametrics/html/aging_work_table.erb +12 -3
  50. data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
  51. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  52. data/lib/jirametrics/html/daily_wip_chart.erb +40 -5
  53. data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
  54. data/lib/jirametrics/html/expedited_chart.erb +6 -14
  55. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
  56. data/lib/jirametrics/html/index.css +323 -63
  57. data/lib/jirametrics/html/index.erb +17 -19
  58. data/lib/jirametrics/html/index.js +164 -0
  59. data/lib/jirametrics/html/legacy_colors.css +174 -0
  60. data/lib/jirametrics/html/sprint_burndown.erb +17 -15
  61. data/lib/jirametrics/html/throughput_chart.erb +42 -11
  62. data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
  63. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +15 -11
  64. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  65. data/lib/jirametrics/html_generator.rb +32 -0
  66. data/lib/jirametrics/html_report_config.rb +52 -55
  67. data/lib/jirametrics/issue.rb +347 -103
  68. data/lib/jirametrics/issue_collection.rb +33 -0
  69. data/lib/jirametrics/issue_printer.rb +97 -0
  70. data/lib/jirametrics/jira_gateway.rb +81 -14
  71. data/lib/jirametrics/mcp_server.rb +531 -0
  72. data/lib/jirametrics/project_config.rb +151 -18
  73. data/lib/jirametrics/pull_request.rb +30 -0
  74. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  75. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  76. data/lib/jirametrics/pull_request_review.rb +13 -0
  77. data/lib/jirametrics/raw_javascript.rb +17 -0
  78. data/lib/jirametrics/settings.json +6 -1
  79. data/lib/jirametrics/sprint.rb +13 -0
  80. data/lib/jirametrics/sprint_burndown.rb +45 -37
  81. data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
  82. data/lib/jirametrics/status.rb +3 -0
  83. data/lib/jirametrics/status_collection.rb +7 -0
  84. data/lib/jirametrics/stitcher.rb +81 -0
  85. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  86. data/lib/jirametrics/throughput_chart.rb +73 -23
  87. data/lib/jirametrics/time_based_histogram.rb +139 -0
  88. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  89. data/lib/jirametrics/user.rb +12 -0
  90. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  91. data/lib/jirametrics.rb +83 -64
  92. metadata +66 -6
@@ -38,6 +38,8 @@ class ExpeditedChart < ChartBase
38
38
  </div>
39
39
  #{describe_non_working_days}
40
40
  HTML
41
+ @x_axis_title = 'Date'
42
+ @y_axis_title = 'Age in days'
41
43
 
42
44
  instance_eval(&block)
43
45
  end
@@ -48,7 +50,7 @@ class ExpeditedChart < ChartBase
48
50
  end
49
51
 
50
52
  if data_sets.empty?
51
- '<h1>Expedited work</h1>There is no expedited work in this time period.'
53
+ '<h1 class="foldable">Expedited work</h1><div>There is no expedited work in this time period.</div>'
52
54
  else
53
55
  wrap_and_render(binding, __FILE__)
54
56
  end
@@ -8,7 +8,13 @@ class Exporter
8
8
 
9
9
  def self.configure &block
10
10
  logfile_name = 'jirametrics.log'
11
- logfile = File.open logfile_name, 'w'
11
+ logfile = File.open(logfile_name, 'w')
12
+ rescue Errno::EACCES
13
+ # FileSystem can't be used here — it hasn't been created yet (it depends on this logfile).
14
+ warn "Error: Cannot write to #{File.expand_path(logfile_name)}. " \
15
+ 'Please ensure the current directory is writable.'
16
+ exit 1
17
+ else
12
18
  file_system = FileSystem.new
13
19
  file_system.logfile = logfile
14
20
  file_system.logfile_name = logfile_name
@@ -40,6 +46,7 @@ class Exporter
40
46
 
41
47
  def download name_filter:
42
48
  @downloading = true
49
+ github_pr_cache = {}
43
50
  each_project_config(name_filter: name_filter) do |project|
44
51
  project.evaluate_next_level
45
52
  next if project.aggregated_project?
@@ -50,41 +57,54 @@ class Exporter
50
57
  end
51
58
 
52
59
  project.download_config.run
53
- downloader = Downloader.new(
60
+ gateway = JiraGateway.new(
61
+ file_system: file_system, jira_config: project.jira_config, settings: project.settings
62
+ )
63
+ downloader = Downloader.create(
54
64
  download_config: project.download_config,
55
65
  file_system: file_system,
56
- jira_gateway: JiraGateway.new(file_system: file_system)
66
+ jira_gateway: gateway,
67
+ github_pr_cache: github_pr_cache
57
68
  )
58
69
  downloader.run
59
70
  end
60
71
  puts "Full output from downloader in #{file_system.logfile_name}"
61
72
  end
62
73
 
63
- def info keys, name_filter:
74
+ def info key, name_filter:
64
75
  selected = []
76
+ file_system.log_only = true
65
77
  each_project_config(name_filter: name_filter) do |project|
66
78
  project.evaluate_next_level
67
79
 
68
80
  project.run load_only: true
69
81
  project.issues.each do |issue|
70
- selected << [project, issue] if keys.include? issue.key
82
+ selected << [project, issue] if key == issue.key
83
+ issue.subtasks.each do |subtask|
84
+ selected << [project, subtask] if key == subtask.key
85
+ end
71
86
  end
72
87
  rescue => e # rubocop:disable Style/RescueStandardError
73
88
  # This happens when we're attempting to load an aggregated project because it hasn't been
74
89
  # properly initialized. Since we don't care about aggregated projects, we just ignore it.
75
90
  raise unless e.message.start_with? 'This is an aggregated project and issues should have been included'
76
91
  end
92
+ file_system.log_only = false
77
93
 
78
94
  if selected.empty?
79
- file_system.log "No issues found to match #{keys.collect(&:inspect).join(', ')}"
95
+ file_system.log "No issues found to match #{key.inspect}"
80
96
  else
81
97
  selected.each do |project, issue|
82
- file_system.log "\nProject #{project.name}"
83
- file_system.log issue.dump
98
+ file_system.log "\nProject #{project.name}", also_write_to_stderr: true
99
+ file_system.log issue.dump, also_write_to_stderr: true
84
100
  end
85
101
  end
86
102
  end
87
103
 
104
+ def stitch stitch_file
105
+ Stitcher.new(file_system: file_system).run(stitch_file: stitch_file)
106
+ end
107
+
88
108
  def each_project_config name_filter:
89
109
  @project_configs.each do |project|
90
110
  yield project if project.name.nil? || File.fnmatch(name_filter, project.name)
@@ -13,7 +13,7 @@ class FileConfig
13
13
  end
14
14
 
15
15
  def run
16
- @issues = project_config.issues.dup
16
+ @issues = project_config.issues
17
17
  instance_eval(&@block)
18
18
 
19
19
  if @columns
@@ -65,22 +65,20 @@ class FileConfig
65
65
  # most common usecase - the Team Dashboard from FocusedObjective.com. The rule for that one
66
66
  # is that all empty values in the first column should be at the bottom.
67
67
  def sort_output all_lines
68
- all_lines.sort do |a, b|
69
- result = nil
70
- if a[0] == b[0]
71
- result = a[1..] <=> b[1..]
68
+ all_lines.each_with_index.sort do |(a, a_idx), (b, b_idx)|
69
+ result = if a[0] == b[0]
70
+ a[1..] <=> b[1..]
72
71
  elsif a[0].nil?
73
- result = 1
72
+ 1
74
73
  elsif b[0].nil?
75
- result = -1
74
+ -1
76
75
  else
77
- result = a[0] <=> b[0]
76
+ a[0] <=> b[0]
78
77
  end
79
78
 
80
- # This will only happen if one of the objects isn't comparable. Seen in production.
81
- result = -1 if result.nil?
82
- result
83
- end
79
+ # When objects aren't comparable, preserve original order for a stable sort.
80
+ result.nil? || result.zero? ? a_idx <=> b_idx : result
81
+ end.map(&:first)
84
82
  end
85
83
 
86
84
  def columns &block
@@ -3,7 +3,15 @@
3
3
  require 'json'
4
4
 
5
5
  class FileSystem
6
- attr_accessor :logfile, :logfile_name
6
+ attr_accessor :logfile, :logfile_name, :log_only
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
+ @log_only = false
14
+ end
7
15
 
8
16
  # Effectively the same as File.read except it forces the encoding to UTF-8
9
17
  def load filename, supress_deprecation: false
@@ -31,6 +39,14 @@ class FileSystem
31
39
  File.write(filename, content)
32
40
  end
33
41
 
42
+ def mkdir path
43
+ FileUtils.mkdir_p path
44
+ end
45
+
46
+ def utime file:, time:
47
+ File.utime time, time, file
48
+ end
49
+
34
50
  def warning message, more: nil
35
51
  log "Warning: #{message}", more: more, also_write_to_stderr: true
36
52
  end
@@ -44,11 +60,43 @@ class FileSystem
44
60
 
45
61
  logfile.puts message
46
62
  logfile.puts more if more
47
- return unless also_write_to_stderr
63
+ return if log_only || !also_write_to_stderr
64
+
65
+ # Obscure edge-case where we're trying to log something before logging is even
66
+ # set up. Quick escape here so that we don't dump the error twice.
67
+ return if logfile == $stdout
48
68
 
49
69
  $stderr.puts message # rubocop:disable Style/StderrPuts
50
70
  end
51
71
 
72
+ def log_start message
73
+ logfile.puts message
74
+ return if log_only || logfile == $stdout
75
+
76
+ $stderr.print message
77
+ $stderr.flush
78
+ end
79
+
80
+ def start_progress
81
+ return if log_only
82
+
83
+ $stderr.print ' '
84
+ $stderr.flush
85
+ end
86
+
87
+ def progress_dot
88
+ return if log_only
89
+
90
+ $stderr.print '.'
91
+ $stderr.flush
92
+ end
93
+
94
+ def end_progress
95
+ return if log_only
96
+
97
+ $stderr.puts '' # rubocop:disable Style/StderrPuts
98
+ end
99
+
52
100
  # In some Jira instances, a sizeable portion of the JSON is made up of empty fields. I've seen
53
101
  # cases where this simple compression will drop the filesize by half.
54
102
  def compress node
@@ -66,7 +114,15 @@ class FileSystem
66
114
  end
67
115
 
68
116
  def file_exist? filename
69
- File.exist? filename
117
+ File.exist?(filename) && File.file?(filename)
118
+ end
119
+
120
+ def dir_exist? path
121
+ File.exist?(path) && File.directory?(path)
122
+ end
123
+
124
+ def unlink filename
125
+ File.unlink filename
70
126
  end
71
127
 
72
128
  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
@@ -27,11 +27,13 @@ class FlowEfficiencyScatterplot < ChartBase
27
27
  </mfrac>
28
28
  </math>
29
29
  </div>
30
- <div style="background: yellow">Note that for this calculation to be accurate, we must be moving items into a
30
+ <div style="background: var(--warning-banner)">Note that for this calculation to be accurate, we must be moving items into a
31
31
  blocked or stalled state the moment we stop working on it, and most teams don't do that.
32
32
  So be aware that your team may have to change their behaviours if you want this chart to be useful.
33
33
  </div>
34
34
  HTML
35
+ @x_axis_title = 'Total time (days)'
36
+ @y_axis_title = 'Time adding value (days)'
35
37
 
36
38
  init_configuration_block block do
37
39
  grouping_rules do |issue, rule|
@@ -60,7 +62,9 @@ class FlowEfficiencyScatterplot < ChartBase
60
62
  create_dataset(issues: issues, label: rules.label, color: rules.color)
61
63
  end
62
64
 
63
- return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
65
+ if data_sets.empty?
66
+ return "<h1 class='foldable'>#{@header_text}</h1><div>No data matched the selected criteria. Nothing to show.</div>"
67
+ end
64
68
 
65
69
  wrap_and_render(binding, __FILE__)
66
70
  end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'json'
5
+
6
+ class GithubGateway
7
+ attr_reader :repo
8
+
9
+ def initialize repo:, project_keys:, file_system:, raw_pr_cache: {}
10
+ @repo = repo
11
+ @project_keys = project_keys
12
+ @file_system = file_system
13
+ @raw_pr_cache = raw_pr_cache
14
+ @issue_key_pattern = build_issue_key_pattern
15
+ end
16
+
17
+ def fetch_pull_requests since: nil
18
+ raw_prs = @raw_pr_cache[[@repo, since]] ||= fetch_raw_pull_requests(since: since)
19
+ raw_prs.filter_map { |pr| build_pr_data(pr) }
20
+ end
21
+
22
+ def fetch_raw_pull_requests since: nil
23
+ # Note: 'commits' is intentionally excluded — including it triggers GitHub's GraphQL node
24
+ # limit (authors sub-connection × PRs × commits exceeds 500,000 nodes). Branch name,
25
+ # title, and body are sufficient for issue key extraction in the vast majority of cases.
26
+ json_fields = %w[number title body headRefName createdAt closedAt mergedAt
27
+ url state reviews additions deletions changedFiles].join(',')
28
+ args = ['pr', 'list', '--state', 'all', '--limit', '5000', '--json', json_fields]
29
+ args += ['--repo', @repo]
30
+ args += ['--search', "updated:>=#{since}"] if since
31
+
32
+ @file_system.log " Downloading pull requests from #{@repo}", also_write_to_stderr: true
33
+ run_command(args)
34
+ end
35
+
36
+ def build_pr_data raw_pr
37
+ issue_keys = extract_issue_keys(raw_pr)
38
+ return nil if issue_keys.empty?
39
+
40
+ PullRequest.new(raw: {
41
+ 'number' => raw_pr['number'],
42
+ 'repo' => @repo,
43
+ 'url' => raw_pr['url'],
44
+ 'title' => raw_pr['title'],
45
+ 'branch' => raw_pr['headRefName'],
46
+ 'opened_at' => raw_pr['createdAt'],
47
+ 'closed_at' => raw_pr['closedAt'],
48
+ 'merged_at' => raw_pr['mergedAt'],
49
+ 'state' => raw_pr['state'],
50
+ 'issue_keys' => issue_keys,
51
+ 'reviews' => extract_reviews(raw_pr['reviews'] || []),
52
+ 'additions' => raw_pr['additions'],
53
+ 'deletions' => raw_pr['deletions'],
54
+ 'changed_files' => raw_pr['changedFiles']
55
+ })
56
+ end
57
+
58
+ def extract_issue_keys raw_pr
59
+ return [] if @issue_key_pattern.nil?
60
+
61
+ sources = [
62
+ raw_pr['headRefName'],
63
+ raw_pr['title'],
64
+ raw_pr['body']
65
+ ]
66
+
67
+ keys = sources.compact.flat_map { |s| s.scan(@issue_key_pattern) }.uniq
68
+ return keys unless keys.empty?
69
+
70
+ commit_messages_for(raw_pr['number']).flat_map { |msg| msg.scan(@issue_key_pattern) }.uniq
71
+ end
72
+
73
+ def extract_reviews raw_reviews
74
+ raw_reviews
75
+ .select { |r| %w[APPROVED CHANGES_REQUESTED].include?(r['state']) }
76
+ .map do |r|
77
+ {
78
+ 'author' => r.dig('author', 'login'),
79
+ 'submitted_at' => r['submittedAt'],
80
+ 'state' => r['state']
81
+ }
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ def commit_messages_for pr_number
88
+ args = ['pr', 'view', pr_number.to_s, '--json', 'commits', '--repo', @repo]
89
+ result = run_command(args)
90
+ (result['commits'] || []).flat_map do |commit|
91
+ [commit['messageHeadline'], commit['messageBody']].compact
92
+ end
93
+ end
94
+
95
+ def build_issue_key_pattern
96
+ return nil if @project_keys.empty?
97
+
98
+ keys_pattern = @project_keys.map { |k| Regexp.escape(k) }.join('|')
99
+ Regexp.new("\\b(?:#{keys_pattern})-\\d+(?![A-Za-z0-9])")
100
+ end
101
+
102
+ def run_command args
103
+ stdout, stderr, status = Open3.capture3('gh', *args)
104
+
105
+ # This extra check seems to only matter on Windows. On the mac, auth failures don't pass status.success?
106
+ if stderr.include?('SAML enforcement')
107
+ raise "GitHub CLI is not authorized to access #{@repo}. " \
108
+ 'Run: gh auth refresh -h github.com -s read:org'
109
+ end
110
+
111
+ raise "GitHub CLI command failed for #{@repo}: #{stderr}" unless status.success?
112
+
113
+ JSON.parse(stdout)
114
+ end
115
+ end
@@ -15,14 +15,24 @@ module GroupableIssueChart
15
15
 
16
16
  def group_issues completed_issues
17
17
  result = {}
18
+ ignored_issues = []
19
+ @issue_hints = {}
20
+ @issue_periods = {}
18
21
  completed_issues.each do |issue|
19
22
  rules = GroupingRules.new
20
23
  @group_by_block.call(issue, rules)
21
- next if rules.ignored?
24
+ if rules.ignored?
25
+ ignored_issues << issue
26
+ next
27
+ end
22
28
 
29
+ @issue_hints[issue] = rules.issue_hint
30
+ @issue_periods[issue] = rules.last_day_of_period
23
31
  (result[rules] ||= []) << issue
24
32
  end
25
33
 
34
+ completed_issues.reject! { |issue| ignored_issues.include? issue }
35
+
26
36
  result.each_key do |rules|
27
37
  rules.color = random_color if rules.color.nil?
28
38
  end
@@ -1,8 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class GroupingRules < Rules
4
- attr_accessor :label
5
- attr_reader :color
4
+ attr_accessor :label, :issue_hint, :label_hint
5
+ attr_reader :color, :last_day_of_period
6
+
7
+ def last_day_of_period= value
8
+ @last_day_of_period = value.is_a?(String) ? Date.parse(value) : value
9
+ end
6
10
 
7
11
  def eql? other
8
12
  other.label == @label && other.color == @color
@@ -13,7 +17,25 @@ class GroupingRules < Rules
13
17
  end
14
18
 
15
19
  def color= color
16
- color = CssVariable[color] unless color.is_a?(CssVariable)
17
- @color = color
20
+ if color.is_a?(Array)
21
+ raise ArgumentError, 'Color pair must have exactly two elements: [light_color, dark_color]' unless color.size == 2
22
+ raise ArgumentError, 'Color pair elements must be strings' unless color.all?(String)
23
+
24
+ if color.any? { |c| c.start_with?('--') }
25
+ raise ArgumentError,
26
+ 'CSS variable references are not supported as color pair elements; use a literal color value instead'
27
+ end
28
+
29
+ light, dark = color
30
+ @color = RawJavascript.new(
31
+ "(document.documentElement.dataset.theme === 'dark' || " \
32
+ '(!document.documentElement.dataset.theme && ' \
33
+ "window.matchMedia('(prefers-color-scheme: dark)').matches)) " \
34
+ "? #{dark.to_json} : #{light.to_json}"
35
+ )
36
+ else
37
+ color = CssVariable[color] unless color.is_a?(CssVariable)
38
+ @color = color
39
+ end
18
40
  end
19
41
  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>
@@ -15,11 +16,9 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
15
16
  x: {
16
17
  type: 'time',
17
18
  min: '<%= @date_range.begin.to_s %>',
18
- max: '<%= (@date_range.end ).to_s %>',
19
+ max: '<%= (@date_range.end + 1).to_s %>',
19
20
  stacked: false,
20
- title: {
21
- display: false
22
- },
21
+ <%= render_axis_title :x %>
23
22
  grid: {
24
23
  color: <%= CssVariable['--grid-line-color'].to_json %>
25
24
  },
@@ -30,6 +29,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
30
29
  ticks: {
31
30
  display: true
32
31
  },
32
+ <%= render_axis_title :y %>
33
33
  grid: {
34
34
  color: <%= CssVariable['--grid-line-color'].to_json %>
35
35
  },
@@ -66,4 +66,4 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
66
66
  }
67
67
  });
68
68
  </script>
69
-
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>
@@ -6,7 +7,7 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
6
7
  {
7
8
  type: 'bar',
8
9
  data: {
9
- labels: [<%= column_headings.collect(&:inspect).join(',') %>],
10
+ labels: [<%= @board_columns.collect { |c| c.name.inspect }.join(',') %>],
10
11
  datasets: <%= JSON.generate(data_sets) %>
11
12
  },
12
13
  options: {
@@ -22,8 +23,10 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
22
23
  labelString: 'Date Completed'
23
24
  },
24
25
  grid: {
25
- color: <%= CssVariable['--grid-line-color'].to_json %>
26
+ color: <%= CssVariable['--grid-line-color'].to_json %>,
27
+ z: 1 // draw the grid lines on top of the bars
26
28
  },
29
+ stacked: true
27
30
  },
28
31
  y: {
29
32
  scaleLabel: {
@@ -35,8 +38,11 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
35
38
  text: 'Age in days'
36
39
  },
37
40
  grid: {
38
- color: <%= CssVariable['--grid-line-color'].to_json %>
41
+ color: <%= CssVariable['--grid-line-color'].to_json %>,
42
+ z: 1 // draw the grid lines on top of the bars
39
43
  },
44
+ stacked: false,
45
+ max: <%= (@max_age * 1.1).to_i %>
40
46
  }
41
47
  },
42
48
  plugins: {
@@ -44,15 +50,28 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
44
50
  callbacks: {
45
51
  label: function(context) {
46
52
  if( typeof(context.dataset.data[context.dataIndex]) == "number" ) {
47
- return "85% of the issues, leave this column in "+context.dataset.data[context.dataIndex]+" days";
53
+ let full_data = <%= @bar_data.inspect %>;
54
+ let columnIndex = context.dataIndex;
55
+ let rowIndex = context.datasetIndex - <%= @row_index_offset %>;
56
+ return context.dataset.label + " of completed work items left this column in " +full_data[rowIndex][columnIndex] + " days or less";
48
57
  }
49
58
  else {
50
- return context.dataset.data[context.dataIndex].title
59
+ return context.dataset.data[context.dataIndex].title;
51
60
  }
52
61
  }
53
62
  }
63
+ },
64
+ legend: {
65
+ labels: {
66
+ filter: function(item, chart) {
67
+ // Logic to remove a particular legend item goes here
68
+ return !item.text.includes('%');
69
+ }
70
+ }
54
71
  }
72
+
55
73
  }
56
74
  }
57
75
  });
58
76
  </script>
77
+ <%= seam_end %>
@@ -1,11 +1,14 @@
1
+ <%= seam_start %>
1
2
  <table class='standard'>
2
3
  <thead>
3
4
  <tr>
4
- <th>Age (days)</th>
5
- <th>E</th>
6
- <th>B</th>
5
+ <th title="Age in days">Age</th>
6
+ <th title="Expedited">E</th>
7
+ <th title="Blocked / Stalled">B/S</th>
8
+ <th title="Priority">P</th>
7
9
  <th>Issue</th>
8
10
  <th>Status</th>
11
+ <th>Forecast</th>
9
12
  <th>Fix versions</th>
10
13
  <% if any_scrum_boards %>
11
14
  <th>Sprints</th>
@@ -28,6 +31,7 @@
28
31
  <td style="text-align: right;"><%= issue_age || 'Not started' %></td>
29
32
  <td><%= expedited_text(issue) %></td>
30
33
  <td><%= blocked_text(issue) %></td>
34
+ <td><%= priority_text(issue) %></td>
31
35
  <td>
32
36
  <% parent_hierarchy(issue).each_with_index do |parent, index| %>
33
37
  <% color = parent != issue ? "var(--hierarchy-table-inactive-item-text-color)" : 'var(--default-text-color)' %>
@@ -37,10 +41,14 @@
37
41
  <%= link_to_issue parent, style: "color: #{color}" %>
38
42
  </span>
39
43
  <i><%= parent.summary.strip.inspect %></i>
44
+ <% if parent == issue && (text = not_visible_text(issue)) %>
45
+ <br /><%= text %>
46
+ <% end %>
40
47
  </div>
41
48
  <% end %>
42
49
  </td>
43
50
  <td><%= format_status issue.status, board: issue.board %></td>
51
+ <td><%= dates_text(issue) %></td>
44
52
  <td><%= fix_versions_text(issue) %></td>
45
53
  <% if any_scrum_boards %>
46
54
  <td><%= sprints_text(issue) %></td>
@@ -50,3 +58,4 @@
50
58
  <% end %>
51
59
  </tbody>
52
60
  </table>
61
+ <%= 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>