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
|
@@ -9,6 +9,10 @@ class JiraGateway
|
|
|
9
9
|
attr_accessor :ignore_ssl_errors
|
|
10
10
|
attr_reader :jira_url, :settings, :file_system
|
|
11
11
|
|
|
12
|
+
RETRYABLE_EXIT_CODES = [7, 28, 35, 56].freeze
|
|
13
|
+
MAX_RETRIES = 3
|
|
14
|
+
RETRY_DELAY_SECONDS = 5
|
|
15
|
+
|
|
12
16
|
def initialize file_system:, jira_config:, settings:
|
|
13
17
|
@file_system = file_system
|
|
14
18
|
load_jira_config(jira_config)
|
|
@@ -26,8 +30,23 @@ class JiraGateway
|
|
|
26
30
|
log_entry = sanitize_message log_entry
|
|
27
31
|
@file_system.log log_entry
|
|
28
32
|
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
retries = 0
|
|
34
|
+
loop do
|
|
35
|
+
stdout, stderr, status = capture3(command, stdin_data: stdin_data)
|
|
36
|
+
|
|
37
|
+
if status.success?
|
|
38
|
+
@file_system.log "Returned (stderr): #{stderr.inspect}" unless stderr == ''
|
|
39
|
+
raise 'no response from curl on stdout' if stdout == ''
|
|
40
|
+
return parse_response(command: command, result: stdout)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if RETRYABLE_EXIT_CODES.include?(status.exitstatus) && retries < MAX_RETRIES
|
|
44
|
+
retries += 1
|
|
45
|
+
@file_system.log "Transient network error (exit #{status.exitstatus}), retrying in #{RETRY_DELAY_SECONDS}s (attempt #{retries}/#{MAX_RETRIES})..."
|
|
46
|
+
sleep_between_retries
|
|
47
|
+
next
|
|
48
|
+
end
|
|
49
|
+
|
|
31
50
|
@file_system.error "Failed call with exit status #{status.exitstatus}!"
|
|
32
51
|
@file_system.error "Returned (stdout): #{stdout.inspect}"
|
|
33
52
|
@file_system.error "Returned (stderr): #{stderr.inspect}"
|
|
@@ -37,11 +56,6 @@ class JiraGateway
|
|
|
37
56
|
raise "Failed call with exit status #{status.exitstatus}. " \
|
|
38
57
|
"See #{@file_system.logfile_name} for details"
|
|
39
58
|
end
|
|
40
|
-
|
|
41
|
-
@file_system.log "Returned (stderr): #{stderr.inspect}" unless stderr == ''
|
|
42
|
-
raise 'no response from curl on stdout' if stdout == ''
|
|
43
|
-
|
|
44
|
-
parse_response(command: command, result: stdout)
|
|
45
59
|
end
|
|
46
60
|
|
|
47
61
|
def capture3 command, stdin_data:
|
|
@@ -49,6 +63,11 @@ class JiraGateway
|
|
|
49
63
|
Open3.capture3(command, stdin_data: stdin_data)
|
|
50
64
|
end
|
|
51
65
|
|
|
66
|
+
def sleep_between_retries
|
|
67
|
+
# In its own method so we can mock it out in tests
|
|
68
|
+
sleep RETRY_DELAY_SECONDS
|
|
69
|
+
end
|
|
70
|
+
|
|
52
71
|
def call_url relative_url:
|
|
53
72
|
command = make_curl_command url: "#{@jira_url}#{relative_url}"
|
|
54
73
|
exec_and_parse_response command: command, stdin_data: nil
|
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'mcp'
|
|
4
|
+
require 'mcp/server/transports/stdio_transport'
|
|
5
|
+
|
|
6
|
+
class McpServer
|
|
7
|
+
def initialize projects:, aggregates: {}, timezone_offset: '+00:00'
|
|
8
|
+
@projects = projects
|
|
9
|
+
@aggregates = aggregates
|
|
10
|
+
@timezone_offset = timezone_offset
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run
|
|
14
|
+
canonical_tools = [ListProjectsTool, AgingWorkTool, CompletedWorkTool, NotYetStartedTool, StatusTimeAnalysisTool]
|
|
15
|
+
alias_tools = ALIASES.map do |alias_name, canonical|
|
|
16
|
+
schema = canonical.input_schema
|
|
17
|
+
Class.new(canonical) do
|
|
18
|
+
tool_name alias_name
|
|
19
|
+
input_schema schema
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
server = MCP::Server.new(
|
|
24
|
+
name: 'jirametrics',
|
|
25
|
+
version: Gem.loaded_specs['jirametrics']&.version&.to_s || '0.0.0',
|
|
26
|
+
tools: canonical_tools + alias_tools,
|
|
27
|
+
server_context: { projects: @projects, aggregates: @aggregates, timezone_offset: @timezone_offset }
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
transport = MCP::Server::Transports::StdioTransport.new(server)
|
|
31
|
+
transport.open
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
HISTORY_FILTER_SCHEMA = {
|
|
35
|
+
history_field: {
|
|
36
|
+
type: 'string',
|
|
37
|
+
description: 'When combined with history_value, only return issues where this field ever had that value ' \
|
|
38
|
+
'(e.g. "priority", "status"). Both history_field and history_value must be provided together.'
|
|
39
|
+
},
|
|
40
|
+
history_value: {
|
|
41
|
+
type: 'string',
|
|
42
|
+
description: 'The value to look for in the change history of history_field (e.g. "Highest", "Done").'
|
|
43
|
+
},
|
|
44
|
+
ever_blocked: {
|
|
45
|
+
type: 'boolean',
|
|
46
|
+
description: 'When true, only return issues that were ever blocked. Blocked includes flagged items, ' \
|
|
47
|
+
'issues in blocked statuses, and blocking issue links.'
|
|
48
|
+
},
|
|
49
|
+
ever_stalled: {
|
|
50
|
+
type: 'boolean',
|
|
51
|
+
description: 'When true, only return issues that were ever stalled. Stalled means the issue sat ' \
|
|
52
|
+
'inactive for longer than the stalled threshold, or entered a stalled status.'
|
|
53
|
+
},
|
|
54
|
+
currently_blocked: {
|
|
55
|
+
type: 'boolean',
|
|
56
|
+
description: 'When true, only return issues that are currently blocked (as of the data end date).'
|
|
57
|
+
},
|
|
58
|
+
currently_stalled: {
|
|
59
|
+
type: 'boolean',
|
|
60
|
+
description: 'When true, only return issues that are currently stalled (as of the data end date).'
|
|
61
|
+
}
|
|
62
|
+
}.freeze
|
|
63
|
+
|
|
64
|
+
def self.resolve_projects server_context, project_filter
|
|
65
|
+
return nil if project_filter.nil?
|
|
66
|
+
|
|
67
|
+
aggregates = server_context[:aggregates] || {}
|
|
68
|
+
aggregates[project_filter] || [project_filter]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.column_name_for board, status_id
|
|
72
|
+
board.visible_columns.find { |c| c.status_ids.include?(status_id) }&.name
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.time_per_column issue, end_time
|
|
76
|
+
changes = issue.status_changes
|
|
77
|
+
_, stopped = issue.started_stopped_times
|
|
78
|
+
effective_end = stopped && stopped < end_time ? stopped : end_time
|
|
79
|
+
board = issue.board
|
|
80
|
+
|
|
81
|
+
result = Hash.new(0.0)
|
|
82
|
+
|
|
83
|
+
if changes.empty?
|
|
84
|
+
col = column_name_for(board, issue.status.id) || issue.status.name
|
|
85
|
+
duration = effective_end - issue.created
|
|
86
|
+
result[col] += duration if duration.positive?
|
|
87
|
+
return result
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
first_change = changes.first
|
|
91
|
+
initial_col = column_name_for(board, first_change.old_value_id) || first_change.old_value
|
|
92
|
+
initial_duration = first_change.time - issue.created
|
|
93
|
+
result[initial_col] += initial_duration if initial_duration.positive?
|
|
94
|
+
|
|
95
|
+
changes.each_cons(2) do |prev_change, next_change|
|
|
96
|
+
col = column_name_for(board, prev_change.value_id) || prev_change.value
|
|
97
|
+
duration = next_change.time - prev_change.time
|
|
98
|
+
result[col] += duration if duration.positive?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
last_change = changes.last
|
|
102
|
+
final_col = column_name_for(board, last_change.value_id) || last_change.value
|
|
103
|
+
final_duration = effective_end - last_change.time
|
|
104
|
+
result[final_col] += final_duration if final_duration.positive?
|
|
105
|
+
|
|
106
|
+
result
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def self.time_per_status issue, end_time
|
|
110
|
+
changes = issue.status_changes
|
|
111
|
+
_, stopped = issue.started_stopped_times
|
|
112
|
+
effective_end = stopped && stopped < end_time ? stopped : end_time
|
|
113
|
+
|
|
114
|
+
result = Hash.new(0.0)
|
|
115
|
+
|
|
116
|
+
if changes.empty?
|
|
117
|
+
duration = effective_end - issue.created
|
|
118
|
+
result[issue.status.name] += duration if duration.positive?
|
|
119
|
+
return result
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
first_change = changes.first
|
|
123
|
+
initial_duration = first_change.time - issue.created
|
|
124
|
+
result[first_change.old_value] += initial_duration if initial_duration.positive?
|
|
125
|
+
|
|
126
|
+
changes.each_cons(2) do |prev_change, next_change|
|
|
127
|
+
duration = next_change.time - prev_change.time
|
|
128
|
+
result[prev_change.value] += duration if duration.positive?
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
last_change = changes.last
|
|
132
|
+
final_duration = effective_end - last_change.time
|
|
133
|
+
result[last_change.value] += final_duration if final_duration.positive?
|
|
134
|
+
|
|
135
|
+
result
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def self.flow_efficiency_percent issue, end_time
|
|
139
|
+
active_time, total_time = issue.flow_efficiency_numbers(end_time: end_time)
|
|
140
|
+
total_time.positive? ? (active_time / total_time * 100).round(1) : nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def self.matches_blocked_stalled?(bsc, ever_blocked, ever_stalled, currently_blocked, currently_stalled)
|
|
144
|
+
return false if ever_blocked && bsc.none?(&:blocked?)
|
|
145
|
+
return false if ever_stalled && bsc.none?(&:stalled?)
|
|
146
|
+
return false if currently_blocked && !bsc.last&.blocked?
|
|
147
|
+
return false if currently_stalled && !bsc.last&.stalled?
|
|
148
|
+
|
|
149
|
+
true
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def self.matches_history?(issue, end_time, history_field, history_value,
|
|
153
|
+
ever_blocked, ever_stalled, currently_blocked, currently_stalled)
|
|
154
|
+
return false if history_field && history_value &&
|
|
155
|
+
issue.changes.none? { |c| c.field == history_field && c.value == history_value }
|
|
156
|
+
|
|
157
|
+
if ever_blocked || ever_stalled || currently_blocked || currently_stalled
|
|
158
|
+
bsc = issue.blocked_stalled_changes(end_time: end_time)
|
|
159
|
+
return false unless matches_blocked_stalled?(bsc, ever_blocked, ever_stalled,
|
|
160
|
+
currently_blocked, currently_stalled)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
true
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
class ListProjectsTool < MCP::Tool
|
|
167
|
+
tool_name 'list_projects'
|
|
168
|
+
description 'Lists all available projects with basic metadata. Call this first when the user asks a ' \
|
|
169
|
+
'question that could apply to multiple projects, so you can clarify which one they mean.'
|
|
170
|
+
|
|
171
|
+
input_schema(type: 'object', properties: {})
|
|
172
|
+
|
|
173
|
+
def self.call(server_context:, **)
|
|
174
|
+
lines = server_context[:projects].map do |project_name, project_data|
|
|
175
|
+
"#{project_name} | #{project_data[:issues].size} issues | Data through: #{project_data[:today]}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
aggregates = server_context[:aggregates] || {}
|
|
179
|
+
unless aggregates.empty?
|
|
180
|
+
lines << ''
|
|
181
|
+
lines << 'Aggregate groups (can be used as a project filter):'
|
|
182
|
+
aggregates.each do |name, constituent_names|
|
|
183
|
+
lines << "#{name} | includes: #{constituent_names.join(', ')}"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
MCP::Tool::Response.new([{ type: 'text', text: lines.join("\n") }])
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
class AgingWorkTool < MCP::Tool
|
|
192
|
+
tool_name 'aging_work'
|
|
193
|
+
description 'Returns all issues that have been started but not yet completed (work in progress), ' \
|
|
194
|
+
'sorted from oldest to newest. Age is the number of days since the issue was started.'
|
|
195
|
+
|
|
196
|
+
input_schema(
|
|
197
|
+
type: 'object',
|
|
198
|
+
properties: {
|
|
199
|
+
min_age_days: {
|
|
200
|
+
type: 'integer',
|
|
201
|
+
description: 'Only return issues at least this many days old. Omit to return all ages.'
|
|
202
|
+
},
|
|
203
|
+
project: {
|
|
204
|
+
type: 'string',
|
|
205
|
+
description: 'Only return issues from this project name. Omit to return all projects.'
|
|
206
|
+
},
|
|
207
|
+
current_status: {
|
|
208
|
+
type: 'string',
|
|
209
|
+
description: 'Only return issues currently in this status (e.g. "Review", "In Progress").'
|
|
210
|
+
},
|
|
211
|
+
current_column: {
|
|
212
|
+
type: 'string',
|
|
213
|
+
description: 'Only return issues whose current status maps to this board column (e.g. "In Progress").'
|
|
214
|
+
},
|
|
215
|
+
**HISTORY_FILTER_SCHEMA
|
|
216
|
+
}
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def self.call(server_context:, min_age_days: nil, project: nil, project_name: nil,
|
|
220
|
+
current_status: nil, current_column: nil,
|
|
221
|
+
history_field: nil, history_value: nil, ever_blocked: nil, ever_stalled: nil,
|
|
222
|
+
currently_blocked: nil, currently_stalled: nil, **)
|
|
223
|
+
project ||= project_name
|
|
224
|
+
rows = []
|
|
225
|
+
allowed_projects = McpServer.resolve_projects(server_context, project)
|
|
226
|
+
|
|
227
|
+
server_context[:projects].each do |project_name, project_data|
|
|
228
|
+
next if allowed_projects && !allowed_projects.include?(project_name)
|
|
229
|
+
|
|
230
|
+
today = project_data[:today]
|
|
231
|
+
project_data[:issues].each do |issue|
|
|
232
|
+
started, stopped = issue.started_stopped_times
|
|
233
|
+
next unless started && !stopped
|
|
234
|
+
next if current_status && issue.status.name != current_status
|
|
235
|
+
next if current_column && McpServer.column_name_for(issue.board, issue.status.id) != current_column
|
|
236
|
+
|
|
237
|
+
age = (today - started.to_date).to_i + 1
|
|
238
|
+
next if min_age_days && age < min_age_days
|
|
239
|
+
unless McpServer.matches_history?(issue, project_data[:end_time],
|
|
240
|
+
history_field, history_value, ever_blocked, ever_stalled,
|
|
241
|
+
currently_blocked, currently_stalled)
|
|
242
|
+
next
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
rows << {
|
|
246
|
+
key: issue.key,
|
|
247
|
+
summary: issue.summary,
|
|
248
|
+
status: issue.status.name,
|
|
249
|
+
type: issue.type,
|
|
250
|
+
age_days: age,
|
|
251
|
+
flow_efficiency: McpServer.flow_efficiency_percent(issue, project_data[:end_time]),
|
|
252
|
+
project: project_name
|
|
253
|
+
}
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
rows.sort_by! { |r| -r[:age_days] }
|
|
258
|
+
|
|
259
|
+
if rows.empty?
|
|
260
|
+
text = 'No aging work found.'
|
|
261
|
+
else
|
|
262
|
+
lines = rows.map do |r|
|
|
263
|
+
fe = r[:flow_efficiency] ? " | FE: #{r[:flow_efficiency]}%" : ''
|
|
264
|
+
"#{r[:key]} | #{r[:project]} | #{r[:type]} | #{r[:status]} | Age: #{r[:age_days]}d#{fe} | #{r[:summary]}"
|
|
265
|
+
end
|
|
266
|
+
text = lines.join("\n")
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
MCP::Tool::Response.new([{ type: 'text', text: text }])
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
class CompletedWorkTool < MCP::Tool
|
|
274
|
+
tool_name 'completed_work'
|
|
275
|
+
description 'Returns issues that have been completed, sorted most recently completed first. ' \
|
|
276
|
+
'Includes cycle time (days from start to completion).'
|
|
277
|
+
|
|
278
|
+
input_schema(
|
|
279
|
+
type: 'object',
|
|
280
|
+
properties: {
|
|
281
|
+
days_back: {
|
|
282
|
+
type: 'integer',
|
|
283
|
+
description: 'Only return issues completed within this many days of the data end date. Omit to return all.'
|
|
284
|
+
},
|
|
285
|
+
project: {
|
|
286
|
+
type: 'string',
|
|
287
|
+
description: 'Only return issues from this project name. Omit to return all projects.'
|
|
288
|
+
},
|
|
289
|
+
completed_status: {
|
|
290
|
+
type: 'string',
|
|
291
|
+
description: 'Only return issues whose status at completion matches this value (e.g. "Cancelled", "Done").'
|
|
292
|
+
},
|
|
293
|
+
completed_resolution: {
|
|
294
|
+
type: 'string',
|
|
295
|
+
description: 'Only return issues whose resolution at completion matches this value (e.g. "Won\'t Do").'
|
|
296
|
+
},
|
|
297
|
+
**HISTORY_FILTER_SCHEMA
|
|
298
|
+
}
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
def self.build_row issue, project_name, started, stopped, cutoff, completed_status, completed_resolution,
|
|
302
|
+
end_time, history_field, history_value, ever_blocked, ever_stalled,
|
|
303
|
+
currently_blocked, currently_stalled
|
|
304
|
+
completed_date = stopped.to_date
|
|
305
|
+
return nil if cutoff && completed_date < cutoff
|
|
306
|
+
|
|
307
|
+
status_at_done, resolution_at_done = issue.status_resolution_at_done
|
|
308
|
+
return nil if completed_status && status_at_done&.name != completed_status
|
|
309
|
+
return nil if completed_resolution && completed_resolution != resolution_at_done
|
|
310
|
+
return nil unless McpServer.matches_history?(issue, end_time,
|
|
311
|
+
history_field, history_value, ever_blocked, ever_stalled,
|
|
312
|
+
currently_blocked, currently_stalled)
|
|
313
|
+
|
|
314
|
+
cycle_time = started ? (completed_date - started.to_date).to_i + 1 : nil
|
|
315
|
+
{
|
|
316
|
+
key: issue.key,
|
|
317
|
+
summary: issue.summary,
|
|
318
|
+
type: issue.type,
|
|
319
|
+
completed_date: completed_date,
|
|
320
|
+
cycle_time_days: cycle_time,
|
|
321
|
+
flow_efficiency: McpServer.flow_efficiency_percent(issue, stopped),
|
|
322
|
+
status_at_done: status_at_done&.name,
|
|
323
|
+
resolution_at_done: resolution_at_done,
|
|
324
|
+
project: project_name
|
|
325
|
+
}
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def self.call(server_context:, days_back: nil, project: nil, project_name: nil,
|
|
329
|
+
completed_status: nil, completed_resolution: nil,
|
|
330
|
+
history_field: nil, history_value: nil, ever_blocked: nil, ever_stalled: nil,
|
|
331
|
+
currently_blocked: nil, currently_stalled: nil, **)
|
|
332
|
+
project ||= project_name
|
|
333
|
+
rows = []
|
|
334
|
+
allowed_projects = McpServer.resolve_projects(server_context, project)
|
|
335
|
+
|
|
336
|
+
server_context[:projects].each do |project_name, project_data|
|
|
337
|
+
next if allowed_projects && !allowed_projects.include?(project_name)
|
|
338
|
+
|
|
339
|
+
today = project_data[:today]
|
|
340
|
+
cutoff = today - days_back if days_back
|
|
341
|
+
|
|
342
|
+
project_data[:issues].each do |issue|
|
|
343
|
+
started, stopped = issue.started_stopped_times
|
|
344
|
+
next unless stopped
|
|
345
|
+
|
|
346
|
+
row = build_row(issue, project_name, started, stopped, cutoff, completed_status, completed_resolution,
|
|
347
|
+
project_data[:end_time], history_field, history_value, ever_blocked, ever_stalled,
|
|
348
|
+
currently_blocked, currently_stalled)
|
|
349
|
+
rows << row if row
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
rows.sort_by! { |r| -r[:completed_date].to_time.to_i }
|
|
354
|
+
|
|
355
|
+
if rows.empty?
|
|
356
|
+
text = 'No completed work found.'
|
|
357
|
+
else
|
|
358
|
+
lines = rows.map do |r|
|
|
359
|
+
ct = r[:cycle_time_days] ? "#{r[:cycle_time_days]}d" : 'unknown'
|
|
360
|
+
fe = r[:flow_efficiency] ? " | FE: #{r[:flow_efficiency]}%" : ''
|
|
361
|
+
completion = [r[:status_at_done], r[:resolution_at_done]].compact.join(' / ')
|
|
362
|
+
"#{r[:key]} | #{r[:project]} | #{r[:type]} | #{r[:completed_date]} | " \
|
|
363
|
+
"Cycle time: #{ct}#{fe} | #{completion} | #{r[:summary]}"
|
|
364
|
+
end
|
|
365
|
+
text = lines.join("\n")
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
MCP::Tool::Response.new([{ type: 'text', text: text }])
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
class NotYetStartedTool < MCP::Tool
|
|
373
|
+
tool_name 'not_yet_started'
|
|
374
|
+
description 'Returns issues that have not yet been started (backlog items), sorted by creation date oldest first.'
|
|
375
|
+
|
|
376
|
+
input_schema(
|
|
377
|
+
type: 'object',
|
|
378
|
+
properties: {
|
|
379
|
+
project: {
|
|
380
|
+
type: 'string',
|
|
381
|
+
description: 'Only return issues from this project name. Omit to return all projects.'
|
|
382
|
+
},
|
|
383
|
+
current_status: {
|
|
384
|
+
type: 'string',
|
|
385
|
+
description: 'Only return issues currently in this status (e.g. "To Do", "Backlog").'
|
|
386
|
+
},
|
|
387
|
+
current_column: {
|
|
388
|
+
type: 'string',
|
|
389
|
+
description: 'Only return issues whose current status maps to this board column.'
|
|
390
|
+
},
|
|
391
|
+
**HISTORY_FILTER_SCHEMA
|
|
392
|
+
}
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
def self.call(server_context:, project: nil, project_name: nil, current_status: nil, current_column: nil,
|
|
396
|
+
history_field: nil, history_value: nil, ever_blocked: nil, ever_stalled: nil,
|
|
397
|
+
currently_blocked: nil, currently_stalled: nil, **)
|
|
398
|
+
project ||= project_name
|
|
399
|
+
rows = []
|
|
400
|
+
allowed_projects = McpServer.resolve_projects(server_context, project)
|
|
401
|
+
|
|
402
|
+
server_context[:projects].each do |project_name, project_data|
|
|
403
|
+
next if allowed_projects && !allowed_projects.include?(project_name)
|
|
404
|
+
|
|
405
|
+
project_data[:issues].each do |issue|
|
|
406
|
+
started, stopped = issue.started_stopped_times
|
|
407
|
+
next if started || stopped
|
|
408
|
+
next if current_status && issue.status.name != current_status
|
|
409
|
+
next if current_column && McpServer.column_name_for(issue.board, issue.status.id) != current_column
|
|
410
|
+
unless McpServer.matches_history?(issue, project_data[:end_time],
|
|
411
|
+
history_field, history_value, ever_blocked, ever_stalled,
|
|
412
|
+
currently_blocked, currently_stalled)
|
|
413
|
+
next
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
rows << {
|
|
417
|
+
key: issue.key,
|
|
418
|
+
summary: issue.summary,
|
|
419
|
+
status: issue.status.name,
|
|
420
|
+
type: issue.type,
|
|
421
|
+
created: issue.created.to_date,
|
|
422
|
+
project: project_name
|
|
423
|
+
}
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
rows.sort_by! { |r| r[:created] }
|
|
428
|
+
|
|
429
|
+
if rows.empty?
|
|
430
|
+
text = 'No unstarted work found.'
|
|
431
|
+
else
|
|
432
|
+
lines = rows.map do |r|
|
|
433
|
+
"#{r[:key]} | #{r[:project]} | #{r[:type]} | #{r[:status]} | Created: #{r[:created]} | #{r[:summary]}"
|
|
434
|
+
end
|
|
435
|
+
text = lines.join("\n")
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
MCP::Tool::Response.new([{ type: 'text', text: text }])
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
class StatusTimeAnalysisTool < MCP::Tool
|
|
443
|
+
tool_name 'status_time_analysis'
|
|
444
|
+
description 'Aggregates the time issues spend in each status or column, ranked by average days. ' \
|
|
445
|
+
'Useful for identifying bottlenecks. Before calling this tool, always ask the user ' \
|
|
446
|
+
'which issues they want to include: aging (in progress), completed, not yet started, ' \
|
|
447
|
+
'or all. Do not assume — the answer changes the result significantly.'
|
|
448
|
+
|
|
449
|
+
input_schema(
|
|
450
|
+
type: 'object',
|
|
451
|
+
properties: {
|
|
452
|
+
project: {
|
|
453
|
+
type: 'string',
|
|
454
|
+
description: 'Only include issues from this project name. Omit to include all projects.'
|
|
455
|
+
},
|
|
456
|
+
issue_state: {
|
|
457
|
+
type: 'string',
|
|
458
|
+
enum: %w[all aging completed not_started],
|
|
459
|
+
description: 'Which issues to include: "aging" (in progress), "completed", ' \
|
|
460
|
+
'"not_started" (backlog), or "all" (default).'
|
|
461
|
+
},
|
|
462
|
+
group_by: {
|
|
463
|
+
type: 'string',
|
|
464
|
+
enum: %w[status column],
|
|
465
|
+
description: 'Whether to group results by status name (default) or board column.'
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
def self.select_issues issue, issue_state
|
|
471
|
+
started, stopped = issue.started_stopped_times
|
|
472
|
+
case issue_state
|
|
473
|
+
when 'aging' then started && !stopped
|
|
474
|
+
when 'completed' then !!stopped
|
|
475
|
+
when 'not_started' then !started && !stopped
|
|
476
|
+
else true
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def self.call(server_context:, project: nil, project_name: nil, issue_state: 'all', group_by: 'status',
|
|
481
|
+
column: nil, **)
|
|
482
|
+
project ||= project_name
|
|
483
|
+
group_by = 'column' if column
|
|
484
|
+
|
|
485
|
+
totals = Hash.new { |h, k| h[k] = { total_seconds: 0.0, visit_count: 0 } }
|
|
486
|
+
allowed_projects = McpServer.resolve_projects(server_context, project)
|
|
487
|
+
|
|
488
|
+
server_context[:projects].each do |project_name, project_data|
|
|
489
|
+
next if allowed_projects && !allowed_projects.include?(project_name)
|
|
490
|
+
|
|
491
|
+
project_data[:issues].each do |issue|
|
|
492
|
+
next unless select_issues(issue, issue_state)
|
|
493
|
+
|
|
494
|
+
time_map = if group_by == 'column'
|
|
495
|
+
McpServer.time_per_column(issue, project_data[:end_time])
|
|
496
|
+
else
|
|
497
|
+
McpServer.time_per_status(issue, project_data[:end_time])
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
time_map.each do |name, seconds|
|
|
501
|
+
totals[name][:total_seconds] += seconds
|
|
502
|
+
totals[name][:visit_count] += 1
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
return MCP::Tool::Response.new([{ type: 'text', text: 'No data found.' }]) if totals.empty?
|
|
508
|
+
|
|
509
|
+
rows = totals.map do |name, data|
|
|
510
|
+
total_days = (data[:total_seconds] / 86_400.0).round(1)
|
|
511
|
+
avg_days = (data[:total_seconds] / data[:visit_count] / 86_400.0).round(1)
|
|
512
|
+
{ name: name, total_days: total_days, avg_days: avg_days, visit_count: data[:visit_count] }
|
|
513
|
+
end
|
|
514
|
+
rows.sort_by! { |r| -r[:avg_days] }
|
|
515
|
+
|
|
516
|
+
label = group_by == 'column' ? 'Column' : 'Status'
|
|
517
|
+
lines = rows.map do |r|
|
|
518
|
+
"#{label}: #{r[:name]} | Avg: #{r[:avg_days]}d | Total: #{r[:total_days]}d | Issues: #{r[:visit_count]}"
|
|
519
|
+
end
|
|
520
|
+
MCP::Tool::Response.new([{ type: 'text', text: lines.join("\n") }])
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# Alternative tool names used by AI agents other than Claude.
|
|
525
|
+
# Each entry maps an alias name to the canonical tool class it delegates to.
|
|
526
|
+
# The alias inherits the canonical tool's schema and call behaviour automatically.
|
|
527
|
+
# To add a new alias, append one line: 'alias_name' => CanonicalToolClass
|
|
528
|
+
ALIASES = {
|
|
529
|
+
'board_list' => ListProjectsTool
|
|
530
|
+
}.freeze
|
|
531
|
+
end
|
|
@@ -98,6 +98,12 @@ class ProjectConfig
|
|
|
98
98
|
!!@aggregate_config
|
|
99
99
|
end
|
|
100
100
|
|
|
101
|
+
def aggregate_project_names
|
|
102
|
+
return [] unless aggregated_project?
|
|
103
|
+
|
|
104
|
+
@aggregate_config.included_projects.filter_map(&:name)
|
|
105
|
+
end
|
|
106
|
+
|
|
101
107
|
def download &block
|
|
102
108
|
raise 'Not allowed to have multiple download blocks in one project' if @download_config
|
|
103
109
|
raise 'Not allowed to have both an aggregate and a download section. Pick only one.' if @aggregate_config
|
|
@@ -150,6 +156,17 @@ class ProjectConfig
|
|
|
150
156
|
@file_prefix
|
|
151
157
|
end
|
|
152
158
|
|
|
159
|
+
def validate_discard_status status_name
|
|
160
|
+
return if status_name == :backlog
|
|
161
|
+
return if possible_statuses.empty? # not yet downloaded; skip validation
|
|
162
|
+
|
|
163
|
+
found = possible_statuses.find_all_by_name status_name
|
|
164
|
+
return unless found.empty?
|
|
165
|
+
|
|
166
|
+
raise "discard_changes_before: Status #{status_name.inspect} not found. " \
|
|
167
|
+
"Possible statuses are: #{possible_statuses}"
|
|
168
|
+
end
|
|
169
|
+
|
|
153
170
|
def raise_if_prefix_already_used prefix
|
|
154
171
|
@exporter.project_configs.each do |project|
|
|
155
172
|
next unless project.get_file_prefix(raise_if_not_set: false) == prefix && project.target_path == target_path
|
|
@@ -436,7 +453,7 @@ class ProjectConfig
|
|
|
436
453
|
# To be used by the aggregate_config only. Not intended to be part of the public API
|
|
437
454
|
def add_issues issues_list
|
|
438
455
|
@issues = IssueCollection.new if @issues.nil?
|
|
439
|
-
@all_boards
|
|
456
|
+
@all_boards ||= {}
|
|
440
457
|
|
|
441
458
|
issues_list.each do |issue|
|
|
442
459
|
@issues << issue
|
|
@@ -598,6 +615,8 @@ class ProjectConfig
|
|
|
598
615
|
if status_becomes
|
|
599
616
|
status_becomes = [status_becomes] unless status_becomes.is_a? Array
|
|
600
617
|
|
|
618
|
+
status_becomes.each { |status_name| validate_discard_status status_name }
|
|
619
|
+
|
|
601
620
|
block = lambda do |issue|
|
|
602
621
|
trigger_statuses = status_becomes.collect do |status_name|
|
|
603
622
|
if status_name == :backlog
|
|
@@ -19,8 +19,8 @@ class PullRequestCycleTimeHistogram < TimeBasedHistogram
|
|
|
19
19
|
HTML
|
|
20
20
|
|
|
21
21
|
init_configuration_block(block) do
|
|
22
|
-
grouping_rules do |pull_request,
|
|
23
|
-
|
|
22
|
+
grouping_rules do |pull_request, rule|
|
|
23
|
+
rule.label = pull_request.repo
|
|
24
24
|
end
|
|
25
25
|
end
|
|
26
26
|
end
|
|
@@ -18,8 +18,8 @@ class PullRequestCycleTimeScatterplot < TimeBasedScatterplot
|
|
|
18
18
|
HTML
|
|
19
19
|
|
|
20
20
|
init_configuration_block(block) do
|
|
21
|
-
grouping_rules do |pull_request,
|
|
22
|
-
|
|
21
|
+
grouping_rules do |pull_request, rule|
|
|
22
|
+
rule.label = pull_request.repo
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
end
|
|
@@ -48,8 +48,15 @@ class PullRequestCycleTimeScatterplot < TimeBasedScatterplot
|
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def y_value pull_request
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
if @cycletime_unit == :days
|
|
52
|
+
tz = timezone_offset || '+00:00'
|
|
53
|
+
opened = pull_request.opened_at.getlocal(tz).to_date
|
|
54
|
+
closed = pull_request.closed_at.getlocal(tz).to_date
|
|
55
|
+
(closed - opened).to_i + 1
|
|
56
|
+
else
|
|
57
|
+
divisor = { minutes: 60, hours: 3600 }[@cycletime_unit]
|
|
58
|
+
((pull_request.closed_at - pull_request.opened_at) / divisor).round
|
|
59
|
+
end
|
|
53
60
|
end
|
|
54
61
|
|
|
55
62
|
def label_cycletime value
|
|
@@ -62,7 +69,8 @@ class PullRequestCycleTimeScatterplot < TimeBasedScatterplot
|
|
|
62
69
|
|
|
63
70
|
def title_value pull_request, rules: nil
|
|
64
71
|
age_label = label_cycletime y_value(pull_request)
|
|
65
|
-
|
|
72
|
+
keys = pull_request.issue_keys.join(', ')
|
|
73
|
+
"#{keys} | #{pull_request.title} | #{rules.label} | Age:#{age_label}#{lines_changed_text(pull_request)}"
|
|
66
74
|
end
|
|
67
75
|
|
|
68
76
|
def lines_changed_text pull_request
|
|
@@ -77,5 +85,4 @@ class PullRequestCycleTimeScatterplot < TimeBasedScatterplot
|
|
|
77
85
|
text << "], Files changed: #{to_human_readable pull_request.changed_files}"
|
|
78
86
|
text
|
|
79
87
|
end
|
|
80
|
-
|
|
81
88
|
end
|