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.
- checksums.yaml +4 -4
- data/bin/jirametrics-mcp +5 -0
- data/lib/jirametrics/aging_work_bar_chart.rb +10 -8
- data/lib/jirametrics/aging_work_in_progress_chart.rb +43 -11
- data/lib/jirametrics/aging_work_table.rb +5 -2
- data/lib/jirametrics/board.rb +9 -1
- data/lib/jirametrics/cfd_data_builder.rb +5 -0
- data/lib/jirametrics/chart_base.rb +14 -2
- data/lib/jirametrics/cumulative_flow_diagram.rb +8 -0
- data/lib/jirametrics/cycletime_scatterplot.rb +4 -0
- data/lib/jirametrics/daily_view.rb +5 -4
- data/lib/jirametrics/data_quality_report.rb +3 -1
- data/lib/jirametrics/dependency_chart.rb +1 -1
- data/lib/jirametrics/downloader.rb +18 -7
- data/lib/jirametrics/downloader_for_cloud.rb +68 -22
- data/lib/jirametrics/downloader_for_data_center.rb +1 -1
- data/lib/jirametrics/examples/aggregated_project.rb +1 -1
- data/lib/jirametrics/examples/standard_project.rb +5 -2
- data/lib/jirametrics/exporter.rb +12 -1
- data/lib/jirametrics/file_config.rb +9 -11
- data/lib/jirametrics/file_system.rb +31 -2
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
- data/lib/jirametrics/github_gateway.rb +13 -4
- data/lib/jirametrics/groupable_issue_chart.rb +2 -0
- data/lib/jirametrics/grouping_rules.rb +5 -1
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +7 -8
- data/lib/jirametrics/html/index.css +139 -88
- data/lib/jirametrics/html/index.erb +1 -0
- data/lib/jirametrics/html/index.js +1 -1
- data/lib/jirametrics/html/legacy_colors.css +174 -0
- data/lib/jirametrics/html/time_based_scatterplot.erb +8 -3
- data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
- data/lib/jirametrics/html_generator.rb +2 -1
- data/lib/jirametrics/html_report_config.rb +33 -27
- data/lib/jirametrics/issue.rb +99 -6
- data/lib/jirametrics/jira_gateway.rb +26 -7
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +20 -1
- data/lib/jirametrics/pull_request_cycle_time_histogram.rb +2 -2
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +13 -6
- data/lib/jirametrics/sprint_burndown.rb +1 -1
- data/lib/jirametrics/stitcher.rb +5 -0
- data/lib/jirametrics/throughput_chart.rb +18 -2
- data/lib/jirametrics/time_based_scatterplot.rb +9 -2
- data/lib/jirametrics/wip_by_column_chart.rb +236 -0
- data/lib/jirametrics.rb +58 -0
- 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..
|
|
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
|
data/lib/jirametrics/stitcher.rb
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 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.
|
|
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.
|
|
213
|
+
rubygems_version: 4.0.10
|
|
180
214
|
specification_version: 4
|
|
181
215
|
summary: Extract Jira metrics
|
|
182
216
|
test_files: []
|