jirametrics 2.11 → 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/aggregate_config.rb +10 -2
- data/lib/jirametrics/aging_work_bar_chart.rb +191 -133
- data/lib/jirametrics/aging_work_in_progress_chart.rb +43 -11
- data/lib/jirametrics/aging_work_table.rb +14 -17
- data/lib/jirametrics/anonymizer.rb +81 -6
- data/lib/jirametrics/atlassian_document_format.rb +160 -0
- data/lib/jirametrics/bar_chart_range.rb +17 -0
- data/lib/jirametrics/blocked_stalled_change.rb +5 -3
- data/lib/jirametrics/board.rb +34 -11
- data/lib/jirametrics/board_config.rb +5 -1
- data/lib/jirametrics/board_feature.rb +14 -0
- data/lib/jirametrics/board_movement_calculator.rb +10 -2
- data/lib/jirametrics/cfd_data_builder.rb +108 -0
- data/lib/jirametrics/change_item.rb +43 -20
- data/lib/jirametrics/chart_base.rb +143 -6
- data/lib/jirametrics/css_variable.rb +1 -1
- data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
- data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +22 -5
- data/lib/jirametrics/cycletime_histogram.rb +15 -101
- data/lib/jirametrics/cycletime_scatterplot.rb +17 -83
- data/lib/jirametrics/daily_view.rb +306 -0
- data/lib/jirametrics/daily_wip_by_age_chart.rb +4 -5
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +14 -4
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
- data/lib/jirametrics/daily_wip_chart.rb +30 -8
- data/lib/jirametrics/data_quality_report.rb +43 -12
- data/lib/jirametrics/dependency_chart.rb +6 -3
- data/lib/jirametrics/download_config.rb +15 -0
- data/lib/jirametrics/downloader.rb +128 -71
- data/lib/jirametrics/downloader_for_cloud.rb +287 -0
- data/lib/jirametrics/downloader_for_data_center.rb +95 -0
- data/lib/jirametrics/estimate_accuracy_chart.rb +74 -12
- data/lib/jirametrics/estimation_configuration.rb +25 -0
- data/lib/jirametrics/examples/aggregated_project.rb +2 -2
- data/lib/jirametrics/examples/standard_project.rb +42 -27
- data/lib/jirametrics/expedited_chart.rb +3 -1
- data/lib/jirametrics/exporter.rb +26 -6
- data/lib/jirametrics/file_config.rb +10 -12
- data/lib/jirametrics/file_system.rb +59 -3
- data/lib/jirametrics/fix_version.rb +13 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
- data/lib/jirametrics/github_gateway.rb +115 -0
- data/lib/jirametrics/groupable_issue_chart.rb +11 -1
- data/lib/jirametrics/grouping_rules.rb +26 -4
- data/lib/jirametrics/html/aging_work_bar_chart.erb +5 -5
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +3 -1
- data/lib/jirametrics/html/aging_work_table.erb +7 -0
- data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +40 -5
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
- data/lib/jirametrics/html/expedited_chart.erb +6 -14
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
- data/lib/jirametrics/html/index.css +320 -69
- data/lib/jirametrics/html/index.erb +11 -20
- data/lib/jirametrics/html/index.js +164 -0
- data/lib/jirametrics/html/legacy_colors.css +174 -0
- data/lib/jirametrics/html/sprint_burndown.erb +17 -15
- data/lib/jirametrics/html/throughput_chart.erb +42 -11
- data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +15 -11
- data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
- data/lib/jirametrics/html_generator.rb +32 -0
- data/lib/jirametrics/html_report_config.rb +52 -55
- data/lib/jirametrics/issue.rb +329 -106
- data/lib/jirametrics/issue_collection.rb +33 -0
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +81 -14
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +151 -18
- data/lib/jirametrics/pull_request.rb +30 -0
- data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
- data/lib/jirametrics/pull_request_review.rb +13 -0
- data/lib/jirametrics/raw_javascript.rb +17 -0
- data/lib/jirametrics/settings.json +6 -1
- data/lib/jirametrics/sprint.rb +13 -0
- data/lib/jirametrics/sprint_burndown.rb +45 -37
- data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
- data/lib/jirametrics/status.rb +1 -1
- data/lib/jirametrics/status_collection.rb +7 -0
- data/lib/jirametrics/stitcher.rb +81 -0
- data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
- data/lib/jirametrics/throughput_chart.rb +73 -23
- data/lib/jirametrics/time_based_histogram.rb +139 -0
- data/lib/jirametrics/time_based_scatterplot.rb +107 -0
- data/lib/jirametrics/user.rb +12 -0
- data/lib/jirametrics/wip_by_column_chart.rb +236 -0
- data/lib/jirametrics.rb +83 -64
- metadata +65 -6
|
@@ -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 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,6 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'English'
|
|
3
4
|
require 'thor'
|
|
5
|
+
require 'require_all'
|
|
6
|
+
|
|
7
|
+
# This one does need to be loaded early. The rest will be loaded later.
|
|
8
|
+
require 'jirametrics/file_system'
|
|
4
9
|
|
|
5
10
|
class JiraMetrics < Thor
|
|
6
11
|
def self.exit_on_failure?
|
|
@@ -43,76 +48,90 @@ class JiraMetrics < Thor
|
|
|
43
48
|
|
|
44
49
|
option :config
|
|
45
50
|
desc 'info', 'Dump information about one issue'
|
|
46
|
-
def info
|
|
51
|
+
def info key
|
|
47
52
|
load_config options[:config]
|
|
48
|
-
Exporter.instance.info(
|
|
53
|
+
Exporter.instance.info(key, name_filter: options[:name] || '*')
|
|
49
54
|
end
|
|
50
55
|
|
|
51
|
-
|
|
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
|
|
52
70
|
|
|
53
|
-
|
|
54
|
-
|
|
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'
|
|
55
88
|
|
|
56
|
-
|
|
57
|
-
# The fact that File.exist can see the file does not mean that require will be
|
|
58
|
-
# able to load it. Convert this to an absolute pathname now for require.
|
|
59
|
-
config_file = File.absolute_path(config_file).to_s
|
|
60
|
-
else
|
|
61
|
-
puts "Cannot find configuration file #{config_file.inspect}"
|
|
62
|
-
exit 1
|
|
89
|
+
raise
|
|
63
90
|
end
|
|
64
91
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
require 'jirametrics/anonymizer'
|
|
110
|
-
require 'jirametrics/downloader'
|
|
111
|
-
require 'jirametrics/fix_version'
|
|
112
|
-
require 'jirametrics/download_config'
|
|
113
|
-
require 'jirametrics/columns_config'
|
|
114
|
-
require 'jirametrics/hierarchy_table'
|
|
115
|
-
require 'jirametrics/board'
|
|
116
|
-
load config_file
|
|
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
|
+
|
|
97
|
+
option :config
|
|
98
|
+
desc 'stitch', 'Dump information about one issue'
|
|
99
|
+
def stitch stitch_file = 'stitcher.erb'
|
|
100
|
+
load_config options[:config]
|
|
101
|
+
Exporter.instance.stitch stitch_file
|
|
102
|
+
end
|
|
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
|
+
|
|
120
|
+
no_commands do
|
|
121
|
+
def load_config config_file, file_system: FileSystem.new
|
|
122
|
+
config_file = './config.rb' if config_file.nil?
|
|
123
|
+
|
|
124
|
+
if file_system.file_exist? config_file
|
|
125
|
+
# The fact that File.exist can see the file does not mean that require will be
|
|
126
|
+
# able to load it. Convert this to an absolute pathname now for require.
|
|
127
|
+
config_file = File.absolute_path(config_file).to_s
|
|
128
|
+
else
|
|
129
|
+
file_system.error "Cannot find configuration file #{config_file.inspect}"
|
|
130
|
+
exit 1
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
require_rel 'jirametrics'
|
|
134
|
+
load config_file
|
|
135
|
+
end
|
|
117
136
|
end
|
|
118
137
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,42 @@
|
|
|
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
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date:
|
|
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,28 +83,36 @@ 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
|
|
65
95
|
- lib/jirametrics/aging_work_in_progress_chart.rb
|
|
66
96
|
- lib/jirametrics/aging_work_table.rb
|
|
67
97
|
- lib/jirametrics/anonymizer.rb
|
|
98
|
+
- lib/jirametrics/atlassian_document_format.rb
|
|
99
|
+
- lib/jirametrics/bar_chart_range.rb
|
|
68
100
|
- lib/jirametrics/blocked_stalled_change.rb
|
|
69
101
|
- lib/jirametrics/board.rb
|
|
70
102
|
- lib/jirametrics/board_column.rb
|
|
71
103
|
- lib/jirametrics/board_config.rb
|
|
104
|
+
- lib/jirametrics/board_feature.rb
|
|
72
105
|
- lib/jirametrics/board_movement_calculator.rb
|
|
106
|
+
- lib/jirametrics/cfd_data_builder.rb
|
|
73
107
|
- lib/jirametrics/change_item.rb
|
|
74
108
|
- lib/jirametrics/chart_base.rb
|
|
75
109
|
- lib/jirametrics/columns_config.rb
|
|
76
110
|
- lib/jirametrics/css_variable.rb
|
|
77
|
-
- lib/jirametrics/
|
|
111
|
+
- lib/jirametrics/cumulative_flow_diagram.rb
|
|
112
|
+
- lib/jirametrics/cycle_time_config.rb
|
|
78
113
|
- lib/jirametrics/cycletime_histogram.rb
|
|
79
114
|
- lib/jirametrics/cycletime_scatterplot.rb
|
|
115
|
+
- lib/jirametrics/daily_view.rb
|
|
80
116
|
- lib/jirametrics/daily_wip_by_age_chart.rb
|
|
81
117
|
- lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb
|
|
82
118
|
- lib/jirametrics/daily_wip_by_parent_chart.rb
|
|
@@ -85,7 +121,10 @@ files:
|
|
|
85
121
|
- lib/jirametrics/dependency_chart.rb
|
|
86
122
|
- lib/jirametrics/download_config.rb
|
|
87
123
|
- lib/jirametrics/downloader.rb
|
|
124
|
+
- lib/jirametrics/downloader_for_cloud.rb
|
|
125
|
+
- lib/jirametrics/downloader_for_data_center.rb
|
|
88
126
|
- lib/jirametrics/estimate_accuracy_chart.rb
|
|
127
|
+
- lib/jirametrics/estimation_configuration.rb
|
|
89
128
|
- lib/jirametrics/examples/aggregated_project.rb
|
|
90
129
|
- lib/jirametrics/examples/standard_project.rb
|
|
91
130
|
- lib/jirametrics/expedited_chart.rb
|
|
@@ -94,6 +133,7 @@ files:
|
|
|
94
133
|
- lib/jirametrics/file_system.rb
|
|
95
134
|
- lib/jirametrics/fix_version.rb
|
|
96
135
|
- lib/jirametrics/flow_efficiency_scatterplot.rb
|
|
136
|
+
- lib/jirametrics/github_gateway.rb
|
|
97
137
|
- lib/jirametrics/groupable_issue_chart.rb
|
|
98
138
|
- lib/jirametrics/grouping_rules.rb
|
|
99
139
|
- lib/jirametrics/hierarchy_table.rb
|
|
@@ -101,8 +141,7 @@ files:
|
|
|
101
141
|
- lib/jirametrics/html/aging_work_in_progress_chart.erb
|
|
102
142
|
- lib/jirametrics/html/aging_work_table.erb
|
|
103
143
|
- lib/jirametrics/html/collapsible_issues_panel.erb
|
|
104
|
-
- lib/jirametrics/html/
|
|
105
|
-
- lib/jirametrics/html/cycletime_scatterplot.erb
|
|
144
|
+
- lib/jirametrics/html/cumulative_flow_diagram.erb
|
|
106
145
|
- lib/jirametrics/html/daily_wip_chart.erb
|
|
107
146
|
- lib/jirametrics/html/estimate_accuracy_chart.erb
|
|
108
147
|
- lib/jirametrics/html/expedited_chart.erb
|
|
@@ -110,13 +149,27 @@ files:
|
|
|
110
149
|
- lib/jirametrics/html/hierarchy_table.erb
|
|
111
150
|
- lib/jirametrics/html/index.css
|
|
112
151
|
- lib/jirametrics/html/index.erb
|
|
152
|
+
- lib/jirametrics/html/index.js
|
|
153
|
+
- lib/jirametrics/html/legacy_colors.css
|
|
113
154
|
- lib/jirametrics/html/sprint_burndown.erb
|
|
114
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
|
|
159
|
+
- lib/jirametrics/html_generator.rb
|
|
115
160
|
- lib/jirametrics/html_report_config.rb
|
|
116
161
|
- lib/jirametrics/issue.rb
|
|
162
|
+
- lib/jirametrics/issue_collection.rb
|
|
117
163
|
- lib/jirametrics/issue_link.rb
|
|
164
|
+
- lib/jirametrics/issue_printer.rb
|
|
118
165
|
- lib/jirametrics/jira_gateway.rb
|
|
166
|
+
- lib/jirametrics/mcp_server.rb
|
|
119
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
|
|
172
|
+
- lib/jirametrics/raw_javascript.rb
|
|
120
173
|
- lib/jirametrics/rules.rb
|
|
121
174
|
- lib/jirametrics/self_or_issue_dispatcher.rb
|
|
122
175
|
- lib/jirametrics/settings.json
|
|
@@ -125,10 +178,16 @@ files:
|
|
|
125
178
|
- lib/jirametrics/sprint_issue_change_data.rb
|
|
126
179
|
- lib/jirametrics/status.rb
|
|
127
180
|
- lib/jirametrics/status_collection.rb
|
|
181
|
+
- lib/jirametrics/stitcher.rb
|
|
182
|
+
- lib/jirametrics/throughput_by_completed_resolution_chart.rb
|
|
128
183
|
- lib/jirametrics/throughput_chart.rb
|
|
184
|
+
- lib/jirametrics/time_based_histogram.rb
|
|
185
|
+
- lib/jirametrics/time_based_scatterplot.rb
|
|
129
186
|
- lib/jirametrics/tree_organizer.rb
|
|
130
187
|
- lib/jirametrics/trend_line_calculator.rb
|
|
188
|
+
- lib/jirametrics/user.rb
|
|
131
189
|
- lib/jirametrics/value_equality.rb
|
|
190
|
+
- lib/jirametrics/wip_by_column_chart.rb
|
|
132
191
|
homepage: https://jirametrics.org
|
|
133
192
|
licenses:
|
|
134
193
|
- Apache-2.0
|
|
@@ -151,7 +210,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
151
210
|
- !ruby/object:Gem::Version
|
|
152
211
|
version: '0'
|
|
153
212
|
requirements: []
|
|
154
|
-
rubygems_version:
|
|
213
|
+
rubygems_version: 4.0.10
|
|
155
214
|
specification_version: 4
|
|
156
215
|
summary: Extract Jira metrics
|
|
157
216
|
test_files: []
|