jirametrics 2.20 → 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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aging_work_bar_chart.rb +176 -134
  3. data/lib/jirametrics/bar_chart_range.rb +17 -0
  4. data/lib/jirametrics/board.rb +4 -0
  5. data/lib/jirametrics/board_config.rb +2 -1
  6. data/lib/jirametrics/change_item.rb +10 -3
  7. data/lib/jirametrics/chart_base.rb +31 -0
  8. data/lib/jirametrics/cycletime_config.rb +4 -5
  9. data/lib/jirametrics/cycletime_scatterplot.rb +36 -17
  10. data/lib/jirametrics/daily_wip_by_age_chart.rb +3 -4
  11. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +13 -3
  12. data/lib/jirametrics/daily_wip_chart.rb +1 -1
  13. data/lib/jirametrics/data_quality_report.rb +2 -0
  14. data/lib/jirametrics/exporter.rb +4 -2
  15. data/lib/jirametrics/fix_version.rb +13 -0
  16. data/lib/jirametrics/groupable_issue_chart.rb +7 -1
  17. data/lib/jirametrics/html/aging_work_bar_chart.erb +2 -1
  18. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +2 -0
  19. data/lib/jirametrics/html/aging_work_table.erb +2 -0
  20. data/lib/jirametrics/html/cycletime_histogram.erb +2 -0
  21. data/lib/jirametrics/html/cycletime_scatterplot.erb +6 -6
  22. data/lib/jirametrics/html/daily_wip_chart.erb +2 -0
  23. data/lib/jirametrics/html/estimate_accuracy_chart.erb +2 -0
  24. data/lib/jirametrics/html/expedited_chart.erb +3 -1
  25. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +2 -0
  26. data/lib/jirametrics/html/index.css +17 -0
  27. data/lib/jirametrics/html/index.erb +1 -1
  28. data/lib/jirametrics/html/index.js +24 -0
  29. data/lib/jirametrics/html/sprint_burndown.erb +6 -0
  30. data/lib/jirametrics/html/throughput_chart.erb +2 -2
  31. data/lib/jirametrics/html_generator.rb +31 -0
  32. data/lib/jirametrics/html_report_config.rb +5 -24
  33. data/lib/jirametrics/issue.rb +97 -4
  34. data/lib/jirametrics/jira_gateway.rb +1 -1
  35. data/lib/jirametrics/project_config.rb +12 -2
  36. data/lib/jirametrics/raw_javascript.rb +13 -0
  37. data/lib/jirametrics/sprint.rb +12 -0
  38. data/lib/jirametrics/sprint_burndown.rb +6 -2
  39. data/lib/jirametrics/stitcher.rb +75 -0
  40. data/lib/jirametrics.rb +8 -1
  41. metadata +5 -1
@@ -51,8 +51,6 @@ class DailyWipByAgeChart < DailyWipChart
51
51
  def default_grouping_rules issue:, rules:
52
52
  started, stopped = issue.board.cycletime.started_stopped_dates(issue)
53
53
 
54
- rules.issue_hint = "(age: #{label_days (rules.current_date - started + 1).to_i})" if started
55
-
56
54
  if stopped && started.nil? # We can't tell when it started
57
55
  @has_completed_but_not_started = true
58
56
  not_started stopped: stopped, rules: rules, created: issue.created.to_date
@@ -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
@@ -41,21 +41,30 @@ class DailyWipByBlockedStalledChart < DailyWipChart
41
41
  def default_grouping_rules issue:, rules:
42
42
  started, stopped = issue.board.cycletime.started_stopped_times(issue)
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
@@ -66,7 +66,7 @@ class DailyWipChart < ChartBase
66
66
  hash = {}
67
67
 
68
68
  @issues.each do |issue|
69
- start, stop = issue.board.cycletime.started_stopped_dates(issue)
69
+ start, stop = cycletime_for_issue(issue).started_stopped_dates(issue)
70
70
  next if start.nil? && stop.nil?
71
71
 
72
72
  # If it stopped but never started then assume it started at creation so the data points
@@ -266,6 +266,8 @@ class DataQualityReport < ChartBase
266
266
 
267
267
  def scan_for_items_blocked_on_closed_tickets entry:
268
268
  entry.issue.issue_links.each do |link|
269
+ next unless settings['blocked_link_text'].include?(link.label)
270
+
269
271
  this_active = !entry.stopped
270
272
  other_active = !link.other_issue.board.cycletime.started_stopped_times(link.other_issue).last
271
273
  next unless this_active && !other_active
@@ -50,8 +50,6 @@ class Exporter
50
50
  end
51
51
 
52
52
  project.download_config.run
53
- # load_jira_config(download_config.project_config.jira_config)
54
- # @ignore_ssl_errors = download_config.project_config.settings['ignore_ssl_errors']
55
53
  gateway = JiraGateway.new(
56
54
  file_system: file_system, jira_config: project.jira_config, settings: project.settings
57
55
  )
@@ -90,6 +88,10 @@ class Exporter
90
88
  end
91
89
  end
92
90
 
91
+ def stitch stitch_file
92
+ Stitcher.new(file_system: file_system).run(stitch_file: stitch_file)
93
+ end
94
+
93
95
  def each_project_config name_filter:
94
96
  @project_configs.each do |project|
95
97
  yield project if project.name.nil? || File.fnmatch(name_filter, project.name)
@@ -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
@@ -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>
@@ -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,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>
@@ -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 %>
@@ -67,10 +67,21 @@
67
67
  --sprint-burndown-sprint-color-4: red;
68
68
  --sprint-burndown-sprint-color-5: brown;
69
69
 
70
+ --sprint-color: lightblue;
71
+
70
72
  --daily-view-selected-issue-background: lightgray;
71
73
  --daily-view-issue-border: green;
72
74
  --daily-view-selected-issue-border: red;
73
75
 
76
+ /* The first five are the standard priorities that Jira creates by default. */
77
+ --priority-color-highest: #dc2626; /* red-600 - urgent red */
78
+ --priority-color-high: #ea580c; /* orange-600 - warning orange */
79
+ --priority-color-medium: #9ca3af; /* gray-400 - neutral light gray */
80
+ --priority-color-low: #0891b2; /* cyan-600 - calm blue */
81
+ --priority-color-lowest: #64748b; /* slate-500 - muted slate */
82
+ /* Then here are some values we've seen in multiple instances. */
83
+ --priority-color-notset: gray;
84
+ --priority-color-critical: red;
74
85
  }
75
86
 
76
87
  body {
@@ -237,6 +248,12 @@ div.child_issue {
237
248
  --wip-chart-duration-more-than-four-weeks-color: #8e0000;
238
249
 
239
250
  --daily-view-selected-issue-background: #474747;
251
+
252
+ --priority-color-highest: #ef4444; /* red-500 - bright urgent red */
253
+ --priority-color-high: #f97316; /* orange-500 - bright orange */
254
+ --priority-color-medium: #9ca3af; /* gray-400 - neutral light gray */
255
+ --priority-color-low: #06b6d4; /* cyan-500 - bright calm blue */
256
+ --priority-color-lowest: #94a3b8; /* slate-400 - muted light slate */
240
257
  }
241
258
 
242
259
  a[href] {
@@ -23,6 +23,6 @@
23
23
  </div>
24
24
  </noscript>
25
25
  <%= "\n" + @sections.collect { |text, type| text if type == :header }.compact.join("\n\n") %>
26
- <%= "\n" + @sections.collect { |text, type| text if type == :body }.compact.join("\n\n") %>
26
+ <%= "\n" + @sections.collect { |text, type| text if type != :header }.compact.join("\n\n") %>
27
27
  </body>
28
28
  </html>
@@ -88,3 +88,27 @@ document.addEventListener('DOMContentLoaded', function() {
88
88
  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {
89
89
  location.reload()
90
90
  })
91
+
92
+ // Draw a diagonal pattern to highlight sections of a bar chart. Based on code found at:
93
+ // https://stackoverflow.com/questions/28569667/fill-chart-js-bar-chart-with-diagonal-stripes-or-other-patterns
94
+ function createDiagonalPattern(color = 'black') {
95
+ // create a 5x5 px canvas for the pattern's base shape
96
+ let shape = document.createElement('canvas')
97
+ shape.width = 5
98
+ shape.height = 5
99
+ // get the context for drawing
100
+ let c = shape.getContext('2d')
101
+ // draw 1st line of the shape
102
+ c.strokeStyle = color
103
+ c.beginPath()
104
+ c.moveTo(1, 0)
105
+ c.lineTo(5, 4)
106
+ c.stroke()
107
+ // draw 2nd line of the shape
108
+ c.beginPath()
109
+ c.moveTo(0, 4)
110
+ c.lineTo(1, 5)
111
+ c.stroke()
112
+ // create the pattern from the shape
113
+ return c.createPattern(shape, 'repeat')
114
+ }
@@ -1,5 +1,6 @@
1
1
  <h2>Burndown by <%= y_axis_title %></h2>
2
2
 
3
+ <%= seam_start %>
3
4
  <div class="chart">
4
5
  <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
5
6
  </div>
@@ -63,6 +64,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
63
64
  }
64
65
  });
65
66
  </script>
67
+ <%= seam_end %>
66
68
 
67
69
  <%
68
70
  link_id = next_id
@@ -71,9 +73,11 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
71
73
  <section>
72
74
  <div class='foldable startFolded'>Show statistics</div>
73
75
  <div id="<%= issues_id %>">
76
+ <%= seam_start 'stats_table' %>
74
77
  <table class='standard' style="margin-left: 1em;">
75
78
  <thead>
76
79
  <th>Sprint</th>
80
+ <th>Length</th>
77
81
  <th>State</th>
78
82
  <th>Started</th>
79
83
  <th>Completed</th>
@@ -86,6 +90,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
86
90
  <% @summary_stats.keys.sort_by(&:start_time).each do |sprint| %>
87
91
  <tr>
88
92
  <td><%= sprint.name %></td>
93
+ <td><%= sprint.day_count %></td>
89
94
  <td><%= sprint.raw['state'] %></td>
90
95
  <% stats = @summary_stats[sprint] %>
91
96
  <td><%= stats.started %></td>
@@ -102,6 +107,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
102
107
  <% end %>
103
108
  </tbody>
104
109
  </table>
110
+ <%= seam_end 'stats_table' %>
105
111
 
106
112
  <p>Legend:
107
113
  <ul>
@@ -1,4 +1,4 @@
1
-
1
+ <%= seam_start %>
2
2
  <div class="chart">
3
3
  <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
4
4
  </div>
@@ -59,4 +59,4 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
59
59
  }
60
60
  });
61
61
  </script>
62
-
62
+ <%= seam_end %>
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ class HtmlGenerator
4
+ attr_accessor :file_system, :settings
5
+
6
+ def create_html output_filename:, settings:
7
+ @settings = settings
8
+ html_directory = "#{Pathname.new(File.realpath(__FILE__)).dirname}/html"
9
+ css = load_css html_directory: html_directory
10
+ javascript = file_system.load(File.join(html_directory, 'index.js'))
11
+ erb = ERB.new file_system.load(File.join(html_directory, 'index.erb'))
12
+ file_system.save_file content: erb.result(binding), filename: output_filename
13
+ end
14
+
15
+ def load_css html_directory:
16
+ base_css_filename = File.join(html_directory, 'index.css')
17
+ base_css = file_system.load(base_css_filename)
18
+
19
+ extra_css_filename = settings['include_css']
20
+ if extra_css_filename
21
+ if File.exist?(extra_css_filename)
22
+ base_css << "\n\n" << file_system.load(extra_css_filename)
23
+ log("Loaded CSS: #{extra_css_filename}")
24
+ else
25
+ log("Unable to find specified CSS file: #{extra_css_filename}")
26
+ end
27
+ end
28
+
29
+ base_css
30
+ end
31
+ end
@@ -3,7 +3,7 @@
3
3
  require 'erb'
4
4
  require 'jirametrics/self_or_issue_dispatcher'
5
5
 
6
- class HtmlReportConfig
6
+ class HtmlReportConfig < HtmlGenerator
7
7
  include SelfOrIssueDispatcher
8
8
 
9
9
  attr_reader :file_config, :sections, :charts
@@ -52,7 +52,8 @@ class HtmlReportConfig
52
52
  raise 'Multiple cycletimes not supported' if board.cycletime
53
53
 
54
54
  board.cycletime = CycleTimeConfig.new(
55
- parent_config: self, label: label, block: block, file_system: file_system, settings: settings
55
+ possible_statuses: file_config.project_config, label: label, block: block,
56
+ file_system: file_system, settings: settings
56
57
  )
57
58
  end
58
59
  end
@@ -72,11 +73,7 @@ class HtmlReportConfig
72
73
 
73
74
  html create_footer
74
75
 
75
- html_directory = "#{Pathname.new(File.realpath(__FILE__)).dirname}/html"
76
- css = load_css html_directory: html_directory
77
- javascript = file_system.load(File.join(html_directory, 'index.js'))
78
- erb = ERB.new file_system.load(File.join(html_directory, 'index.erb'))
79
- file_system.save_file content: erb.result(binding), filename: @file_config.output_filename
76
+ create_html output_filename: @file_config.output_filename, settings: settings
80
77
  end
81
78
 
82
79
  def file_system
@@ -87,23 +84,6 @@ class HtmlReportConfig
87
84
  file_system.log message
88
85
  end
89
86
 
90
- def load_css html_directory:
91
- base_css_filename = File.join(html_directory, 'index.css')
92
- base_css = file_system.load(base_css_filename)
93
-
94
- extra_css_filename = settings['include_css']
95
- if extra_css_filename
96
- if File.exist?(extra_css_filename)
97
- base_css << "\n\n" << file_system.load(extra_css_filename)
98
- log("Loaded CSS: #{extra_css_filename}")
99
- else
100
- log("Unable to find specified CSS file: #{extra_css_filename}")
101
- end
102
- end
103
-
104
- base_css
105
- end
106
-
107
87
  def board_id id
108
88
  @board_id = id
109
89
  end
@@ -175,6 +155,7 @@ class HtmlReportConfig
175
155
  after_init_block&.call chart
176
156
 
177
157
  @charts << chart
158
+ chart.before_run
178
159
  html chart.run
179
160
  end
180
161
 
@@ -212,8 +212,87 @@ class Issue
212
212
  first_time_in_status(*board.visible_columns.collect(&:status_ids).flatten)
213
213
  end
214
214
 
215
+ # If this issue will ever be in an active sprint then return the time that it
216
+ # was first added to that sprint, whether or not the sprint was active at that
217
+ # time. Although it seems like an odd thing to calculate, it's a reasonable proxy
218
+ # for 'ready' in cases where the team doesn't have an explicit 'ready' status.
219
+ # You'd be better off with an explicit 'ready' but sometimes that's not an option.
220
+ def first_time_added_to_active_sprint
221
+ unless board.scrum?
222
+ raise 'first_time_added_to_active_sprint() can only be used with Scrum boards: ' \
223
+ "issue=#{key}, board=#{board.inspect}"
224
+ end
225
+ data_clazz = Struct.new(:sprint_id, :sprint_start, :sprint_stop, :change)
226
+
227
+ matching_changes = []
228
+ all_datas = []
229
+
230
+ @changes.each do |change|
231
+ next unless change.sprint?
232
+
233
+ added_sprint_ids = change.value_id - change.old_value_id
234
+ added_sprint_ids.each do |id|
235
+ data = data_clazz.new
236
+ data.sprint_id = id
237
+ data.change = change
238
+ data.sprint_start, data.sprint_stop = find_sprint_start_end(sprint_id: id, change: change)
239
+ all_datas << data
240
+ end
241
+
242
+ removed_sprint_ids = change.old_value_id - change.value_id
243
+ removed_sprint_ids.each do |id|
244
+ data = all_datas.find { |d| d.sprint_id == id }
245
+ # It's possible for an issue to be created inside a sprint and therefore for
246
+ # that add-to-sprint not show in the history.
247
+ next unless data
248
+
249
+ all_datas.delete(data)
250
+ next if data.sprint_start.nil? || data.sprint_start >= change.time
251
+
252
+ matching_changes << data.change
253
+ end
254
+ end
255
+
256
+ # There can't be any more removes so whatever is left is a valid option
257
+ # Now all we care about is if the sprint has started.
258
+ all_datas.each do |data|
259
+ matching_changes << data.change if data.sprint_start
260
+ end
261
+
262
+ matching_changes.min_by(&:time)
263
+ end
264
+
265
+ def find_sprint_start_end sprint_id:, change:
266
+ # There are two different places that sprint data could be found. In theory all
267
+ # sprints would be found in both places. In practice, sometimes what we need is
268
+ # in one or the other but not both.
269
+
270
+ # First look in the actual sprints json. If any issues are in this sprint then it should
271
+ # be here.
272
+ sprint = board.sprints.find { |s| s.id == sprint_id }
273
+ return [sprint.start_time, sprint.completed_time] if sprint
274
+
275
+ # Then look at the sprints inside the issue. Even though the field id may be specified,
276
+ # that custom field may not be present. This happens if it was in that sprint but was
277
+ # then removed, whether or not that sprint had ever started.
278
+ sprint_data = raw['fields'][change.field_id]&.find { |sd| sd['id'].to_i == sprint_id }
279
+ if sprint_data
280
+ start = parse_time(sprint_data['startDate'])
281
+ stop = parse_time(sprint_data['completeDate'])
282
+ return [start, stop]
283
+ end
284
+
285
+ # If we got this far then the sprint can't be found anywhere, so we pretend that it never
286
+ # started. Is this guaranteed to be true? No. In theory if all issues were removed from
287
+ # an active sprint then it would also disappear, even though it had started. Nothing we
288
+ # can do to detect that edge-case though.
289
+ [nil, nil]
290
+ end
291
+
215
292
  def parse_time text
216
- if text.is_a? String
293
+ if text.nil?
294
+ nil
295
+ elsif text.is_a? String
217
296
  Time.parse(text).getlocal(@timezone_offset)
218
297
  else
219
298
  Time.at(text / 1000).getlocal(@timezone_offset)
@@ -225,6 +304,10 @@ class Issue
225
304
  parse_time @raw['fields']['created'] if @raw['fields']['created']
226
305
  end
227
306
 
307
+ def time_created
308
+ @changes.first
309
+ end
310
+
228
311
  def updated
229
312
  parse_time @raw['fields']['updated']
230
313
  end
@@ -305,9 +388,7 @@ class Issue
305
388
  results
306
389
  end
307
390
 
308
- def blocked_stalled_changes end_time:, settings: nil
309
- settings ||= @board.project_config.settings
310
-
391
+ def blocked_stalled_statuses settings
311
392
  blocked_statuses = settings['blocked_statuses']
312
393
  stalled_statuses = settings['stalled_statuses']
313
394
  unless blocked_statuses.is_a?(Array) && stalled_statuses.is_a?(Array)
@@ -315,6 +396,14 @@ class Issue
315
396
  "stalled_statuses(#{stalled_statuses.inspect}) must both be arrays"
316
397
  end
317
398
 
399
+ [blocked_statuses, stalled_statuses]
400
+ end
401
+
402
+ def blocked_stalled_changes end_time:, settings: nil
403
+ settings ||= @board.project_config.settings
404
+
405
+ blocked_statuses, stalled_statuses = blocked_stalled_statuses(settings)
406
+
318
407
  blocked_link_texts = settings['blocked_link_text']
319
408
  stalled_threshold = settings['stalled_threshold_days']
320
409
  flagged_means_blocked = !!settings['flagged_means_blocked'] # rubocop:disable Style/DoubleNegation
@@ -710,6 +799,10 @@ class Issue
710
799
  board.sprints.select { |s| sprint_ids.include? s.id }
711
800
  end
712
801
 
802
+ def started_sprints
803
+ sprints.reject { |sprint| sprint.future? }
804
+ end
805
+
713
806
  def compact_text text, max: 60
714
807
  return '' if text.nil?
715
808
 
@@ -67,7 +67,7 @@ class JiraGateway
67
67
 
68
68
  def sanitize_message message
69
69
  token = @jira_api_token || @jira_personal_access_token
70
- raise 'Neither Jira API Token or personal access token has been set' unless token
70
+ return message unless token # cookie based authentication
71
71
 
72
72
  message.gsub(token, '[API_TOKEN]')
73
73
  end