jirametrics 2.25 → 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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/bin/jirametrics-mcp +5 -0
  3. data/lib/jirametrics/aging_work_bar_chart.rb +10 -8
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +43 -11
  5. data/lib/jirametrics/aging_work_table.rb +5 -2
  6. data/lib/jirametrics/board.rb +9 -1
  7. data/lib/jirametrics/cfd_data_builder.rb +5 -0
  8. data/lib/jirametrics/chart_base.rb +14 -2
  9. data/lib/jirametrics/cumulative_flow_diagram.rb +8 -0
  10. data/lib/jirametrics/cycletime_scatterplot.rb +4 -0
  11. data/lib/jirametrics/daily_view.rb +5 -4
  12. data/lib/jirametrics/data_quality_report.rb +3 -1
  13. data/lib/jirametrics/dependency_chart.rb +1 -1
  14. data/lib/jirametrics/downloader.rb +18 -7
  15. data/lib/jirametrics/downloader_for_cloud.rb +68 -22
  16. data/lib/jirametrics/downloader_for_data_center.rb +1 -1
  17. data/lib/jirametrics/examples/aggregated_project.rb +1 -1
  18. data/lib/jirametrics/examples/standard_project.rb +5 -2
  19. data/lib/jirametrics/exporter.rb +12 -1
  20. data/lib/jirametrics/file_config.rb +9 -11
  21. data/lib/jirametrics/file_system.rb +31 -2
  22. data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
  23. data/lib/jirametrics/github_gateway.rb +13 -4
  24. data/lib/jirametrics/groupable_issue_chart.rb +2 -0
  25. data/lib/jirametrics/grouping_rules.rb +5 -1
  26. data/lib/jirametrics/html/cumulative_flow_diagram.erb +7 -8
  27. data/lib/jirametrics/html/index.css +139 -88
  28. data/lib/jirametrics/html/index.erb +1 -0
  29. data/lib/jirametrics/html/index.js +1 -1
  30. data/lib/jirametrics/html/legacy_colors.css +174 -0
  31. data/lib/jirametrics/html/time_based_scatterplot.erb +8 -3
  32. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  33. data/lib/jirametrics/html_generator.rb +2 -1
  34. data/lib/jirametrics/html_report_config.rb +33 -27
  35. data/lib/jirametrics/issue.rb +99 -6
  36. data/lib/jirametrics/jira_gateway.rb +26 -7
  37. data/lib/jirametrics/mcp_server.rb +531 -0
  38. data/lib/jirametrics/project_config.rb +20 -1
  39. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +2 -2
  40. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +13 -6
  41. data/lib/jirametrics/sprint_burndown.rb +1 -1
  42. data/lib/jirametrics/stitcher.rb +5 -0
  43. data/lib/jirametrics/throughput_chart.rb +18 -2
  44. data/lib/jirametrics/time_based_scatterplot.rb +9 -2
  45. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  46. data/lib/jirametrics.rb +58 -0
  47. metadata +36 -2
@@ -66,7 +66,7 @@ class SprintBurndown < ChartBase
66
66
  result = +''
67
67
  result << render_top_text(binding)
68
68
 
69
- possible_colours = (1..5).collect { |i| CssVariable["--sprint-burndown-sprint-color-#{i}"] }
69
+ possible_colours = (1..ChartBase::OKABE_ITO_PALETTE.size).collect { |i| CssVariable["--sprint-burndown-sprint-color-#{i}"] }
70
70
  charts_to_generate = []
71
71
  charts_to_generate << [:data_set_by_story_points, 'Story Points'] if @use_story_points
72
72
  charts_to_generate << [:data_set_by_story_counts, 'Story Count'] if @use_story_counts
@@ -60,6 +60,11 @@ class Stitcher < HtmlGenerator
60
60
  if matches[:seam] == 'start'
61
61
  content = +''
62
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
63
68
  @all_stitches << Stitcher::StitchContent.new(
64
69
  file: filename, title: matches[:title], type: matches[:type], content: content
65
70
  )
@@ -79,12 +79,21 @@ class ThroughputChart < ChartBase
79
79
  end
80
80
  end
81
81
 
82
+ def calculate_custom_periods
83
+ last_days = @issue_periods.values.compact.uniq.sort
84
+ last_days.each_with_index.map do |last_day, i|
85
+ first_day = i.zero? ? @date_range.begin : last_days[i - 1] + 1
86
+ first_day..last_day
87
+ end
88
+ end
89
+
82
90
  def weekly_throughput_dataset completed_issues:, label:, color:, dashed: false, label_hint: nil
91
+ periods = @issue_periods&.values&.any? ? calculate_custom_periods : calculate_time_periods
83
92
  result = {
84
93
  label: label,
85
94
  label_hint: label_hint,
86
95
  data: throughput_dataset(
87
- periods: calculate_time_periods, completed_issues: completed_issues, label_hint: label_hint
96
+ periods: periods, completed_issues: completed_issues, label_hint: label_hint
88
97
  ),
89
98
  fill: false,
90
99
  showLine: true,
@@ -109,10 +118,17 @@ class ThroughputChart < ChartBase
109
118
  end
110
119
 
111
120
  def throughput_dataset periods:, completed_issues:, label_hint: nil
121
+ custom_mode = @issue_periods&.values&.any?
112
122
  periods.collect do |period|
113
123
  closed_issues = completed_issues.filter_map do |issue|
114
124
  stop_date = issue.started_stopped_dates.last
115
- [stop_date, issue] if stop_date && period.include?(stop_date)
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
116
132
  end
117
133
 
118
134
  date_label = "on #{period.end}"
@@ -18,7 +18,7 @@ class TimeBasedScatterplot < ChartBase
18
18
  overall_percent_line = calculate_percent_line(items)
19
19
  @percentage_lines << [overall_percent_line, CssVariable['--cycletime-scatterplot-overall-trendline-color']]
20
20
 
21
- return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
21
+ return "<h1 class='foldable'>#{@header_text}</h1><div>No data matched the selected criteria. Nothing to show.</div>" if data_sets.empty?
22
22
 
23
23
  wrap_and_render(binding, __FILE__)
24
24
  end
@@ -79,9 +79,14 @@ class TimeBasedScatterplot < ChartBase
79
79
  }
80
80
  end
81
81
 
82
+ def minimum_y_value
83
+ nil
84
+ end
85
+
82
86
  def data_for_item item, rules: nil
83
87
  y = y_value(item)
84
- return nil if y < 1 # These will get called out on the quality report
88
+ min = minimum_y_value
89
+ return nil if min && y < min
85
90
 
86
91
  @highest_y_value = y if @highest_y_value < y
87
92
 
@@ -93,7 +98,9 @@ class TimeBasedScatterplot < ChartBase
93
98
  end
94
99
 
95
100
  def calculate_percent_line items
101
+ min = minimum_y_value
96
102
  times = items.collect { |item| y_value(item) }
103
+ times.reject! { |y| min && y < min }
97
104
  index = times.size * 85 / 100
98
105
  times.sort[index]
99
106
  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.25'
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
@@ -120,10 +150,12 @@ files:
120
150
  - lib/jirametrics/html/index.css
121
151
  - lib/jirametrics/html/index.erb
122
152
  - lib/jirametrics/html/index.js
153
+ - lib/jirametrics/html/legacy_colors.css
123
154
  - lib/jirametrics/html/sprint_burndown.erb
124
155
  - lib/jirametrics/html/throughput_chart.erb
125
156
  - lib/jirametrics/html/time_based_histogram.erb
126
157
  - lib/jirametrics/html/time_based_scatterplot.erb
158
+ - lib/jirametrics/html/wip_by_column_chart.erb
127
159
  - lib/jirametrics/html_generator.rb
128
160
  - lib/jirametrics/html_report_config.rb
129
161
  - lib/jirametrics/issue.rb
@@ -131,6 +163,7 @@ files:
131
163
  - lib/jirametrics/issue_link.rb
132
164
  - lib/jirametrics/issue_printer.rb
133
165
  - lib/jirametrics/jira_gateway.rb
166
+ - lib/jirametrics/mcp_server.rb
134
167
  - lib/jirametrics/project_config.rb
135
168
  - lib/jirametrics/pull_request.rb
136
169
  - lib/jirametrics/pull_request_cycle_time_histogram.rb
@@ -154,6 +187,7 @@ files:
154
187
  - lib/jirametrics/trend_line_calculator.rb
155
188
  - lib/jirametrics/user.rb
156
189
  - lib/jirametrics/value_equality.rb
190
+ - lib/jirametrics/wip_by_column_chart.rb
157
191
  homepage: https://jirametrics.org
158
192
  licenses:
159
193
  - Apache-2.0
@@ -176,7 +210,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
176
210
  - !ruby/object:Gem::Version
177
211
  version: '0'
178
212
  requirements: []
179
- rubygems_version: 4.0.8
213
+ rubygems_version: 4.0.10
180
214
  specification_version: 4
181
215
  summary: Extract Jira metrics
182
216
  test_files: []