jirametrics 2.22 → 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 (81) 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 +26 -10
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +43 -11
  6. data/lib/jirametrics/aging_work_table.rb +9 -7
  7. data/lib/jirametrics/anonymizer.rb +74 -1
  8. data/lib/jirametrics/atlassian_document_format.rb +93 -93
  9. data/lib/jirametrics/blocked_stalled_change.rb +5 -3
  10. data/lib/jirametrics/board.rb +28 -8
  11. data/lib/jirametrics/board_feature.rb +14 -0
  12. data/lib/jirametrics/board_movement_calculator.rb +2 -2
  13. data/lib/jirametrics/cfd_data_builder.rb +108 -0
  14. data/lib/jirametrics/change_item.rb +4 -3
  15. data/lib/jirametrics/chart_base.rb +107 -3
  16. data/lib/jirametrics/css_variable.rb +1 -1
  17. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  18. data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +1 -2
  19. data/lib/jirametrics/cycletime_histogram.rb +15 -103
  20. data/lib/jirametrics/cycletime_scatterplot.rb +13 -98
  21. data/lib/jirametrics/daily_view.rb +38 -13
  22. data/lib/jirametrics/daily_wip_by_age_chart.rb +1 -1
  23. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +1 -1
  24. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
  25. data/lib/jirametrics/daily_wip_chart.rb +29 -7
  26. data/lib/jirametrics/data_quality_report.rb +38 -12
  27. data/lib/jirametrics/dependency_chart.rb +2 -2
  28. data/lib/jirametrics/download_config.rb +15 -0
  29. data/lib/jirametrics/downloader.rb +87 -5
  30. data/lib/jirametrics/downloader_for_cloud.rb +107 -22
  31. data/lib/jirametrics/downloader_for_data_center.rb +3 -2
  32. data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
  33. data/lib/jirametrics/examples/aggregated_project.rb +2 -2
  34. data/lib/jirametrics/examples/standard_project.rb +32 -19
  35. data/lib/jirametrics/expedited_chart.rb +3 -1
  36. data/lib/jirametrics/exporter.rb +15 -2
  37. data/lib/jirametrics/file_config.rb +9 -11
  38. data/lib/jirametrics/file_system.rb +35 -2
  39. data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
  40. data/lib/jirametrics/github_gateway.rb +115 -0
  41. data/lib/jirametrics/groupable_issue_chart.rb +4 -0
  42. data/lib/jirametrics/grouping_rules.rb +26 -4
  43. data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -4
  44. data/lib/jirametrics/html/aging_work_table.erb +3 -0
  45. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  46. data/lib/jirametrics/html/daily_wip_chart.erb +38 -5
  47. data/lib/jirametrics/html/estimate_accuracy_chart.erb +2 -12
  48. data/lib/jirametrics/html/expedited_chart.erb +3 -13
  49. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +2 -8
  50. data/lib/jirametrics/html/index.css +228 -60
  51. data/lib/jirametrics/html/index.erb +6 -0
  52. data/lib/jirametrics/html/index.js +53 -3
  53. data/lib/jirametrics/html/legacy_colors.css +174 -0
  54. data/lib/jirametrics/html/sprint_burndown.erb +7 -13
  55. data/lib/jirametrics/html/throughput_chart.erb +40 -9
  56. data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +59 -59
  57. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +11 -7
  58. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  59. data/lib/jirametrics/html_generator.rb +2 -1
  60. data/lib/jirametrics/html_report_config.rb +45 -33
  61. data/lib/jirametrics/issue.rb +197 -99
  62. data/lib/jirametrics/issue_printer.rb +97 -0
  63. data/lib/jirametrics/jira_gateway.rb +32 -10
  64. data/lib/jirametrics/mcp_server.rb +531 -0
  65. data/lib/jirametrics/project_config.rb +87 -8
  66. data/lib/jirametrics/pull_request.rb +30 -0
  67. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  68. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  69. data/lib/jirametrics/pull_request_review.rb +13 -0
  70. data/lib/jirametrics/raw_javascript.rb +4 -0
  71. data/lib/jirametrics/settings.json +3 -1
  72. data/lib/jirametrics/sprint_burndown.rb +4 -2
  73. data/lib/jirametrics/status.rb +1 -1
  74. data/lib/jirametrics/stitcher.rb +7 -1
  75. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  76. data/lib/jirametrics/throughput_chart.rb +73 -23
  77. data/lib/jirametrics/time_based_histogram.rb +139 -0
  78. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  79. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  80. data/lib/jirametrics.rb +58 -0
  81. metadata +52 -5
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/groupable_issue_chart'
4
+
5
+ class TimeBasedHistogram < ChartBase
6
+ include GroupableIssueChart
7
+
8
+ attr_reader :show_stats
9
+
10
+ def initialize
11
+ super
12
+
13
+ percentiles [50, 85, 98]
14
+ @show_stats = true
15
+ end
16
+
17
+ def percentiles percs = nil
18
+ @percentiles = percs unless percs.nil?
19
+ @percentiles
20
+ end
21
+
22
+ def disable_stats
23
+ @show_stats = false
24
+ end
25
+
26
+ def run
27
+ histogram_items = all_items
28
+ rules_to_items = group_issues histogram_items
29
+
30
+ the_stats = {}
31
+
32
+ overall_histogram = histogram_data_for(items: histogram_items).transform_values(&:size)
33
+ the_stats[:all] = stats_for histogram_data: overall_histogram, percentiles: @percentiles
34
+ data_sets = rules_to_items.keys.collect do |rules|
35
+ the_label = rules.label
36
+ the_histogram = histogram_data_for(items: rules_to_items[rules])
37
+ if @show_stats
38
+ the_stats[the_label] = stats_for(
39
+ histogram_data: the_histogram.transform_values(&:size), percentiles: @percentiles
40
+ )
41
+ end
42
+
43
+ data_set_for(
44
+ histogram_data: the_histogram,
45
+ label: the_label,
46
+ color: rules.color
47
+ )
48
+ end
49
+
50
+ if data_sets.empty?
51
+ return "<h1 class='foldable'>#{@header_text}</h1>" \
52
+ '<div>No data matched the selected criteria. Nothing to show.</div>'
53
+ end
54
+
55
+ wrap_and_render(binding, __FILE__)
56
+ end
57
+
58
+ def histogram_data_for items:
59
+ items_hash = {}
60
+ items.each do |item|
61
+ days = value_for_item item
62
+ (items_hash[days] ||= []) << item if days.positive?
63
+ end
64
+ items_hash
65
+ end
66
+
67
+ def stats_for histogram_data:, percentiles:
68
+ return {} if histogram_data.empty?
69
+
70
+ total_values = histogram_data.values.sum
71
+
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
75
+
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 }
80
+
81
+ minmax = histogram_data.keys.minmax
82
+
83
+ # Calculate percentiles
84
+ sorted_values = histogram_data.keys.sort
85
+ cumulative_counts = {}
86
+ cumulative_sum = 0
87
+
88
+ sorted_values.each do |value|
89
+ cumulative_sum += histogram_data[value]
90
+ cumulative_counts[value] = cumulative_sum
91
+ end
92
+
93
+ percentile_results = {}
94
+ percentiles.each do |percentile|
95
+ rank = (percentile / 100.0) * total_values
96
+ percentile_value = sorted_values.find { |value| cumulative_counts[value] >= rank }
97
+ percentile_results[percentile] = percentile_value
98
+ 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
+ end
108
+
109
+ def sort_items items
110
+ items
111
+ end
112
+
113
+ def label_for_item item, hint:
114
+ raise NotImplementedError, "#{self.class} must implement label_for_item"
115
+ end
116
+
117
+ def data_set_for histogram_data:, label:, color:
118
+ {
119
+ type: 'bar',
120
+ label: label,
121
+ data: histogram_data.keys.sort.filter_map do |days|
122
+ items = histogram_data[days]
123
+ next if items.empty?
124
+
125
+ {
126
+ x: days,
127
+ y: items.size,
128
+ title: [title_for_item(count: items.size, value: days)] +
129
+ sort_items(items).collect do |item|
130
+ hint = @issue_hints&.fetch(item, nil)
131
+ label_for_item(item, hint: hint)
132
+ end
133
+ }
134
+ end,
135
+ backgroundColor: color,
136
+ borderRadius: 0
137
+ }
138
+ end
139
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/groupable_issue_chart'
4
+
5
+ class TimeBasedScatterplot < ChartBase
6
+ include GroupableIssueChart
7
+
8
+ def initialize
9
+ super
10
+
11
+ @percentage_lines = []
12
+ @highest_y_value = 0
13
+ end
14
+
15
+ def run
16
+ items = all_items
17
+ data_sets = create_datasets items
18
+ overall_percent_line = calculate_percent_line(items)
19
+ @percentage_lines << [overall_percent_line, CssVariable['--cycletime-scatterplot-overall-trendline-color']]
20
+
21
+ return "<h1 class='foldable'>#{@header_text}</h1><div>No data matched the selected criteria. Nothing to show.</div>" if data_sets.empty?
22
+
23
+ wrap_and_render(binding, __FILE__)
24
+ end
25
+
26
+ def create_datasets items
27
+ data_sets = []
28
+
29
+ group_issues(items).each do |rules, items_by_type|
30
+ label = rules.label
31
+ color = rules.color
32
+ percent_line = calculate_percent_line items_by_type
33
+ data = items_by_type.filter_map { |item| data_for_item(item, rules: rules) }
34
+ data_sets << {
35
+ label: "#{label} (85% at #{label_days(percent_line)})",
36
+ data: data,
37
+ fill: false,
38
+ showLine: false,
39
+ backgroundColor: color
40
+ }
41
+
42
+ data_sets << trend_line_data_set(label: label, data: data, color: color)
43
+
44
+ @percentage_lines << [percent_line, color]
45
+ end
46
+ data_sets
47
+ end
48
+
49
+ def show_trend_lines
50
+ @show_trend_lines = true
51
+ end
52
+
53
+ def trend_line_data_set label:, data:, color:
54
+ points = data.collect do |hash|
55
+ [Time.parse(hash[:x]).to_i, hash[:y]]
56
+ end
57
+
58
+ # The trend calculation works with numbers only so convert Time to an int and back
59
+ calculator = TrendLineCalculator.new(points)
60
+ data_points = calculator.chart_datapoints(
61
+ range: time_range.begin.to_i..time_range.end.to_i,
62
+ max_y: @highest_y_value
63
+ )
64
+ data_points.each do |point_hash|
65
+ point_hash[:x] = chart_format Time.at(point_hash[:x])
66
+ end
67
+
68
+ {
69
+ type: 'line',
70
+ label: "#{label} Trendline",
71
+ data: data_points,
72
+ fill: false,
73
+ borderWidth: 1,
74
+ markerType: 'none',
75
+ borderColor: color,
76
+ borderDash: [6, 3],
77
+ pointStyle: 'dash',
78
+ hidden: !@show_trend_lines
79
+ }
80
+ end
81
+
82
+ def minimum_y_value
83
+ nil
84
+ end
85
+
86
+ def data_for_item item, rules: nil
87
+ y = y_value(item)
88
+ min = minimum_y_value
89
+ return nil if min && y < min
90
+
91
+ @highest_y_value = y if @highest_y_value < y
92
+
93
+ {
94
+ y: y,
95
+ x: chart_format(x_value(item)),
96
+ title: [title_value(item, rules: rules)]
97
+ }
98
+ end
99
+
100
+ def calculate_percent_line items
101
+ min = minimum_y_value
102
+ times = items.collect { |item| y_value(item) }
103
+ times.reject! { |y| min && y < min }
104
+ index = times.size * 85 / 100
105
+ times.sort[index]
106
+ end
107
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/chart_base'
4
+
5
+ class WipByColumnChart < ChartBase
6
+ attr_accessor :possible_statuses, :board_id
7
+
8
+ ColumnStats = Struct.new(:name, :min_wip_limit, :max_wip_limit, :wip_history, keyword_init: true)
9
+
10
+ def initialize block
11
+ super()
12
+ header_text 'WIP by column'
13
+ description_text <<-HTML
14
+ <p>
15
+ This chart shows how much time each board column has spent at different WIP (Work in Progress) levels.
16
+ </p>
17
+ <p>
18
+ Each row on the Y axis is a WIP level (the number of items in that column at the same time).
19
+ 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
+ a wider bar means more time was spent there.
22
+ </p>
23
+ <p>
24
+ A column whose widest bar is at WIP&nbsp;1 was almost always working on one item at a time, often called
25
+ single-piece-flow. This team is likely collaborating very well and might have been
26
+ <a href="https://blog.mikebowler.ca/2021/06/19/pair-programming/">pairing</a> or
27
+ <a href="https://blog.mikebowler.ca/2023/04/22/ensemble-programming/">mobbing/ensembling</a>
28
+ and these teams tend to be very effective.
29
+ </p>
30
+ <p>
31
+ A column with wide bars at high WIP levels usually indicates a team that is highly siloed. Where each person
32
+ is working by themselves.
33
+ </p>
34
+ <p>
35
+ The dashed lines show the minimum and maximum WIP limits configured on the board.
36
+ If the widest bar sits well above the maximum limit, the limit may be set too low or not being respected.
37
+ If the widest bar sits below the minimum limit, consider whether that limit is still meaningful.
38
+ </p>
39
+ <p>
40
+ Hover over any bar to see the exact percentage.
41
+ </p>
42
+ <% if @all_boards[@board_id].team_managed_kanban? %>
43
+ <p>
44
+ If the data looks a bit off then that's probably because you're using a Team Managed project in "kanban mode".
45
+ For this specific case, we are unable to tell if an item is actually visible on the board and so we may
46
+ be reporting more items started than you actually see on the board. See
47
+ <a href="https://jirametrics.org/faq/#team-managed-kanban-backlog">the FAQ</a>.
48
+ </p>
49
+ <% end %>
50
+ HTML
51
+
52
+ instance_eval(&block)
53
+ end
54
+
55
+ def show_recommendations
56
+ @show_recommendations = true
57
+ end
58
+
59
+ def run
60
+ @header_text += " on board: #{current_board.name}"
61
+ stats = column_stats
62
+ @column_names = stats.collect(&:name)
63
+ @wip_data = stats.collect do |stat|
64
+ total = stat.wip_history.sum { |_wip, seconds| seconds }.to_f
65
+ next [] if total.zero?
66
+
67
+ stat.wip_history.collect { |wip, seconds| { 'wip' => wip, 'pct' => format_pct(seconds, total) } }
68
+ 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 } }
71
+ @recommendations = @show_recommendations ? compute_recommendations(stats) : Array.new(stats.size)
72
+
73
+ trim_zero_end_columns
74
+ @recommendation_texts = @show_recommendations ? build_recommendation_texts : []
75
+
76
+ wrap_and_render(binding, __FILE__)
77
+ end
78
+
79
+ def column_stats
80
+ board = current_board
81
+ columns = board.visible_columns
82
+ status_to_column = build_status_to_column_map(columns)
83
+ relevant_issues = @issues.select { |issue| issue.board.id == @board_id }
84
+
85
+ current_column = initial_column_state(relevant_issues, status_to_column)
86
+ events = events_within_range(relevant_issues, status_to_column)
87
+ column_wip_seconds = compute_wip_seconds(columns, current_column, events)
88
+
89
+ columns.collect.with_index do |column, index|
90
+ ColumnStats.new(
91
+ name: column.name,
92
+ min_wip_limit: column.min,
93
+ max_wip_limit: column.max,
94
+ wip_history: column_wip_seconds[index].sort.to_a
95
+ )
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ def trim_zero_end_columns
102
+ all_zero = @wip_data.map { |col| col.none? { |e| e['wip'].positive? } }
103
+ first = all_zero.index(false)
104
+ return unless first
105
+
106
+ last = all_zero.rindex(false)
107
+ @column_names = @column_names[first..last]
108
+ @wip_data = @wip_data[first..last]
109
+ @wip_limits = @wip_limits[first..last]
110
+ @recommendations = @recommendations[first..last]
111
+ @max_wip = @wip_data.flat_map { |col| col.map { |e| e['wip'] } }.max || 0
112
+ end
113
+
114
+ def compute_recommendations stats
115
+ stats.collect do |stat|
116
+ next nil if stat.wip_history.empty?
117
+
118
+ total = stat.wip_history.sum { |_wip, seconds| seconds }.to_f
119
+ next nil if total.zero?
120
+
121
+ cumulative = 0
122
+ stat.wip_history.sort.find do |_wip, seconds|
123
+ cumulative += seconds
124
+ cumulative / total >= 0.85
125
+ end&.first
126
+ end
127
+ end
128
+
129
+ def build_recommendation_texts
130
+ @column_names.each_with_index.filter_map do |name, i|
131
+ rec = @recommendations[i]
132
+ next if rec.nil?
133
+
134
+ next "Almost nothing passes through column '#{name}'. Do we still need it?" if rec.zero?
135
+
136
+ max = @wip_limits[i]['max']
137
+ if max.nil?
138
+ "Add a WIP limit to column '#{name}' — suggested maximum: #{rec}"
139
+ elsif rec < max
140
+ "Lower the WIP limit for '#{name}' from #{max} to #{rec}"
141
+ elsif rec > max
142
+ "Raise the WIP limit for '#{name}' from #{max} to #{rec}"
143
+ end
144
+ end
145
+ end
146
+
147
+ def format_pct seconds, total
148
+ raw = seconds / total * 100.0
149
+ (1..10).each do |decimals|
150
+ rounded = raw.round(decimals)
151
+ next if rounded.zero? && raw.positive?
152
+ next if rounded >= 100.0 && raw < 100.0
153
+
154
+ return rounded
155
+ end
156
+ raw
157
+ end
158
+
159
+ def build_status_to_column_map columns
160
+ columns.each_with_object({}).with_index do |(column, map), index|
161
+ column.status_ids.each { |id| map[id] = index }
162
+ end
163
+ end
164
+
165
+ def initial_column_state relevant_issues, status_to_column
166
+ relevant_issues.each_with_object({}) do |issue, hash|
167
+ started_time, stopped_time = issue.board.cycletime.started_stopped_times(issue)
168
+ in_wip = started_time &&
169
+ started_time <= time_range.begin &&
170
+ (stopped_time.nil? || stopped_time > time_range.begin)
171
+ unless in_wip
172
+ hash[issue] = nil
173
+ next
174
+ end
175
+
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
178
+ end
179
+ end
180
+
181
+ def events_within_range relevant_issues, status_to_column
182
+ events = []
183
+ relevant_issues.each do |issue|
184
+ started_time, stopped_time = issue.board.cycletime.started_stopped_times(issue)
185
+ next unless started_time
186
+
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]
191
+ end
192
+
193
+ # Status changes while the issue is actively in WIP and within the window
194
+ 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
199
+
200
+ events << [change.time, issue, status_to_column[change.value_id]]
201
+ end
202
+
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
207
+ end
208
+ events.sort_by!(&:first)
209
+ end
210
+
211
+ def compute_wip_seconds columns, current_column, events
212
+ wip_counts = Array.new(columns.size, 0)
213
+ current_column.each_value { |col| wip_counts[col] += 1 unless col.nil? }
214
+
215
+ column_wip_seconds = Array.new(columns.size) { Hash.new(0) }
216
+ prev_time = time_range.begin
217
+
218
+ 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
229
+ 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?
233
+
234
+ column_wip_seconds
235
+ end
236
+ end
data/lib/jirametrics.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'English'
3
4
  require 'thor'
4
5
  require 'require_all'
5
6
 
@@ -52,6 +53,47 @@ class JiraMetrics < Thor
52
53
  Exporter.instance.info(key, name_filter: options[:name] || '*')
53
54
  end
54
55
 
56
+ option :config
57
+ option :name
58
+ desc 'mcp', 'Start in MCP (Model Context Protocol) server mode'
59
+ def mcp
60
+ # Redirect stdout to stderr for the entire startup phase so that any
61
+ # incidental output (from config files, gem loading, etc.) does not
62
+ # corrupt the JSON-RPC channel before the MCP transport takes over.
63
+ original_stdout = $stdout.dup
64
+ $stdout.reopen($stderr)
65
+
66
+ load_config options[:config]
67
+ require 'jirametrics/mcp_server'
68
+
69
+ Exporter.instance.file_system.log_only = true
70
+
71
+ projects = {}
72
+ aggregates = {}
73
+ Exporter.instance.each_project_config(name_filter: options[:name] || '*') do |project|
74
+ project.evaluate_next_level
75
+ project.run load_only: true
76
+ projects[project.name || 'default'] = {
77
+ issues: project.issues,
78
+ today: project.time_range.end.to_date,
79
+ end_time: project.time_range.end
80
+ }
81
+ rescue StandardError => e
82
+ if e.message.start_with? 'This is an aggregated project'
83
+ names = project.aggregate_project_names
84
+ aggregates[project.name] = names if names.any?
85
+ next
86
+ end
87
+ next if e.message.start_with? 'No data found'
88
+
89
+ raise
90
+ end
91
+
92
+ $stdout.reopen(original_stdout)
93
+ original_stdout.close
94
+ McpServer.new(projects: projects, aggregates: aggregates, timezone_offset: Exporter.instance.timezone_offset).run
95
+ end
96
+
55
97
  option :config
56
98
  desc 'stitch', 'Dump information about one issue'
57
99
  def stitch stitch_file = 'stitcher.erb'
@@ -59,6 +101,22 @@ class JiraMetrics < Thor
59
101
  Exporter.instance.stitch stitch_file
60
102
  end
61
103
 
104
+ def self.log_uncaught_exception exception, file_system: nil
105
+ return unless exception && !exception.is_a?(SystemExit)
106
+
107
+ begin
108
+ file_system ||= Exporter.instance.file_system
109
+ return if file_system.logfile == $stdout
110
+
111
+ file_system.logfile.puts "#{exception.class}: #{exception.message}"
112
+ exception.backtrace&.each { |line| file_system.logfile.puts "\t#{line}" }
113
+ rescue StandardError
114
+ # Exporter may not be initialized, or the logfile may already be closed
115
+ end
116
+ end
117
+
118
+ at_exit { JiraMetrics.log_uncaught_exception $ERROR_INFO }
119
+
62
120
  no_commands do
63
121
  def load_config config_file, file_system: FileSystem.new
64
122
  config_file = './config.rb' if config_file.nil?
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jirametrics
3
3
  version: !ruby/object:Gem::Version
4
- version: '2.22'
4
+ version: '2.30'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Bowler
@@ -9,6 +9,34 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: mutant-rspec
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: mcp
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
12
40
  - !ruby/object:Gem::Dependency
13
41
  name: random-word
14
42
  requirement: !ruby/object:Gem::Requirement
@@ -55,10 +83,12 @@ description: Extract metrics from Jira and export to either a report or to CSV f
55
83
  email: mbowler@gargoylesoftware.com
56
84
  executables:
57
85
  - jirametrics
86
+ - jirametrics-mcp
58
87
  extensions: []
59
88
  extra_rdoc_files: []
60
89
  files:
61
90
  - bin/jirametrics
91
+ - bin/jirametrics-mcp
62
92
  - lib/jirametrics.rb
63
93
  - lib/jirametrics/aggregate_config.rb
64
94
  - lib/jirametrics/aging_work_bar_chart.rb
@@ -71,12 +101,15 @@ files:
71
101
  - lib/jirametrics/board.rb
72
102
  - lib/jirametrics/board_column.rb
73
103
  - lib/jirametrics/board_config.rb
104
+ - lib/jirametrics/board_feature.rb
74
105
  - lib/jirametrics/board_movement_calculator.rb
106
+ - lib/jirametrics/cfd_data_builder.rb
75
107
  - lib/jirametrics/change_item.rb
76
108
  - lib/jirametrics/chart_base.rb
77
109
  - lib/jirametrics/columns_config.rb
78
110
  - lib/jirametrics/css_variable.rb
79
- - lib/jirametrics/cycletime_config.rb
111
+ - lib/jirametrics/cumulative_flow_diagram.rb
112
+ - lib/jirametrics/cycle_time_config.rb
80
113
  - lib/jirametrics/cycletime_histogram.rb
81
114
  - lib/jirametrics/cycletime_scatterplot.rb
82
115
  - lib/jirametrics/daily_view.rb
@@ -100,6 +133,7 @@ files:
100
133
  - lib/jirametrics/file_system.rb
101
134
  - lib/jirametrics/fix_version.rb
102
135
  - lib/jirametrics/flow_efficiency_scatterplot.rb
136
+ - lib/jirametrics/github_gateway.rb
103
137
  - lib/jirametrics/groupable_issue_chart.rb
104
138
  - lib/jirametrics/grouping_rules.rb
105
139
  - lib/jirametrics/hierarchy_table.rb
@@ -107,8 +141,7 @@ files:
107
141
  - lib/jirametrics/html/aging_work_in_progress_chart.erb
108
142
  - lib/jirametrics/html/aging_work_table.erb
109
143
  - lib/jirametrics/html/collapsible_issues_panel.erb
110
- - lib/jirametrics/html/cycletime_histogram.erb
111
- - lib/jirametrics/html/cycletime_scatterplot.erb
144
+ - lib/jirametrics/html/cumulative_flow_diagram.erb
112
145
  - lib/jirametrics/html/daily_wip_chart.erb
113
146
  - lib/jirametrics/html/estimate_accuracy_chart.erb
114
147
  - lib/jirametrics/html/expedited_chart.erb
@@ -117,15 +150,25 @@ files:
117
150
  - lib/jirametrics/html/index.css
118
151
  - lib/jirametrics/html/index.erb
119
152
  - lib/jirametrics/html/index.js
153
+ - lib/jirametrics/html/legacy_colors.css
120
154
  - lib/jirametrics/html/sprint_burndown.erb
121
155
  - lib/jirametrics/html/throughput_chart.erb
156
+ - lib/jirametrics/html/time_based_histogram.erb
157
+ - lib/jirametrics/html/time_based_scatterplot.erb
158
+ - lib/jirametrics/html/wip_by_column_chart.erb
122
159
  - lib/jirametrics/html_generator.rb
123
160
  - lib/jirametrics/html_report_config.rb
124
161
  - lib/jirametrics/issue.rb
125
162
  - lib/jirametrics/issue_collection.rb
126
163
  - lib/jirametrics/issue_link.rb
164
+ - lib/jirametrics/issue_printer.rb
127
165
  - lib/jirametrics/jira_gateway.rb
166
+ - lib/jirametrics/mcp_server.rb
128
167
  - lib/jirametrics/project_config.rb
168
+ - lib/jirametrics/pull_request.rb
169
+ - lib/jirametrics/pull_request_cycle_time_histogram.rb
170
+ - lib/jirametrics/pull_request_cycle_time_scatterplot.rb
171
+ - lib/jirametrics/pull_request_review.rb
129
172
  - lib/jirametrics/raw_javascript.rb
130
173
  - lib/jirametrics/rules.rb
131
174
  - lib/jirametrics/self_or_issue_dispatcher.rb
@@ -136,11 +179,15 @@ files:
136
179
  - lib/jirametrics/status.rb
137
180
  - lib/jirametrics/status_collection.rb
138
181
  - lib/jirametrics/stitcher.rb
182
+ - lib/jirametrics/throughput_by_completed_resolution_chart.rb
139
183
  - lib/jirametrics/throughput_chart.rb
184
+ - lib/jirametrics/time_based_histogram.rb
185
+ - lib/jirametrics/time_based_scatterplot.rb
140
186
  - lib/jirametrics/tree_organizer.rb
141
187
  - lib/jirametrics/trend_line_calculator.rb
142
188
  - lib/jirametrics/user.rb
143
189
  - lib/jirametrics/value_equality.rb
190
+ - lib/jirametrics/wip_by_column_chart.rb
144
191
  homepage: https://jirametrics.org
145
192
  licenses:
146
193
  - Apache-2.0
@@ -163,7 +210,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
163
210
  - !ruby/object:Gem::Version
164
211
  version: '0'
165
212
  requirements: []
166
- rubygems_version: 3.6.9
213
+ rubygems_version: 4.0.10
167
214
  specification_version: 4
168
215
  summary: Extract Jira metrics
169
216
  test_files: []