jirametrics 2.22 → 2.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/bin/jirametrics-mcp +5 -0
  3. data/lib/jirametrics/aggregate_config.rb +10 -2
  4. data/lib/jirametrics/aging_work_bar_chart.rb +26 -10
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +43 -11
  6. data/lib/jirametrics/aging_work_table.rb +9 -7
  7. data/lib/jirametrics/anonymizer.rb +74 -1
  8. data/lib/jirametrics/atlassian_document_format.rb +93 -93
  9. data/lib/jirametrics/blocked_stalled_change.rb +5 -3
  10. data/lib/jirametrics/board.rb +28 -8
  11. data/lib/jirametrics/board_feature.rb +14 -0
  12. data/lib/jirametrics/board_movement_calculator.rb +2 -2
  13. data/lib/jirametrics/cfd_data_builder.rb +108 -0
  14. data/lib/jirametrics/change_item.rb +4 -3
  15. data/lib/jirametrics/chart_base.rb +107 -3
  16. data/lib/jirametrics/css_variable.rb +1 -1
  17. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  18. data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +1 -2
  19. data/lib/jirametrics/cycletime_histogram.rb +15 -103
  20. data/lib/jirametrics/cycletime_scatterplot.rb +13 -98
  21. data/lib/jirametrics/daily_view.rb +38 -13
  22. data/lib/jirametrics/daily_wip_by_age_chart.rb +1 -1
  23. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +1 -1
  24. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
  25. data/lib/jirametrics/daily_wip_chart.rb +29 -7
  26. data/lib/jirametrics/data_quality_report.rb +38 -12
  27. data/lib/jirametrics/dependency_chart.rb +2 -2
  28. data/lib/jirametrics/download_config.rb +15 -0
  29. data/lib/jirametrics/downloader.rb +87 -5
  30. data/lib/jirametrics/downloader_for_cloud.rb +107 -22
  31. data/lib/jirametrics/downloader_for_data_center.rb +3 -2
  32. data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
  33. data/lib/jirametrics/examples/aggregated_project.rb +2 -2
  34. data/lib/jirametrics/examples/standard_project.rb +32 -19
  35. data/lib/jirametrics/expedited_chart.rb +3 -1
  36. data/lib/jirametrics/exporter.rb +15 -2
  37. data/lib/jirametrics/file_config.rb +9 -11
  38. data/lib/jirametrics/file_system.rb +35 -2
  39. data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
  40. data/lib/jirametrics/github_gateway.rb +115 -0
  41. data/lib/jirametrics/groupable_issue_chart.rb +4 -0
  42. data/lib/jirametrics/grouping_rules.rb +26 -4
  43. data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -4
  44. data/lib/jirametrics/html/aging_work_table.erb +3 -0
  45. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  46. data/lib/jirametrics/html/daily_wip_chart.erb +38 -5
  47. data/lib/jirametrics/html/estimate_accuracy_chart.erb +2 -12
  48. data/lib/jirametrics/html/expedited_chart.erb +3 -13
  49. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +2 -8
  50. data/lib/jirametrics/html/index.css +228 -60
  51. data/lib/jirametrics/html/index.erb +6 -0
  52. data/lib/jirametrics/html/index.js +53 -3
  53. data/lib/jirametrics/html/legacy_colors.css +174 -0
  54. data/lib/jirametrics/html/sprint_burndown.erb +7 -13
  55. data/lib/jirametrics/html/throughput_chart.erb +40 -9
  56. data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +59 -59
  57. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +11 -7
  58. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  59. data/lib/jirametrics/html_generator.rb +2 -1
  60. data/lib/jirametrics/html_report_config.rb +45 -33
  61. data/lib/jirametrics/issue.rb +197 -99
  62. data/lib/jirametrics/issue_printer.rb +97 -0
  63. data/lib/jirametrics/jira_gateway.rb +32 -10
  64. data/lib/jirametrics/mcp_server.rb +531 -0
  65. data/lib/jirametrics/project_config.rb +87 -8
  66. data/lib/jirametrics/pull_request.rb +30 -0
  67. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  68. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  69. data/lib/jirametrics/pull_request_review.rb +13 -0
  70. data/lib/jirametrics/raw_javascript.rb +4 -0
  71. data/lib/jirametrics/settings.json +3 -1
  72. data/lib/jirametrics/sprint_burndown.rb +4 -2
  73. data/lib/jirametrics/status.rb +1 -1
  74. data/lib/jirametrics/stitcher.rb +7 -1
  75. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  76. data/lib/jirametrics/throughput_chart.rb +73 -23
  77. data/lib/jirametrics/time_based_histogram.rb +139 -0
  78. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  79. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  80. data/lib/jirametrics.rb +58 -0
  81. metadata +52 -5
@@ -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,19 +30,32 @@ class JiraGateway
26
30
  log_entry = sanitize_message log_entry
27
31
  @file_system.log log_entry
28
32
 
29
- stdout, stderr, status = capture3(command, stdin_data: stdin_data)
30
- unless status.success?
31
- @file_system.log "Failed call with exit status #{status.exitstatus}!"
32
- @file_system.log "Returned (stdout): #{stdout.inspect}"
33
- @file_system.log "Returned (stderr): #{stderr.inspect}"
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
+
50
+ @file_system.error "Failed call with exit status #{status.exitstatus}!"
51
+ @file_system.error "Returned (stdout): #{stdout.inspect}"
52
+ @file_system.error "Returned (stderr): #{stderr.inspect}"
53
+ if stderr.include?('401')
54
+ raise 'The request was not authorized. Verify that your authentication token hasn\'t expired'
55
+ end
34
56
  raise "Failed call with exit status #{status.exitstatus}. " \
35
57
  "See #{@file_system.logfile_name} for details"
36
58
  end
37
-
38
- @file_system.log "Returned (stderr): #{stderr.inspect}" unless stderr == ''
39
- raise 'no response from curl on stdout' if stdout == ''
40
-
41
- parse_response(command: command, result: stdout)
42
59
  end
43
60
 
44
61
  def capture3 command, stdin_data:
@@ -46,6 +63,11 @@ class JiraGateway
46
63
  Open3.capture3(command, stdin_data: stdin_data)
47
64
  end
48
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
+
49
71
  def call_url relative_url:
50
72
  command = make_curl_command url: "#{@jira_url}#{relative_url}"
51
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