jirametrics 2.25 → 2.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/bin/jirametrics-mcp +5 -0
  3. data/lib/jirametrics/aging_work_bar_chart.rb +10 -8
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +43 -11
  5. data/lib/jirametrics/aging_work_table.rb +5 -2
  6. data/lib/jirametrics/board.rb +9 -1
  7. data/lib/jirametrics/cfd_data_builder.rb +5 -0
  8. data/lib/jirametrics/chart_base.rb +14 -2
  9. data/lib/jirametrics/cumulative_flow_diagram.rb +8 -0
  10. data/lib/jirametrics/cycletime_scatterplot.rb +4 -0
  11. data/lib/jirametrics/daily_view.rb +5 -4
  12. data/lib/jirametrics/data_quality_report.rb +3 -1
  13. data/lib/jirametrics/dependency_chart.rb +1 -1
  14. data/lib/jirametrics/downloader.rb +18 -7
  15. data/lib/jirametrics/downloader_for_cloud.rb +68 -22
  16. data/lib/jirametrics/downloader_for_data_center.rb +1 -1
  17. data/lib/jirametrics/examples/aggregated_project.rb +1 -1
  18. data/lib/jirametrics/examples/standard_project.rb +5 -2
  19. data/lib/jirametrics/exporter.rb +12 -1
  20. data/lib/jirametrics/file_config.rb +9 -11
  21. data/lib/jirametrics/file_system.rb +31 -2
  22. data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
  23. data/lib/jirametrics/github_gateway.rb +13 -4
  24. data/lib/jirametrics/groupable_issue_chart.rb +2 -0
  25. data/lib/jirametrics/grouping_rules.rb +5 -1
  26. data/lib/jirametrics/html/cumulative_flow_diagram.erb +7 -8
  27. data/lib/jirametrics/html/index.css +139 -88
  28. data/lib/jirametrics/html/index.erb +1 -0
  29. data/lib/jirametrics/html/index.js +1 -1
  30. data/lib/jirametrics/html/legacy_colors.css +174 -0
  31. data/lib/jirametrics/html/time_based_scatterplot.erb +8 -3
  32. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  33. data/lib/jirametrics/html_generator.rb +2 -1
  34. data/lib/jirametrics/html_report_config.rb +33 -27
  35. data/lib/jirametrics/issue.rb +99 -6
  36. data/lib/jirametrics/jira_gateway.rb +26 -7
  37. data/lib/jirametrics/mcp_server.rb +531 -0
  38. data/lib/jirametrics/project_config.rb +20 -1
  39. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +2 -2
  40. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +13 -6
  41. data/lib/jirametrics/sprint_burndown.rb +1 -1
  42. data/lib/jirametrics/stitcher.rb +5 -0
  43. data/lib/jirametrics/throughput_chart.rb +18 -2
  44. data/lib/jirametrics/time_based_scatterplot.rb +9 -2
  45. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  46. data/lib/jirametrics.rb +58 -0
  47. metadata +36 -2
@@ -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
- stdout, stderr, status = capture3(command, stdin_data: stdin_data)
30
- unless status.success?
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, _rule|
23
- rules.label = pull_request.repo
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, _rule|
22
- rules.label = pull_request.repo
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
- divisor = { minutes: 60, hours: 3600, days: 86_400 }[@cycletime_unit]
52
- ((pull_request.closed_at - pull_request.opened_at) / divisor).round
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
- "#{pull_request.title} | #{rules.label} | Age:#{age_label}#{lines_changed_text(pull_request)}"
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