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
@@ -1,17 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'jirametrics/groupable_issue_chart'
3
+ require 'jirametrics/time_based_histogram'
4
4
 
5
- class CycletimeHistogram < ChartBase
6
- include GroupableIssueChart
5
+ class CycletimeHistogram < TimeBasedHistogram
7
6
  attr_accessor :possible_statuses
8
- attr_reader :show_stats
9
7
 
10
8
  def initialize block
11
9
  super()
12
10
 
13
- percentiles [50, 85, 98]
14
- @show_stats = true
11
+ @x_axis_title = 'Cycletime in days'
12
+ @y_axis_title = 'Count'
15
13
 
16
14
  header_text 'Cycletime Histogram'
17
15
  description_text <<-HTML
@@ -30,110 +28,26 @@ class CycletimeHistogram < ChartBase
30
28
  end
31
29
  end
32
30
 
33
- def percentiles percs = nil
34
- @percentiles = percs unless percs.nil?
35
- @percentiles
36
- end
37
-
38
- def disable_stats
39
- @show_stats = false
40
- end
41
-
42
- def run
31
+ def all_items
43
32
  stopped_issues = completed_issues_in_range include_unstarted: true
44
33
 
45
34
  # For the histogram, we only want to consider items that have both a start and a stop time.
46
- histogram_issues = stopped_issues.select { |issue| issue.board.cycletime.started_stopped_times(issue).first }
47
- rules_to_issues = group_issues histogram_issues
48
-
49
- the_stats = {}
50
-
51
- overall_stats = stats_for histogram_data: histogram_data_for(issues: histogram_issues), percentiles: @percentiles
52
- the_stats[:all] = overall_stats
53
- data_sets = rules_to_issues.keys.collect do |rules|
54
- the_issue_type = rules.label
55
- the_histogram = histogram_data_for(issues: rules_to_issues[rules])
56
- the_stats[the_issue_type] = stats_for histogram_data: the_histogram, percentiles: @percentiles if @show_stats
57
-
58
- data_set_for(
59
- histogram_data: the_histogram,
60
- label: the_issue_type,
61
- color: rules.color
62
- )
63
- end
64
-
65
- return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
66
-
67
- wrap_and_render(binding, __FILE__)
35
+ stopped_issues.select { |issue| issue.started_stopped_times.first }
68
36
  end
69
37
 
70
- def histogram_data_for issues:
71
- count_hash = {}
72
- issues.each do |issue|
73
- days = issue.board.cycletime.cycletime(issue)
74
- count_hash[days] = (count_hash[days] || 0) + 1 if days.positive?
75
- end
76
- count_hash
38
+ def value_for_item issue
39
+ issue.board.cycletime.cycletime(issue)
77
40
  end
78
41
 
79
- def stats_for histogram_data:, percentiles:
80
- return {} if histogram_data.empty?
81
-
82
- total_values = histogram_data.values.sum
83
-
84
- # Calculate the average
85
- weighted_sum = histogram_data.reduce(0) { |sum, (value, frequency)| sum + (value * frequency) }
86
- average = total_values.zero? ? 0 : weighted_sum.to_f / total_values
87
-
88
- # Find the mode (or modes!) and the spread of the distribution
89
- sorted_histogram = histogram_data.sort_by { |_value, frequency| frequency }
90
- max_freq = sorted_histogram[-1][1]
91
- mode = sorted_histogram.select { |_v, f| f == max_freq }
92
-
93
- minmax = histogram_data.keys.minmax
94
-
95
- # Calculate percentiles
96
- sorted_values = histogram_data.keys.sort
97
- cumulative_counts = {}
98
- cumulative_sum = 0
99
-
100
- sorted_values.each do |value|
101
- cumulative_sum += histogram_data[value]
102
- cumulative_counts[value] = cumulative_sum
103
- end
104
-
105
- percentile_results = {}
106
- percentiles.each do |percentile|
107
- rank = (percentile / 100.0) * total_values
108
- percentile_value = sorted_values.find { |value| cumulative_counts[value] >= rank }
109
- percentile_results[percentile] = percentile_value
110
- end
111
-
112
- {
113
- average: average,
114
- mode: mode.collect(&:first).sort,
115
- min: minmax[0],
116
- max: minmax[1],
117
- percentiles: percentile_results
118
- }
42
+ def title_for_item count:, value:
43
+ "#{count} items completed in #{label_days value}"
119
44
  end
120
45
 
121
- def data_set_for histogram_data:, label:, color:
122
- keys = histogram_data.keys.sort
123
- {
124
- type: 'bar',
125
- label: label,
126
- data: keys.sort.filter_map do |key|
127
- next if histogram_data[key].zero?
46
+ def sort_items items
47
+ items.sort_by(&:key_as_i)
48
+ end
128
49
 
129
- {
130
- x: key,
131
- y: histogram_data[key],
132
- title: "#{histogram_data[key]} items completed in #{label_days key}"
133
- }
134
- end,
135
- backgroundColor: color,
136
- borderRadius: 0
137
- }
50
+ def label_for_item issue, hint:
51
+ "#{issue.key} : #{issue.summary}#{" #{hint}" if hint}"
138
52
  end
139
53
  end
@@ -1,10 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'jirametrics/groupable_issue_chart'
4
-
5
- class CycletimeScatterplot < ChartBase
6
- include GroupableIssueChart
3
+ require 'jirametrics/time_based_scatterplot'
7
4
 
5
+ class CycletimeScatterplot < TimeBasedScatterplot
8
6
  attr_accessor :possible_statuses
9
7
 
10
8
  def initialize block
@@ -26,6 +24,8 @@ class CycletimeScatterplot < ChartBase
26
24
  </div>
27
25
  #{describe_non_working_days}
28
26
  HTML
27
+ @x_axis_title = 'Date completed'
28
+ @y_axis_title = 'Cycletime in days'
29
29
 
30
30
  init_configuration_block block do
31
31
  grouping_rules do |issue, rule|
@@ -33,95 +33,29 @@ class CycletimeScatterplot < ChartBase
33
33
  rule.color = color_for type: issue.type
34
34
  end
35
35
  end
36
-
37
- @percentage_lines = []
38
- @highest_cycletime = 0
39
36
  end
40
37
 
41
- def run
42
- completed_issues = completed_issues_in_range include_unstarted: false
43
-
44
- data_sets = create_datasets completed_issues
45
- overall_percent_line = calculate_percent_line(completed_issues)
46
- @percentage_lines << [overall_percent_line, CssVariable['--cycletime-scatterplot-overall-trendline-color']]
47
-
48
- return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
49
-
50
- wrap_and_render(binding, __FILE__)
38
+ def minimum_y_value
39
+ 1 # Values under 1 day are data quality problems; they're flagged in the quality report instead
51
40
  end
52
41
 
53
- def create_datasets completed_issues
54
- data_sets = []
55
-
56
- group_issues(completed_issues).each do |rules, completed_issues_by_type|
57
- label = rules.label
58
- color = rules.color
59
- percent_line = calculate_percent_line completed_issues_by_type
60
- data = completed_issues_by_type.filter_map { |issue| data_for_issue(issue) }
61
- data_sets << {
62
- label: "#{label} (85% at #{label_days(percent_line)})",
63
- data: data,
64
- fill: false,
65
- showLine: false,
66
- backgroundColor: color
67
- }
68
-
69
- data_sets << trend_line_data_set(label: label, data: data, color: color)
70
-
71
- @percentage_lines << [percent_line, color]
72
- end
73
- data_sets
42
+ def all_items
43
+ completed_issues_in_range include_unstarted: false
74
44
  end
75
45
 
76
- def show_trend_lines
77
- @show_trend_lines = true
46
+ def x_value item
47
+ item.started_stopped_times.last
78
48
  end
79
49
 
80
- def trend_line_data_set label:, data:, color:
81
- points = data.collect do |hash|
82
- [Time.parse(hash[:x]).to_i, hash[:y]]
83
- end
84
-
85
- # The trend calculation works with numbers only so convert Time to an int and back
86
- calculator = TrendLineCalculator.new(points)
87
- data_points = calculator.chart_datapoints(
88
- range: time_range.begin.to_i..time_range.end.to_i,
89
- max_y: @highest_cycletime
90
- )
91
- data_points.each do |point_hash|
92
- point_hash[:x] = chart_format Time.at(point_hash[:x])
93
- end
94
-
95
- {
96
- type: 'line',
97
- label: "#{label} Trendline",
98
- data: data_points,
99
- fill: false,
100
- borderWidth: 1,
101
- markerType: 'none',
102
- borderColor: color,
103
- borderDash: [6, 3],
104
- pointStyle: 'dash',
105
- hidden: !@show_trend_lines
106
- }
50
+ def y_value item
51
+ item.board.cycletime.cycletime(item)
107
52
  end
108
53
 
109
- def data_for_issue issue
110
- cycle_time = issue.board.cycletime.cycletime(issue)
111
- return nil if cycle_time < 1 # These will get called out on the quality report
112
-
113
- @highest_cycletime = cycle_time if @highest_cycletime < cycle_time
114
-
115
- {
116
- y: cycle_time,
117
- x: chart_format(issue.board.cycletime.started_stopped_times(issue).last),
118
- title: ["#{issue.key} : #{issue.summary} (#{label_days(cycle_time)})"]
119
- }
54
+ def title_value item, rules: nil
55
+ hint = @issue_hints&.fetch(item, nil)
56
+ "#{item.key} : #{item.summary} (#{label_days(y_value(item))})#{" #{hint}" if hint}"
120
57
  end
121
58
 
122
- def calculate_percent_line completed_issues
123
- times = completed_issues.collect { |issue| issue.board.cycletime.cycletime(issue) }
124
- index = times.size * 85 / 100
125
- times.sort[index]
126
- end
59
+ # Kept for backwards compatibility with existing callers and specs
60
+ alias data_for_issue data_for_item
127
61
  end
@@ -0,0 +1,306 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DailyView < ChartBase
4
+ attr_accessor :possible_statuses
5
+
6
+ def initialize _block
7
+ super()
8
+
9
+ header_text 'Daily View'
10
+ description_text <<-HTML
11
+ <div class="p">
12
+ This view shows all the items (<%= aging_issues.count %>) you'll want to discuss during your daily
13
+ coordination meeting
14
+ (aka daily scrum, standup), in the order that you should be discussing them. The most important
15
+ items are at the top, and the least at the bottom.
16
+ </div>
17
+ <div class="p">
18
+ By default, we sort by priority first and then by age within each of those priorities.
19
+ Hover over the issue to make it stand out more.
20
+ </div>
21
+ HTML
22
+ end
23
+
24
+ def run
25
+ aging_issues = select_aging_issues
26
+
27
+ return "<h1 class='foldable'>#{@header_text}</h1><div>There are no items currently in progress</div>" if aging_issues.empty?
28
+
29
+ result = +''
30
+ result << render_top_text(binding)
31
+ aging_issues.each do |issue|
32
+ result << render_issue(issue, child: false)
33
+ end
34
+ result
35
+ end
36
+
37
+ def select_aging_issues
38
+ aging_issues = issues.select do |issue|
39
+ started_at, stopped_at = issue.started_stopped_times
40
+ started_at && !stopped_at
41
+ end
42
+
43
+ today = date_range.end
44
+ aging_issues.collect do |issue|
45
+ [issue, issue.priority_name, issue.board.cycletime.age(issue, today: today)]
46
+ end.sort(&issue_sorter).collect(&:first)
47
+ end
48
+
49
+ def issue_sorter
50
+ priority_names = settings['priority_order']
51
+ lambda do |a, b|
52
+ a_issue, a_priority, a_age = *a
53
+ b_issue, b_priority, b_age = *b
54
+
55
+ a_priority_index = priority_names.index(a_priority)
56
+ b_priority_index = priority_names.index(b_priority)
57
+
58
+ if a_priority_index.nil? && b_priority_index.nil?
59
+ result = a_priority <=> b_priority
60
+ elsif a_priority_index.nil?
61
+ result = 1
62
+ elsif b_priority_index.nil?
63
+ result = -1
64
+ else
65
+ result = b_priority_index <=> a_priority_index
66
+ end
67
+
68
+ result = b_age <=> a_age if result.zero?
69
+ result = a_issue <=> b_issue if result.zero?
70
+ result
71
+ end
72
+ end
73
+
74
+ def make_blocked_stalled_lines issue
75
+ today = date_range.end
76
+ started_date = issue.started_stopped_times.first&.to_date
77
+ return [] unless started_date
78
+
79
+ blocked_stalled = issue.blocked_stalled_by_date(
80
+ date_range: today..today, chart_end_time: time_range.end, settings: settings
81
+ )[today]
82
+ return [] if blocked_stalled.active?
83
+
84
+ lines = []
85
+ if blocked_stalled.blocked?
86
+ marker = color_block '--blocked-color'
87
+ lines << ["#{marker} Blocked by flag"] if blocked_stalled.flag
88
+ lines << ["#{marker} Blocked by status: #{blocked_stalled.status}"] if blocked_stalled.blocked_by_status?
89
+ blocked_stalled.blocking_issue_keys&.each do |key|
90
+ blocking_issue = issues.find_by_key key: key, include_hidden: true
91
+ if blocking_issue
92
+ lines << "<section><div class=\"foldable startFolded\">#{marker} Blocked by issue: " \
93
+ "#{make_issue_label issue: blocking_issue, done: blocking_issue.done?}</div>"
94
+ lines << blocking_issue
95
+ lines << '</section>'
96
+ else
97
+ lines << ["#{marker} Blocked by issue: #{key} (no description found)"]
98
+ end
99
+ end
100
+ elsif blocked_stalled.stalled_by_status?
101
+ lines << ["#{color_block '--stalled-color'} Stalled by status: #{blocked_stalled.status}"]
102
+ else
103
+ lines << ["#{color_block '--stalled-color'} Stalled by inactivity: #{blocked_stalled.stalled_days} days"]
104
+ end
105
+ lines
106
+ end
107
+
108
+ def make_issue_label issue:, done:
109
+ label = "<img src='#{issue.type_icon_url}' title='#{issue.type}' class='icon' /> "
110
+ label << '<s>' if done
111
+ label << "<b><a href='#{issue.url}'>#{issue.key}</a></b> &nbsp;<i>#{issue.summary}</i>"
112
+ label << '</s>' if done
113
+ label
114
+ end
115
+
116
+ def make_title_line issue:, done:
117
+ title_line = +''
118
+ title_line << color_block('--expedited-color', title: 'Expedited') if issue.expedited?
119
+ title_line << make_issue_label(issue: issue, done: done)
120
+ title_line
121
+ end
122
+
123
+ def make_parent_lines issue
124
+ lines = []
125
+ parent_key = issue.parent_key
126
+ if parent_key
127
+ parent = issues.find_by_key key: parent_key, include_hidden: true
128
+ text = parent ? make_issue_label(issue: parent, done: parent.done?) : parent_key
129
+ lines << ["Parent: #{text}"]
130
+ end
131
+ lines
132
+ end
133
+
134
+ def make_stats_lines issue:, done:
135
+ line = []
136
+
137
+ line << "<img src='#{issue.priority_url}' class='icon' /> <b>#{issue.priority_name}</b>"
138
+
139
+ if done
140
+ cycletime = issue.board.cycletime.cycletime(issue)
141
+
142
+ line << "Cycletime: <b>#{label_days cycletime}</b>"
143
+ else
144
+ age = issue.board.cycletime.age(issue, today: date_range.end)
145
+ line << "Age: <b>#{age ? label_days(age) : '(Not Started)'}</b>"
146
+ end
147
+ line << "Status: <b>#{format_status issue.status, board: issue.board}</b>"
148
+
149
+ column = issue.board.visible_columns.find { |c| c.status_ids.include?(issue.status.id) }
150
+ line << "Column: <b>#{column&.name || '(not visible on board)'}</b>"
151
+
152
+ if issue.assigned_to
153
+ line << "Assignee: <img src='#{issue.assigned_to_icon_url}' class='icon' /> <b>#{issue.assigned_to}</b>"
154
+ end
155
+
156
+ if issue.due_date
157
+ today = date_range.end
158
+ days = (issue.due_date - today).to_i
159
+ relative =
160
+ if days.zero? then 'today'
161
+ elsif days.positive? then "in #{label_days days}"
162
+ else "#{label_days(-days)} ago"
163
+ end
164
+ content = "#{issue.due_date} (#{relative})"
165
+ content = "<span style='background: var(--warning-banner)'>#{content}</span>" if days.negative?
166
+ line << "Due: <b>#{content}</b>"
167
+ end
168
+
169
+ block = lambda do |collection, label|
170
+ unless collection.empty?
171
+ text = collection.collect { |l| "<span class='label'>#{l}</span>" }.join(' ')
172
+ line << "#{label} #{text}"
173
+ end
174
+ end
175
+ block.call issue.labels, 'Labels:'
176
+ block.call issue.component_names, 'Components:'
177
+
178
+ [line]
179
+ end
180
+
181
+ def make_child_lines issue
182
+ lines = []
183
+ subtasks = issue.subtasks
184
+
185
+ return lines if subtasks.empty?
186
+
187
+ lines << "<section><div class=\"foldable startFolded\">Child issues (#{subtasks.count})</div>"
188
+ lines += subtasks
189
+ lines << '</section>'
190
+
191
+ lines
192
+ end
193
+
194
+ def make_history_lines issue
195
+ history = issue.changes.reverse
196
+ lines = []
197
+
198
+ lines << '<section><div class="foldable startFolded">Issue history</div>'
199
+ table = +''
200
+ table << '<table>'
201
+ history.each do |c|
202
+ time = c.time.strftime '%b %d, %Y @ %I:%M%P'
203
+
204
+ table << '<tr>'
205
+ table << "<td><span class='time' title='Timestamp: #{c.time}'>#{time}</span></td>"
206
+ table << "<td><img src='#{c.author_icon_url}' class='icon' title='#{c.author}' /></td>"
207
+ text = history_text change: c, board: issue.board
208
+ table << "<td><span class='field'>#{c.field_as_human_readable}</span> #{text}</td>"
209
+ table << '</tr>'
210
+ end
211
+ table << '</table>'
212
+ lines << [table]
213
+ lines << '</section>'
214
+ lines
215
+ end
216
+
217
+ def history_text change:, board:
218
+ convertor = ->(value, _id) { value.inspect }
219
+ convertor = ->(_value, id) { format_status(board.possible_statuses.find_by_id(id), board: board) } if change.status?
220
+
221
+ if change.comment? || change.description?
222
+ atlassian_document_format.to_html(change.value)
223
+ elsif %w[status priority assignee duedate issuetype].include?(change.field)
224
+ to = convertor.call(change.value, change.value_id)
225
+ if change.old_value
226
+ from = convertor.call(change.old_value, change.old_value_id)
227
+ "Changed from #{from} to #{to}"
228
+ else
229
+ "Set to #{to}"
230
+ end
231
+ elsif change.flagged?
232
+ change.value == '' ? 'Off' : 'On'
233
+ else
234
+ change.value
235
+ end
236
+ end
237
+
238
+ def make_sprints_lines issue
239
+ return [] unless issue.board.scrum?
240
+
241
+ sprint_names = issue.sprints.collect do |sprint|
242
+ if sprint.closed?
243
+ "<s>#{sprint.name}</s>"
244
+ else
245
+ sprint.name
246
+ end
247
+ end
248
+
249
+ return [['Sprints: NONE']] if sprint_names.empty?
250
+
251
+ [[+'Sprints: ' << sprint_names
252
+ .collect { |name| "<span class='label'>#{name}</span>" }
253
+ .join(' ')]]
254
+ end
255
+
256
+ def make_description_lines issue
257
+ description = issue.raw['fields']['description']
258
+ return [] unless description
259
+
260
+ text = "<div class='foldable startFolded'>Description</div>" \
261
+ "<div>#{atlassian_document_format.to_html(description)}</div>"
262
+ [[text]]
263
+ end
264
+
265
+ def assemble_issue_lines issue, child:
266
+ done = issue.done?
267
+
268
+ lines = []
269
+ lines << [make_title_line(issue: issue, done: done)]
270
+ lines << make_not_visible_line(issue)
271
+ lines += make_parent_lines(issue) unless child
272
+ lines += make_stats_lines(issue: issue, done: done)
273
+ unless done
274
+ lines += make_description_lines(issue)
275
+ lines += make_sprints_lines(issue)
276
+ lines += make_blocked_stalled_lines(issue)
277
+ lines += make_child_lines(issue)
278
+ lines += make_history_lines(issue)
279
+ end
280
+ lines.compact
281
+ end
282
+
283
+ def render_issue issue, child:
284
+ css_class = child ? 'child_issue' : 'daily_issue'
285
+ result = +''
286
+ result << "<div class='#{css_class}'>"
287
+ assemble_issue_lines(issue, child: child).each do |row|
288
+ if row.is_a? Issue
289
+ result << render_issue(row, child: true)
290
+ elsif row.is_a?(String)
291
+ result << row
292
+ else
293
+ result << '<div class="heading">'
294
+ row.each do |chunk|
295
+ result << "<div>#{chunk}</div>"
296
+ end
297
+ result << '</div>'
298
+ end
299
+ end
300
+ result << '</div>'
301
+ end
302
+
303
+ def make_not_visible_line issue
304
+ not_visible_text issue
305
+ end
306
+ end
@@ -49,9 +49,7 @@ class DailyWipByAgeChart < DailyWipChart
49
49
  end
50
50
 
51
51
  def default_grouping_rules issue:, rules:
52
- started, stopped = issue.board.cycletime.started_stopped_dates(issue)
53
-
54
- rules.issue_hint = "(age: #{label_days (rules.current_date - started + 1).to_i})" if started
52
+ started, stopped = issue.started_stopped_dates
55
53
 
56
54
  if stopped && started.nil? # We can't tell when it started
57
55
  @has_completed_but_not_started = true
@@ -72,7 +70,7 @@ class DailyWipByAgeChart < DailyWipChart
72
70
  rules.label = 'Start date unknown'
73
71
  rules.color = '--body-background'
74
72
  rules.group_priority = 11
75
- created_days = rules.current_date - created + 1
73
+ created_days = rules.current_date - created
76
74
  rules.issue_hint = "(created: #{label_days created_days.to_i} earlier, stopped on #{stopped})"
77
75
  end
78
76
  end
@@ -84,7 +82,8 @@ class DailyWipByAgeChart < DailyWipChart
84
82
  end
85
83
 
86
84
  def group_by_age started:, rules:
87
- age = rules.current_date - started + 1
85
+ age = (rules.current_date - started).to_i + 1
86
+ rules.issue_hint = "(age: #{label_days age})"
88
87
 
89
88
  case age
90
89
  when 1
@@ -39,23 +39,32 @@ class DailyWipByBlockedStalledChart < DailyWipChart
39
39
  end
40
40
 
41
41
  def default_grouping_rules issue:, rules:
42
- started, stopped = issue.board.cycletime.started_stopped_times(issue)
42
+ started, stopped = issue.started_stopped_times
43
43
  stopped_date = stopped&.to_date
44
+ started_date = started&.to_date
44
45
 
45
46
  date = rules.current_date
46
47
  change = issue.blocked_stalled_by_date(date_range: date..date, chart_end_time: time_range.end)[date]
47
-
48
48
  stopped_today = stopped_date == rules.current_date
49
49
 
50
+ days = nil
51
+ if started_date && stopped_date
52
+ days = (stopped_date - started_date).to_i + 1 # cycletime
53
+ elsif started_date
54
+ days = (time_range.end.to_date - started_date).to_i + 1 # age
55
+ end
56
+
50
57
  if stopped_today && started.nil?
51
58
  @has_completed_but_not_started = true
52
59
  rules.label = 'Completed but not started'
53
60
  rules.color = '--wip-chart-completed-but-not-started-color'
54
61
  rules.group_priority = -1
62
+ rules.issue_hint = '(Cycle time: Unknown)'
55
63
  elsif stopped_today
56
64
  rules.label = 'Completed'
57
65
  rules.color = '--wip-chart-completed-color'
58
66
  rules.group_priority = -2
67
+ rules.issue_hint = "(Cycle time: #{label_days days})"
59
68
  elsif started.nil?
60
69
  rules.label = 'Start date unknown'
61
70
  rules.color = '--body-background'
@@ -64,16 +73,17 @@ class DailyWipByBlockedStalledChart < DailyWipChart
64
73
  rules.label = 'Blocked'
65
74
  rules.color = '--blocked-color'
66
75
  rules.group_priority = 1
67
- rules.issue_hint = "(#{change.reasons})"
76
+ rules.issue_hint = "(Age: #{label_days days}, #{change.reasons})"
68
77
  elsif change&.stalled?
69
78
  rules.label = 'Stalled'
70
79
  rules.color = '--stalled-color'
71
80
  rules.group_priority = 2
72
- rules.issue_hint = "(#{change.reasons})"
81
+ rules.issue_hint = "(Age: #{label_days days}, #{change.reasons})"
73
82
  else
74
83
  rules.label = 'Active'
75
84
  rules.color = '--wip-chart-active-color'
76
85
  rules.group_priority = 3
86
+ rules.issue_hint = "(Age: #{label_days days})"
77
87
  end
78
88
  end
79
89
  end
@@ -26,11 +26,13 @@ class DailyWipByParentChart < DailyWipChart
26
26
  end
27
27
 
28
28
  def default_grouping_rules issue:, rules:
29
- parent = issue.parent&.key
29
+ parent = issue.parent
30
30
  if parent
31
- rules.label = parent
31
+ rules.label = parent.key
32
+ rules.label_hint = "#{parent.key} : #{parent.summary}"
32
33
  else
33
34
  rules.label = 'No parent'
35
+ rules.label_hint = 'No parent'
34
36
  rules.group_priority = 1000
35
37
  rules.color = '--body-background'
36
38
  end