jirametrics 2.4 → 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 (100) hide show
  1. checksums.yaml +4 -4
  2. data/bin/jirametrics-mcp +5 -0
  3. data/lib/jirametrics/aggregate_config.rb +16 -3
  4. data/lib/jirametrics/aging_work_bar_chart.rb +193 -133
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +138 -42
  6. data/lib/jirametrics/aging_work_table.rb +63 -19
  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 +6 -4
  11. data/lib/jirametrics/board.rb +74 -22
  12. data/lib/jirametrics/board_config.rb +11 -3
  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 +54 -18
  17. data/lib/jirametrics/chart_base.rb +203 -30
  18. data/lib/jirametrics/css_variable.rb +2 -2
  19. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  20. data/lib/jirametrics/cycle_time_config.rb +137 -0
  21. data/lib/jirametrics/cycletime_histogram.rb +17 -38
  22. data/lib/jirametrics/cycletime_scatterplot.rb +18 -87
  23. data/lib/jirametrics/daily_view.rb +306 -0
  24. data/lib/jirametrics/daily_wip_by_age_chart.rb +5 -8
  25. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +15 -5
  26. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -6
  27. data/lib/jirametrics/daily_wip_chart.rb +36 -16
  28. data/lib/jirametrics/data_quality_report.rb +251 -42
  29. data/lib/jirametrics/dependency_chart.rb +42 -12
  30. data/lib/jirametrics/download_config.rb +27 -0
  31. data/lib/jirametrics/downloader.rb +185 -110
  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 +75 -14
  35. data/lib/jirametrics/estimation_configuration.rb +25 -0
  36. data/lib/jirametrics/examples/aggregated_project.rb +9 -23
  37. data/lib/jirametrics/examples/standard_project.rb +57 -58
  38. data/lib/jirametrics/expedited_chart.rb +11 -10
  39. data/lib/jirametrics/exporter.rb +51 -14
  40. data/lib/jirametrics/file_config.rb +21 -6
  41. data/lib/jirametrics/file_system.rb +96 -4
  42. data/lib/jirametrics/fix_version.rb +13 -0
  43. data/lib/jirametrics/flow_efficiency_scatterplot.rb +115 -0
  44. data/lib/jirametrics/github_gateway.rb +115 -0
  45. data/lib/jirametrics/groupable_issue_chart.rb +12 -4
  46. data/lib/jirametrics/grouping_rules.rb +26 -4
  47. data/lib/jirametrics/html/aging_work_bar_chart.erb +8 -17
  48. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +24 -5
  49. data/lib/jirametrics/html/aging_work_table.erb +13 -4
  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 +41 -15
  53. data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
  54. data/lib/jirametrics/html/expedited_chart.erb +7 -24
  55. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +81 -0
  56. data/lib/jirametrics/html/hierarchy_table.erb +1 -1
  57. data/lib/jirametrics/html/index.css +336 -62
  58. data/lib/jirametrics/html/index.erb +16 -21
  59. data/lib/jirametrics/html/index.js +164 -0
  60. data/lib/jirametrics/html/legacy_colors.css +174 -0
  61. data/lib/jirametrics/html/sprint_burndown.erb +18 -25
  62. data/lib/jirametrics/html/throughput_chart.erb +43 -21
  63. data/lib/jirametrics/html/time_based_histogram.erb +123 -0
  64. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +16 -21
  65. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  66. data/lib/jirametrics/html_generator.rb +32 -0
  67. data/lib/jirametrics/html_report_config.rb +83 -76
  68. data/lib/jirametrics/issue.rb +499 -91
  69. data/lib/jirametrics/issue_collection.rb +33 -0
  70. data/lib/jirametrics/issue_printer.rb +97 -0
  71. data/lib/jirametrics/jira_gateway.rb +96 -16
  72. data/lib/jirametrics/mcp_server.rb +531 -0
  73. data/lib/jirametrics/project_config.rb +374 -130
  74. data/lib/jirametrics/pull_request.rb +30 -0
  75. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  76. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  77. data/lib/jirametrics/pull_request_review.rb +13 -0
  78. data/lib/jirametrics/raw_javascript.rb +17 -0
  79. data/lib/jirametrics/rules.rb +2 -2
  80. data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
  81. data/lib/jirametrics/settings.json +10 -2
  82. data/lib/jirametrics/sprint.rb +13 -0
  83. data/lib/jirametrics/sprint_burndown.rb +47 -39
  84. data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
  85. data/lib/jirametrics/status.rb +84 -19
  86. data/lib/jirametrics/status_collection.rb +83 -38
  87. data/lib/jirametrics/stitcher.rb +81 -0
  88. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  89. data/lib/jirametrics/throughput_chart.rb +73 -23
  90. data/lib/jirametrics/time_based_histogram.rb +139 -0
  91. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  92. data/lib/jirametrics/user.rb +12 -0
  93. data/lib/jirametrics/value_equality.rb +2 -2
  94. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  95. data/lib/jirametrics.rb +101 -66
  96. metadata +72 -16
  97. data/lib/jirametrics/cycletime_config.rb +0 -69
  98. data/lib/jirametrics/discard_changes_before.rb +0 -37
  99. data/lib/jirametrics/html/cycletime_histogram.erb +0 -47
  100. data/lib/jirametrics/html/data_quality_report.erb +0 -126
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/self_or_issue_dispatcher'
4
+ require 'date'
5
+
6
+ class CycleTimeConfig
7
+ include SelfOrIssueDispatcher
8
+
9
+ attr_reader :label, :settings, :file_system
10
+
11
+ def initialize possible_statuses:, label:, block:, settings:, file_system: nil, today: Date.today
12
+ @possible_statuses = possible_statuses
13
+ @label = label
14
+ @today = today
15
+ @settings = settings
16
+
17
+ # If we hit something deprecated and this is nil then we'll blow up. Although it's ugly, this
18
+ # may make it easier to find problems in the test code ;-)
19
+ @file_system = file_system
20
+ instance_eval(&block) unless block.nil?
21
+ end
22
+
23
+ def start_at block = nil
24
+ @start_at = block unless block.nil?
25
+ @start_at
26
+ end
27
+
28
+ def stop_at block = nil
29
+ @stop_at = block unless block.nil?
30
+ @stop_at
31
+ end
32
+
33
+ def in_progress? issue
34
+ started_time, stopped_time = started_stopped_times(issue)
35
+ started_time && stopped_time.nil?
36
+ end
37
+
38
+ def done? issue
39
+ started_stopped_times(issue).last
40
+ end
41
+
42
+ def started_time issue
43
+ @file_system.deprecated date: '2024-10-16', message: 'Use started_stopped_times() instead'
44
+ started_stopped_times(issue).first
45
+ end
46
+
47
+ def stopped_time issue
48
+ @file_system.deprecated date: '2024-10-16', message: 'Use started_stopped_times() instead'
49
+ started_stopped_times(issue).last
50
+ end
51
+
52
+ def fabricate_change_item time
53
+ @file_system.deprecated(
54
+ date: '2024-12-16', message: "This method should now return a ChangeItem not a #{time.class}", depth: 4
55
+ )
56
+ raw = {
57
+ 'field' => 'Fabricated change',
58
+ 'to' => '0',
59
+ 'toString' => '',
60
+ 'from' => '0',
61
+ 'fromString' => ''
62
+ }
63
+ ChangeItem.new raw: raw, time: time, artificial: true, author_raw: nil
64
+ end
65
+
66
+ def started_stopped_changes issue
67
+ cache_key = "#{issue.key}:#{issue.board.id}"
68
+ last_result = (@cache ||= {})[cache_key]
69
+ return *last_result if last_result && settings['cache_cycletime_calculations']
70
+
71
+ started = @start_at.call(issue)
72
+ stopped = @stop_at.call(issue)
73
+
74
+ # Obscure edge case where some of the start_at and stop_at blocks might return false in place of nil.
75
+ # If they are false then explicitly make them nil.
76
+ started ||= nil
77
+ stopped ||= nil
78
+
79
+ # These are only here for backwards compatibility. Hopefully nobody will ever need them.
80
+ started = fabricate_change_item(started) if !started.nil? && !started.is_a?(ChangeItem)
81
+ stopped = fabricate_change_item(stopped) if !stopped.nil? && !stopped.is_a?(ChangeItem)
82
+
83
+ # In the case where started and stopped are exactly the same time, we pretend that
84
+ # it just stopped and never started. This allows us to have logic like 'in or right of'
85
+ # for the start and not have it conflict.
86
+ started = nil if started&.time == stopped&.time
87
+
88
+ result = [started, stopped]
89
+ if last_result && result != last_result
90
+ @file_system.error(
91
+ "Calculation mismatch; this could break caching. #{issue.inspect} new=#{result.inspect}, " \
92
+ "previous=#{last_result.inspect}"
93
+ )
94
+ end
95
+ @cache[cache_key] = result
96
+ result
97
+ end
98
+
99
+ def started_stopped_times issue
100
+ started, stopped = started_stopped_changes(issue)
101
+ [started&.time, stopped&.time]
102
+ end
103
+
104
+ def flush_cache
105
+ @cache = nil
106
+ end
107
+
108
+ def started_stopped_dates issue
109
+ started_time, stopped_time = started_stopped_times(issue)
110
+ [started_time&.to_date, stopped_time&.to_date]
111
+ end
112
+
113
+ def cycletime issue
114
+ start, stop = started_stopped_times(issue)
115
+ return nil if start.nil? || stop.nil?
116
+
117
+ (stop.to_date - start.to_date).to_i + 1
118
+ end
119
+
120
+ def age issue, today: nil
121
+ start = started_stopped_times(issue).first
122
+ stop = today || @today || Date.today
123
+ return nil if start.nil? || stop.nil?
124
+
125
+ (stop.to_date - start.to_date).to_i + 1
126
+ end
127
+
128
+ def possible_statuses
129
+ if parent_config.is_a? BoardConfig
130
+ project_config = parent_config.project_config
131
+ else
132
+ # TODO: This will go away when cycletimes are no longer supported inside html_reports
133
+ project_config = parent_config.file_config.project_config
134
+ end
135
+ project_config.possible_statuses
136
+ end
137
+ end
@@ -1,14 +1,16 @@
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
7
 
9
8
  def initialize block
10
9
  super()
11
10
 
11
+ @x_axis_title = 'Cycletime in days'
12
+ @y_axis_title = 'Count'
13
+
12
14
  header_text 'Cycletime Histogram'
13
15
  description_text <<-HTML
14
16
  <p>
@@ -26,49 +28,26 @@ class CycletimeHistogram < ChartBase
26
28
  end
27
29
  end
28
30
 
29
- def run
31
+ def all_items
30
32
  stopped_issues = completed_issues_in_range include_unstarted: true
31
33
 
32
34
  # For the histogram, we only want to consider items that have both a start and a stop time.
33
- histogram_issues = stopped_issues.select { |issue| issue.board.cycletime.started_time(issue) }
34
- rules_to_issues = group_issues histogram_issues
35
-
36
- data_sets = rules_to_issues.keys.collect do |rules|
37
- data_set_for(
38
- histogram_data: histogram_data_for(issues: rules_to_issues[rules]),
39
- label: rules.label,
40
- color: rules.color
41
- )
42
- end
35
+ stopped_issues.select { |issue| issue.started_stopped_times.first }
36
+ end
43
37
 
44
- wrap_and_render(binding, __FILE__)
38
+ def value_for_item issue
39
+ issue.board.cycletime.cycletime(issue)
45
40
  end
46
41
 
47
- def histogram_data_for issues:
48
- count_hash = {}
49
- issues.each do |issue|
50
- days = issue.board.cycletime.cycletime(issue)
51
- count_hash[days] = (count_hash[days] || 0) + 1 if days.positive?
52
- end
53
- count_hash
42
+ def title_for_item count:, value:
43
+ "#{count} items completed in #{label_days value}"
54
44
  end
55
45
 
56
- def data_set_for histogram_data:, label:, color:
57
- keys = histogram_data.keys.sort
58
- {
59
- type: 'bar',
60
- label: label,
61
- data: keys.sort.filter_map do |key|
62
- next if histogram_data[key].zero?
46
+ def sort_items items
47
+ items.sort_by(&:key_as_i)
48
+ end
63
49
 
64
- {
65
- x: key,
66
- y: histogram_data[key],
67
- title: "#{histogram_data[key]} items completed in #{label_days key}"
68
- }
69
- end,
70
- backgroundColor: color,
71
- borderRadius: 0
72
- }
50
+ def label_for_item issue, hint:
51
+ "#{issue.key} : #{issue.summary}#{" #{hint}" if hint}"
73
52
  end
74
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
@@ -24,8 +22,10 @@ class CycletimeScatterplot < ChartBase
24
22
  predict that most work of this type will complete in <%= overall_percent_line %> days or
25
23
  less. The other lines reflect the 85% line for that respective type of work.
26
24
  </div>
27
- #{ describe_non_working_days }
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,98 +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
- groups = group_issues completed_issues
57
-
58
- groups.each_key do |rules|
59
- completed_issues_by_type = groups[rules]
60
- label = rules.label
61
- color = rules.color
62
- percent_line = calculate_percent_line completed_issues_by_type
63
- data = completed_issues_by_type.filter_map { |issue| data_for_issue(issue) }
64
- data_sets << {
65
- label: "#{label} (85% at #{label_days(percent_line)})",
66
- data: data,
67
- fill: false,
68
- showLine: false,
69
- backgroundColor: color
70
- }
71
-
72
- data_sets << trend_line_data_set(label: label, data: data, color: color)
73
-
74
- @percentage_lines << [percent_line, color]
75
- end
76
- data_sets
42
+ def all_items
43
+ completed_issues_in_range include_unstarted: false
77
44
  end
78
45
 
79
- def show_trend_lines
80
- @show_trend_lines = true
46
+ def x_value item
47
+ item.started_stopped_times.last
81
48
  end
82
49
 
83
- def trend_line_data_set label:, data:, color:
84
- points = data.collect do |hash|
85
- [Time.parse(hash[:x]).to_i, hash[:y]]
86
- end
87
-
88
- # The trend calculation works with numbers only so convert Time to an int and back
89
- calculator = TrendLineCalculator.new(points)
90
- data_points = calculator.chart_datapoints(
91
- range: time_range.begin.to_i..time_range.end.to_i,
92
- max_y: @highest_cycletime
93
- )
94
- data_points.each do |point_hash|
95
- point_hash[:x] = chart_format Time.at(point_hash[:x])
96
- end
97
-
98
- {
99
- type: 'line',
100
- label: "#{label} Trendline",
101
- data: data_points,
102
- fill: false,
103
- borderWidth: 1,
104
- markerType: 'none',
105
- borderColor: color,
106
- borderDash: [6, 3],
107
- pointStyle: 'dash',
108
- hidden: !@show_trend_lines
109
- }
50
+ def y_value item
51
+ item.board.cycletime.cycletime(item)
110
52
  end
111
53
 
112
- def data_for_issue issue
113
- cycle_time = issue.board.cycletime.cycletime(issue)
114
- return nil if cycle_time < 1 # These will get called out on the quality report
115
-
116
- @highest_cycletime = cycle_time if @highest_cycletime < cycle_time
117
-
118
- {
119
- y: cycle_time,
120
- x: chart_format(issue.board.cycletime.stopped_time(issue)),
121
- title: ["#{issue.key} : #{issue.summary} (#{label_days(cycle_time)})"]
122
- }
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}"
123
57
  end
124
58
 
125
- def calculate_percent_line completed_issues
126
- times = completed_issues.collect { |issue| issue.board.cycletime.cycletime(issue) }
127
- index = times.size * 85 / 100
128
- times.sort[index]
129
- end
59
+ # Kept for backwards compatibility with existing callers and specs
60
+ alias data_for_issue data_for_item
130
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
@@ -4,7 +4,7 @@ require 'jirametrics/daily_wip_chart'
4
4
 
5
5
  class DailyWipByAgeChart < DailyWipChart
6
6
  def initialize block
7
- super(block)
7
+ super
8
8
 
9
9
  add_trend_line line_color: '--aging-work-in-progress-by-age-trend-line-color', group_labels: [
10
10
  'Less than a day',
@@ -49,11 +49,7 @@ class DailyWipByAgeChart < DailyWipChart
49
49
  end
50
50
 
51
51
  def default_grouping_rules issue:, rules:
52
- cycletime = issue.board.cycletime
53
- started = cycletime.started_time(issue)&.to_date
54
- stopped = cycletime.stopped_time(issue)&.to_date
55
-
56
- rules.issue_hint = "(age: #{label_days (rules.current_date - started + 1).to_i})" if started
52
+ started, stopped = issue.started_stopped_dates
57
53
 
58
54
  if stopped && started.nil? # We can't tell when it started
59
55
  @has_completed_but_not_started = true
@@ -74,7 +70,7 @@ class DailyWipByAgeChart < DailyWipChart
74
70
  rules.label = 'Start date unknown'
75
71
  rules.color = '--body-background'
76
72
  rules.group_priority = 11
77
- created_days = rules.current_date - created + 1
73
+ created_days = rules.current_date - created
78
74
  rules.issue_hint = "(created: #{label_days created_days.to_i} earlier, stopped on #{stopped})"
79
75
  end
80
76
  end
@@ -86,7 +82,8 @@ class DailyWipByAgeChart < DailyWipChart
86
82
  end
87
83
 
88
84
  def group_by_age started:, rules:
89
- age = rules.current_date - started + 1
85
+ age = (rules.current_date - started).to_i + 1
86
+ rules.issue_hint = "(age: #{label_days age})"
90
87
 
91
88
  case age
92
89
  when 1