jirametrics 2.31 → 3.1

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +42 -29
  3. data/lib/jirametrics/aging_work_bar_chart.rb +52 -40
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +65 -60
  5. data/lib/jirametrics/aging_work_table.rb +36 -33
  6. data/lib/jirametrics/anonymizer.rb +31 -86
  7. data/lib/jirametrics/atlassian_document_format.rb +11 -4
  8. data/lib/jirametrics/blocked_stalled_by_date_builder.rb +64 -0
  9. data/lib/jirametrics/blocked_stalled_change.rb +5 -1
  10. data/lib/jirametrics/blocked_stalled_change_stream_builder.rb +194 -0
  11. data/lib/jirametrics/board.rb +23 -9
  12. data/lib/jirametrics/board_config.rb +0 -7
  13. data/lib/jirametrics/board_movement_calculator.rb +24 -21
  14. data/lib/jirametrics/cfd_data_builder.rb +46 -44
  15. data/lib/jirametrics/change_item.rb +19 -7
  16. data/lib/jirametrics/chart_base.rb +51 -65
  17. data/lib/jirametrics/cumulative_flow_diagram.rb +60 -47
  18. data/lib/jirametrics/cycle_time_config.rb +35 -46
  19. data/lib/jirametrics/cycletime_histogram.rb +7 -0
  20. data/lib/jirametrics/cycletime_scatterplot.rb +7 -0
  21. data/lib/jirametrics/daily_view.rb +83 -61
  22. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +24 -9
  23. data/lib/jirametrics/daily_wip_chart.rb +72 -45
  24. data/lib/jirametrics/data_quality_report.rb +70 -66
  25. data/lib/jirametrics/dependency_chart.rb +40 -31
  26. data/lib/jirametrics/download_config.rb +0 -3
  27. data/lib/jirametrics/downloader.rb +39 -14
  28. data/lib/jirametrics/downloader_for_cloud.rb +110 -74
  29. data/lib/jirametrics/estimate_accuracy_chart.rb +13 -13
  30. data/lib/jirametrics/examples/standard_project.rb +2 -2
  31. data/lib/jirametrics/expedited_chart.rb +47 -37
  32. data/lib/jirametrics/exporter.rb +203 -12
  33. data/lib/jirametrics/file_config.rb +19 -12
  34. data/lib/jirametrics/file_system.rb +16 -10
  35. data/lib/jirametrics/flow_efficiency_calculator.rb +62 -0
  36. data/lib/jirametrics/flow_efficiency_scatterplot.rb +4 -2
  37. data/lib/jirametrics/github_gateway.rb +52 -12
  38. data/lib/jirametrics/groupable_issue_chart.rb +3 -0
  39. data/lib/jirametrics/html/cumulative_flow_diagram.erb +3 -3
  40. data/lib/jirametrics/html/index.css +10 -4
  41. data/lib/jirametrics/html_report_config.rb +9 -29
  42. data/lib/jirametrics/issue.rb +293 -411
  43. data/lib/jirametrics/issue_collection.rb +1 -0
  44. data/lib/jirametrics/issue_printer.rb +60 -23
  45. data/lib/jirametrics/jira_gateway.rb +56 -18
  46. data/lib/jirametrics/mcp_server.rb +226 -210
  47. data/lib/jirametrics/project_config.rb +206 -133
  48. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +1 -20
  49. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +11 -33
  50. data/lib/jirametrics/rules.rb +1 -0
  51. data/lib/jirametrics/self_or_issue_dispatcher.rb +1 -3
  52. data/lib/jirametrics/sprint_burndown.rb +142 -242
  53. data/lib/jirametrics/sprint_count_measure.rb +42 -0
  54. data/lib/jirametrics/sprint_issue_change_data.rb +1 -0
  55. data/lib/jirametrics/sprint_points_measure.rb +62 -0
  56. data/lib/jirametrics/sprint_summary_stats.rb +16 -0
  57. data/lib/jirametrics/status_collection.rb +29 -5
  58. data/lib/jirametrics/stitcher.rb +21 -16
  59. data/lib/jirametrics/throughput_chart.rb +38 -24
  60. data/lib/jirametrics/time_based_chart.rb +65 -0
  61. data/lib/jirametrics/time_based_histogram.rb +30 -23
  62. data/lib/jirametrics/time_based_scatterplot.rb +11 -2
  63. data/lib/jirametrics/trend_line_calculator.rb +2 -2
  64. data/lib/jirametrics/wip_by_column_chart.rb +75 -34
  65. data/lib/jirametrics.rb +35 -4
  66. metadata +13 -20
@@ -32,8 +32,8 @@ class Stitcher < HtmlGenerator
32
32
  end
33
33
 
34
34
  def make_output_filename input_filename
35
- if /^(.+)\.erb$/ =~ input_filename
36
- "#{$1}.html"
35
+ if /^(?<base_name>.+)\.erb$/ =~ input_filename
36
+ "#{base_name}.html"
37
37
  else
38
38
  "#{input_filename}.html"
39
39
  end
@@ -48,7 +48,8 @@ class Stitcher < HtmlGenerator
48
48
  ''
49
49
  end
50
50
 
51
- def parse_file filename
51
+ # Imperative: it parses the file. The boolean is a "was it newly parsed" status, not a question.
52
+ def parse_file filename # rubocop:disable Naming/PredicateMethod
52
53
  return false if @loaded_files.include? filename
53
54
 
54
55
  # To match: <!-- seam-start | chart78 | GithubPrScatterplot | PR Scatterplot | chart -->
@@ -57,19 +58,7 @@ class Stitcher < HtmlGenerator
57
58
  file_system.load(filename).lines do |line|
58
59
  matches = line.match(regex)
59
60
  if matches
60
- if matches[:seam] == 'start'
61
- content = +''
62
- else
63
- if content.nil? || content.strip.empty?
64
- file_system.warning "Seam found with no content in #{filename.inspect}: " \
65
- "id=#{matches[:id].strip.inspect}, class=#{matches[:clazz].strip.inspect}, " \
66
- "title=#{matches[:title].strip.inspect}"
67
- end
68
- @all_stitches << Stitcher::StitchContent.new(
69
- file: filename, title: matches[:title], type: matches[:type], content: content
70
- )
71
- content = nil
72
- end
61
+ content = handle_seam matches, filename: filename, content: content
73
62
  elsif content
74
63
  content << line
75
64
  end
@@ -78,4 +67,20 @@ class Stitcher < HtmlGenerator
78
67
  @loaded_files << filename
79
68
  true
80
69
  end
70
+
71
+ # Handle one seam marker line, returning the new accumulator state: a fresh buffer for a start
72
+ # seam, or nil for an end seam (after recording the content collected since the start).
73
+ def handle_seam matches, filename:, content:
74
+ return +'' if matches[:seam] == 'start'
75
+
76
+ if content.nil? || content.strip.empty?
77
+ file_system.warning "Seam found with no content in #{filename.inspect}: " \
78
+ "id=#{matches[:id].strip.inspect}, class=#{matches[:clazz].strip.inspect}, " \
79
+ "title=#{matches[:title].strip.inspect}"
80
+ end
81
+ @all_stitches << Stitcher::StitchContent.new(
82
+ file: filename, title: matches[:title], type: matches[:type], content: content
83
+ )
84
+ nil
85
+ end
81
86
  end
@@ -7,8 +7,10 @@ class ThroughputChart < ChartBase
7
7
 
8
8
  attr_accessor :possible_statuses
9
9
 
10
- def initialize block
10
+ def initialize block, throughput_samples: nil, not_started_count: nil
11
11
  super()
12
+ @throughput_samples = throughput_samples
13
+ @not_started_count = not_started_count
12
14
 
13
15
  header_text 'Throughput Chart'
14
16
  description_text <<-TEXT
@@ -120,30 +122,42 @@ class ThroughputChart < ChartBase
120
122
  def throughput_dataset periods:, completed_issues:, label_hint: nil
121
123
  custom_mode = @issue_periods&.values&.any?
122
124
  periods.collect do |period|
123
- closed_issues = completed_issues.filter_map do |issue|
124
- stop_date = issue.started_stopped_dates.last
125
- next unless stop_date
126
-
127
- if custom_mode
128
- [stop_date, issue] if @issue_periods[issue] == period.end
129
- elsif period.include?(stop_date)
130
- [stop_date, issue]
131
- end
132
- end
125
+ closed_issues = closed_issues_in_period(period, completed_issues, custom_mode)
126
+ period_dataset(period: period, closed_issues: closed_issues, label_hint: label_hint)
127
+ end
128
+ end
129
+
130
+ def closed_issues_in_period period, completed_issues, custom_mode
131
+ completed_issues.filter_map do |issue|
132
+ stop_date = issue.started_stopped_dates.last
133
+ next unless stop_date
133
134
 
134
- date_label = "on #{period.end}"
135
- date_label = "between #{period.begin} and #{period.end}" unless period.begin == period.end
136
-
137
- with_label_hint = label_hint ? " with #{label_hint}" : ''
138
- {
139
- y: closed_issues.size,
140
- x: "#{period.end}T23:59:59",
141
- title: ["#{closed_issues.size} items closed#{with_label_hint} #{date_label}"] +
142
- closed_issues.collect do |_stop_date, issue|
143
- hint = @issue_hints&.fetch(issue, nil)
144
- "#{issue.key} : #{issue.summary}#{" #{hint}" if hint}"
145
- end
146
- }
135
+ if custom_mode
136
+ issue if @issue_periods[issue] == period.end
137
+ elsif period.include?(stop_date)
138
+ issue
139
+ end
147
140
  end
148
141
  end
142
+
143
+ def period_dataset period:, closed_issues:, label_hint:
144
+ label_hint_text = label_hint ? " with #{label_hint}" : ''
145
+ header = "#{closed_issues.size} items closed#{label_hint_text} #{period_label(period)}"
146
+ {
147
+ y: closed_issues.size,
148
+ x: "#{period.end}T23:59:59",
149
+ title: [header] + closed_issues.collect { |issue| issue_title_line(issue) }
150
+ }
151
+ end
152
+
153
+ def period_label period
154
+ return "on #{period.end}" if period.begin == period.end
155
+
156
+ "between #{period.begin} and #{period.end}"
157
+ end
158
+
159
+ def issue_title_line issue
160
+ hint = @issue_hints&.fetch(issue, nil)
161
+ "#{issue.key} : #{issue.summary}#{" #{hint}" if hint}"
162
+ end
149
163
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/chart_base'
4
+
5
+ # Common base for every cycle-time chart. It owns the cycletime *unit system* --
6
+ # which unit the value axis is expressed in and how a raw value is labelled --
7
+ # because that concern is core to any chart that plots a cycle time, not a
8
+ # cross-cutting add-on. The scatter/histo split lives one level down (in
9
+ # TimeBasedScatterplot / TimeBasedHistogram, which supply the value_axis_title=
10
+ # hook so this class doesn't need to know which axis carries the value); the
11
+ # PR-vs-issue split lives one level below that.
12
+ class TimeBasedChart < ChartBase
13
+ # The cycletime units we support, mapped to how each reads in an axis title.
14
+ # :days counts calendar days; :hours24 counts elapsed 24-hour clock periods
15
+ # (the two differ for anything that crosses midnight).
16
+ VALUE_AXIS_LABELS = {
17
+ minutes: 'minutes',
18
+ hours: 'hours',
19
+ days: 'days',
20
+ hours24: '24-hour periods'
21
+ }.freeze
22
+
23
+ def initialize
24
+ super
25
+
26
+ @cycletime_unit = :days
27
+ end
28
+
29
+ def cycletime_unit unit
30
+ axis_label = VALUE_AXIS_LABELS[unit]
31
+ unless axis_label
32
+ raise ArgumentError,
33
+ "cycletime_unit must be one of #{VALUE_AXIS_LABELS.keys.map(&:inspect).join(', ')}, got #{unit.inspect}"
34
+ end
35
+
36
+ @cycletime_unit = unit
37
+ self.value_axis_title = "Cycle time in #{axis_label}"
38
+ end
39
+
40
+ def label_cycletime value
41
+ case @cycletime_unit
42
+ when :minutes then label_minutes(value)
43
+ when :hours then label_hours(value)
44
+ when :days then label_days(value)
45
+ when :hours24 then "#{value}x 24h periods"
46
+ end
47
+ end
48
+
49
+ # Converts the span between two Times into the selected cycletime unit.
50
+ #
51
+ # :days is deliberately different from the other units: it counts calendar days
52
+ # inclusively in the configured timezone (opened and closed on the same day is
53
+ # 1 day, crossing one midnight is 2), matching the working-days cycletime engine.
54
+ # The elapsed units (:hours, :minutes) divide the wall-clock span and round up --
55
+ # a partial unit still counts as one, so a 20-minute PR is 1 hour, never 0.
56
+ def duration_in_unit from, to
57
+ if @cycletime_unit == :days
58
+ tz = timezone_offset || '+00:00'
59
+ (to.getlocal(tz).to_date - from.getlocal(tz).to_date).to_i + 1
60
+ else
61
+ seconds_per_unit = { minutes: 60, hours: 3600, hours24: 86_400 }[@cycletime_unit]
62
+ ((to - from) / seconds_per_unit).ceil
63
+ end
64
+ end
65
+ end
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'jirametrics/groupable_issue_chart'
4
+ require 'jirametrics/time_based_chart'
4
5
 
5
- class TimeBasedHistogram < ChartBase
6
+ class TimeBasedHistogram < TimeBasedChart
6
7
  include GroupableIssueChart
7
8
 
8
9
  attr_reader :show_stats
@@ -14,6 +15,11 @@ class TimeBasedHistogram < ChartBase
14
15
  @show_stats = true
15
16
  end
16
17
 
18
+ # On a histogram the cycle time is plotted along the x-axis (count is on the y-axis).
19
+ def value_axis_title= title
20
+ @x_axis_title = title
21
+ end
22
+
17
23
  def percentiles percs = nil
18
24
  @percentiles = percs unless percs.nil?
19
25
  @percentiles
@@ -68,42 +74,43 @@ class TimeBasedHistogram < ChartBase
68
74
  return {} if histogram_data.empty?
69
75
 
70
76
  total_values = histogram_data.values.sum
77
+ min, max = histogram_data.keys.minmax
78
+ {
79
+ average: average_for(histogram_data, total_values),
80
+ mode: modes_for(histogram_data),
81
+ min: min,
82
+ max: max,
83
+ percentiles: percentiles_for(histogram_data, percentiles, total_values)
84
+ }
85
+ end
71
86
 
72
- # Calculate the average
73
- weighted_sum = histogram_data.reduce(0) { |sum, (value, frequency)| sum + (value * frequency) }
74
- average = total_values.zero? ? 0 : weighted_sum.to_f / total_values
87
+ def average_for histogram_data, total_values
88
+ return 0 if total_values.zero?
75
89
 
76
- # Find the mode (or modes!) and the spread of the distribution
77
- sorted_histogram = histogram_data.sort_by { |_value, frequency| frequency }
78
- max_freq = sorted_histogram[-1][1]
79
- mode = sorted_histogram.select { |_v, f| f == max_freq }
90
+ weighted_sum = histogram_data.reduce(0) { |sum, (value, frequency)| sum + (value * frequency) }
91
+ weighted_sum.to_f / total_values
92
+ end
80
93
 
81
- minmax = histogram_data.keys.minmax
94
+ # Every value that ties for the highest frequency, so a flat or multi-modal distribution returns them all.
95
+ def modes_for histogram_data
96
+ sorted_by_frequency = histogram_data.sort_by { |_value, frequency| frequency }
97
+ max_frequency = sorted_by_frequency[-1][1]
98
+ sorted_by_frequency.select { |_value, frequency| frequency == max_frequency }.collect(&:first).sort
99
+ end
82
100
 
83
- # Calculate percentiles
101
+ def percentiles_for histogram_data, percentiles, total_values
84
102
  sorted_values = histogram_data.keys.sort
85
103
  cumulative_counts = {}
86
104
  cumulative_sum = 0
87
-
88
105
  sorted_values.each do |value|
89
106
  cumulative_sum += histogram_data[value]
90
107
  cumulative_counts[value] = cumulative_sum
91
108
  end
92
109
 
93
- percentile_results = {}
94
- percentiles.each do |percentile|
110
+ percentiles.to_h do |percentile|
95
111
  rank = (percentile / 100.0) * total_values
96
- percentile_value = sorted_values.find { |value| cumulative_counts[value] >= rank }
97
- percentile_results[percentile] = percentile_value
112
+ [percentile, sorted_values.find { |value| cumulative_counts[value] >= rank }]
98
113
  end
99
-
100
- {
101
- average: average,
102
- mode: mode.collect(&:first).sort,
103
- min: minmax[0],
104
- max: minmax[1],
105
- percentiles: percentile_results
106
- }
107
114
  end
108
115
 
109
116
  def sort_items items
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'jirametrics/groupable_issue_chart'
4
+ require 'jirametrics/time_based_chart'
4
5
 
5
- class TimeBasedScatterplot < ChartBase
6
+ class TimeBasedScatterplot < TimeBasedChart
6
7
  include GroupableIssueChart
7
8
 
8
9
  def initialize
@@ -12,13 +13,21 @@ class TimeBasedScatterplot < ChartBase
12
13
  @highest_y_value = 0
13
14
  end
14
15
 
16
+ # On a scatterplot the cycle time is plotted up the y-axis.
17
+ def value_axis_title= title
18
+ @y_axis_title = title
19
+ end
20
+
15
21
  def run
16
22
  items = all_items
17
23
  data_sets = create_datasets items
18
24
  overall_percent_line = calculate_percent_line(items)
19
25
  @percentage_lines << [overall_percent_line, CssVariable['--cycletime-scatterplot-overall-trendline-color']]
20
26
 
21
- return "<h1 class='foldable'>#{@header_text}</h1><div>No data matched the selected criteria. Nothing to show.</div>" if data_sets.empty?
27
+ if data_sets.empty?
28
+ return "<h1 class='foldable'>#{@header_text}</h1>" \
29
+ '<div>No data matched the selected criteria. Nothing to show.</div>'
30
+ end
22
31
 
23
32
  wrap_and_render(binding, __FILE__)
24
33
  end
@@ -24,11 +24,11 @@ class TrendLineCalculator
24
24
  def horizontal? = @slope.zero?
25
25
  def vertical? = @slope.nan? || @slope.infinite?
26
26
 
27
- def calc_y x: # rubocop:disable Naming/MethodParameterName
27
+ def calc_y x:
28
28
  ((x * @slope) + @offset).to_i
29
29
  end
30
30
 
31
- def line_crosses_at y: # rubocop:disable Naming/MethodParameterName
31
+ def line_crosses_at y:
32
32
  raise "line will never cross #{y}. Trend is perfectly horizontal" if horizontal?
33
33
 
34
34
  ((y.to_f - @offset) / @slope).to_i
@@ -7,7 +7,8 @@ class WipByColumnChart < ChartBase
7
7
 
8
8
  ColumnStats = Struct.new(:name, :min_wip_limit, :max_wip_limit, :wip_history, keyword_init: true)
9
9
 
10
- def initialize block
10
+ # Long only because of the inline description_text heredoc and one-time setup; splitting wouldn't help.
11
+ def initialize block # rubocop:disable Metrics/MethodLength
11
12
  super()
12
13
  header_text 'WIP by column'
13
14
  description_text <<-HTML
@@ -17,7 +18,7 @@ class WipByColumnChart < ChartBase
17
18
  <p>
18
19
  Each row on the Y axis is a WIP level (the number of items in that column at the same time).
19
20
  Each column on the X axis is a board column.
20
- The horizontal bars show what percentage of the total time that column spent at that WIP level —
21
+ The horizontal bars show what percentage of the total time that column spent at that WIP level -
21
22
  a wider bar means more time was spent there.
22
23
  </p>
23
24
  <p>
@@ -64,10 +65,10 @@ class WipByColumnChart < ChartBase
64
65
  total = stat.wip_history.sum { |_wip, seconds| seconds }.to_f
65
66
  next [] if total.zero?
66
67
 
67
- stat.wip_history.collect { |wip, seconds| { 'wip' => wip, 'pct' => format_pct(seconds, total) } }
68
+ wip_percentages(stat.wip_history, total)
68
69
  end
69
- @max_wip = stats.flat_map { |s| s.wip_history.collect { |wip, _| wip } }.max || 0
70
- @wip_limits = stats.collect { |s| { 'min' => s.min_wip_limit, 'max' => s.max_wip_limit } }
70
+ @max_wip = max_wip_level(stats)
71
+ @wip_limits = wip_limits(stats)
71
72
  @recommendations = @show_recommendations ? compute_recommendations(stats) : Array.new(stats.size)
72
73
 
73
74
  trim_zero_end_columns
@@ -135,7 +136,7 @@ class WipByColumnChart < ChartBase
135
136
 
136
137
  max = @wip_limits[i]['max']
137
138
  if max.nil?
138
- "Add a WIP limit to column '#{name}' — suggested maximum: #{rec}"
139
+ "Add a WIP limit to column '#{name}' - suggested maximum: #{rec}"
139
140
  elsif rec < max
140
141
  "Lower the WIP limit for '#{name}' from #{max} to #{rec}"
141
142
  elsif rec > max
@@ -144,6 +145,22 @@ class WipByColumnChart < ChartBase
144
145
  end
145
146
  end
146
147
 
148
+ # Turn a column's WIP history into render rows: each WIP level with the percentage of the column's
149
+ # total time spent there.
150
+ def wip_percentages wip_history, total
151
+ wip_history.collect { |wip, seconds| { 'wip' => wip, 'pct' => format_pct(seconds, total) } }
152
+ end
153
+
154
+ # The highest WIP level reached in any column, or 0 when there's no data.
155
+ def max_wip_level stats
156
+ stats.flat_map { |stat| stat.wip_history.collect { |wip, _seconds| wip } }.max || 0
157
+ end
158
+
159
+ # The configured min/max WIP limits for each column, in column order.
160
+ def wip_limits stats
161
+ stats.collect { |stat| { 'min' => stat.min_wip_limit, 'max' => stat.max_wip_limit } }
162
+ end
163
+
147
164
  def format_pct seconds, total
148
165
  raw = seconds / total * 100.0
149
166
  (1..10).each do |decimals|
@@ -173,8 +190,7 @@ class WipByColumnChart < ChartBase
173
190
  next
174
191
  end
175
192
 
176
- last_change = issue.status_changes.reverse.find { |c| c.time <= time_range.begin }
177
- hash[issue] = last_change ? status_to_column[last_change.value_id] : nil
193
+ hash[issue] = column_as_of(issue, time_range.begin, status_to_column)
178
194
  end
179
195
  end
180
196
 
@@ -184,30 +200,46 @@ class WipByColumnChart < ChartBase
184
200
  started_time, stopped_time = issue.board.cycletime.started_stopped_times(issue)
185
201
  next unless started_time
186
202
 
187
- # Issue starts within the window: add an explicit event to enter WIP in its current column
188
- if started_time > time_range.begin && started_time <= time_range.end
189
- last_change = issue.status_changes.reverse.find { |c| c.time <= started_time }
190
- events << [started_time, issue, last_change ? status_to_column[last_change.value_id] : nil]
203
+ if within_window?(started_time)
204
+ # add an explicit event to enter WIP in its current column
205
+ events << [started_time, issue, column_as_of(issue, started_time, status_to_column)]
191
206
  end
192
207
 
193
- # Status changes while the issue is actively in WIP and within the window
194
208
  issue.status_changes.each do |change|
195
- next unless change.time > time_range.begin
196
- next if change.time > time_range.end
197
- next unless change.time >= started_time
198
- next if stopped_time && change.time >= stopped_time
209
+ next unless in_wip_and_within_window?(change, started_time, stopped_time)
199
210
 
200
211
  events << [change.time, issue, status_to_column[change.value_id]]
201
212
  end
202
213
 
203
- # Issue stops within the window: add an explicit event to exit WIP
204
- if stopped_time && stopped_time > time_range.begin && stopped_time <= time_range.end
205
- events << [stopped_time, issue, nil]
206
- end
214
+ events << [stopped_time, issue, nil] if within_window?(stopped_time)
207
215
  end
208
216
  events.sort_by!(&:first)
209
217
  end
210
218
 
219
+ # True when a (present) time falls inside the chart window: after it opens, up to and including
220
+ # when it closes.
221
+ def within_window? time
222
+ time && time > time_range.begin && time <= time_range.end
223
+ end
224
+
225
+ # A status change counts if it happened inside the window and while the issue was actively in WIP:
226
+ # after the window opens, up to and including when it closes, at or after the issue started, and
227
+ # strictly before it stopped.
228
+ def in_wip_and_within_window? change, started_time, stopped_time
229
+ return false unless within_window?(change.time)
230
+ return false unless change.time >= started_time
231
+ return false if stopped_time && change.time >= stopped_time
232
+
233
+ true
234
+ end
235
+
236
+ # The column the issue was displayed in as of the given time: the column of its most recent
237
+ # status change at or before that time, or nil if it hadn't changed status yet.
238
+ def column_as_of issue, time, status_to_column
239
+ last_change = issue.status_changes.reverse.find { |change| change.time <= time }
240
+ last_change ? status_to_column[last_change.value_id] : nil
241
+ end
242
+
211
243
  def compute_wip_seconds columns, current_column, events
212
244
  wip_counts = Array.new(columns.size, 0)
213
245
  current_column.each_value { |col| wip_counts[col] += 1 unless col.nil? }
@@ -216,21 +248,30 @@ class WipByColumnChart < ChartBase
216
248
  prev_time = time_range.begin
217
249
 
218
250
  events.each do |time, issue, new_col|
219
- elapsed = (time - prev_time).to_i
220
- if elapsed.positive?
221
- wip_counts.each_with_index { |wip, idx| column_wip_seconds[idx][wip] += elapsed }
222
- prev_time = time
223
- end
224
-
225
- old_col = current_column[issue]
226
- wip_counts[old_col] -= 1 unless old_col.nil?
227
- wip_counts[new_col] += 1 unless new_col.nil?
228
- current_column[issue] = new_col
251
+ prev_time = accumulate_wip_seconds(column_wip_seconds, wip_counts, prev_time, time)
252
+ apply_event(wip_counts, current_column, issue, new_col)
229
253
  end
230
-
231
- elapsed = (time_range.end - prev_time).to_i
232
- wip_counts.each_with_index { |wip, idx| column_wip_seconds[idx][wip] += elapsed } if elapsed.positive?
254
+ accumulate_wip_seconds(column_wip_seconds, wip_counts, prev_time, time_range.end)
233
255
 
234
256
  column_wip_seconds
235
257
  end
258
+
259
+ # Attribute the seconds between prev_time and time to each column's current WIP level, then return
260
+ # the new prev_time. A zero-length gap changes nothing and leaves prev_time where it was.
261
+ def accumulate_wip_seconds column_wip_seconds, wip_counts, prev_time, time
262
+ elapsed = (time - prev_time).to_i
263
+ return prev_time unless elapsed.positive?
264
+
265
+ wip_counts.each_with_index { |wip, idx| column_wip_seconds[idx][wip] += elapsed }
266
+ time
267
+ end
268
+
269
+ # Move the issue out of its old column and into new_col, keeping wip_counts and current_column in
270
+ # sync. A nil column means the issue isn't on the board - ie entering or leaving WIP.
271
+ def apply_event wip_counts, current_column, issue, new_col
272
+ old_col = current_column[issue]
273
+ wip_counts[old_col] -= 1 unless old_col.nil?
274
+ wip_counts[new_col] += 1 unless new_col.nil?
275
+ current_column[issue] = new_col
276
+ end
236
277
  end
data/lib/jirametrics.rb CHANGED
@@ -46,6 +46,23 @@ class JiraMetrics < Thor
46
46
  Exporter.instance.export(name_filter: options[:name] || '*')
47
47
  end
48
48
 
49
+ option :config
50
+ option :name
51
+ desc 'verify', 'Check that JiraMetrics can authenticate to Jira, without downloading anything'
52
+ def verify
53
+ load_config options[:config]
54
+ results = Exporter.instance.verify_jira_connections(name_filter: options[:name] || '*')
55
+ exit 1 if results.empty? || results.any? { |result| !result.ok }
56
+ end
57
+
58
+ option :config
59
+ option :name
60
+ desc 'boards', "Show a board's columns and statuses, to help choose cycletime start/stop points"
61
+ def boards board_id = nil
62
+ load_config options[:config]
63
+ exit 1 unless Exporter.instance.boards(board_id: board_id, name_filter: options[:name])
64
+ end
65
+
49
66
  option :config
50
67
  desc 'info', 'Dump information about one issue'
51
68
  def info key
@@ -63,7 +80,9 @@ class JiraMetrics < Thor
63
80
  original_stdout = $stdout.dup
64
81
  $stdout.reopen($stderr)
65
82
 
66
- load_config options[:config]
83
+ # Log to a separate file so that starting the MCP server doesn't truncate jirametrics.log, which
84
+ # someone may be relying on to debug a preceding export run.
85
+ load_config options[:config], logfile_name: 'jirametrics-mcp.log'
67
86
  require 'jirametrics/mcp_server'
68
87
 
69
88
  Exporter.instance.file_system.log_only = true
@@ -78,6 +97,10 @@ class JiraMetrics < Thor
78
97
  today: project.time_range.end.to_date,
79
98
  end_time: project.time_range.end
80
99
  }
100
+ # Fabrication is lazy, so force every historical status to resolve now and then emit the single
101
+ # consolidated warning into jirametrics-mcp.log (file_system is log_only here).
102
+ project.resolve_all_status_changes
103
+ project.report_fabricated_statuses
81
104
  rescue StandardError => e
82
105
  if e.message.start_with? 'This is an aggregated project'
83
106
  names = project.aggregate_project_names
@@ -108,17 +131,21 @@ class JiraMetrics < Thor
108
131
  file_system ||= Exporter.instance.file_system
109
132
  return if file_system.logfile == $stdout
110
133
 
111
- file_system.logfile.puts "#{exception.class}: #{exception.message}"
112
- exception.backtrace&.each { |line| file_system.logfile.puts "\t#{line}" }
134
+ write_exception_to_logfile file_system.logfile, exception
113
135
  rescue StandardError
114
136
  # Exporter may not be initialized, or the logfile may already be closed
115
137
  end
116
138
  end
117
139
 
140
+ def self.write_exception_to_logfile logfile, exception
141
+ logfile.puts "#{exception.class}: #{exception.message}"
142
+ exception.backtrace&.each { |line| logfile.puts "\t#{line}" }
143
+ end
144
+
118
145
  at_exit { JiraMetrics.log_uncaught_exception $ERROR_INFO }
119
146
 
120
147
  no_commands do
121
- def load_config config_file, file_system: FileSystem.new
148
+ def load_config config_file, logfile_name: nil, file_system: FileSystem.new
122
149
  config_file = './config.rb' if config_file.nil?
123
150
 
124
151
  if file_system.file_exist? config_file
@@ -131,6 +158,10 @@ class JiraMetrics < Thor
131
158
  end
132
159
 
133
160
  require_rel 'jirametrics'
161
+ # Set only after the require above, so Exporter is defined. The config file we load below calls
162
+ # Exporter.configure, which opens this log; the MCP server passes its own name so it doesn't
163
+ # truncate jirametrics.log.
164
+ Exporter.logfile_name = logfile_name if logfile_name
134
165
  load config_file
135
166
  end
136
167
  end