jirametrics 2.31 → 3.0

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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +42 -29
  3. data/lib/jirametrics/aging_work_bar_chart.rb +52 -40
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +65 -60
  5. data/lib/jirametrics/aging_work_table.rb +36 -33
  6. data/lib/jirametrics/anonymizer.rb +31 -86
  7. data/lib/jirametrics/atlassian_document_format.rb +11 -4
  8. data/lib/jirametrics/blocked_stalled_by_date_builder.rb +64 -0
  9. data/lib/jirametrics/blocked_stalled_change.rb +5 -1
  10. data/lib/jirametrics/blocked_stalled_change_stream_builder.rb +194 -0
  11. data/lib/jirametrics/board.rb +23 -9
  12. data/lib/jirametrics/board_config.rb +0 -7
  13. data/lib/jirametrics/board_movement_calculator.rb +24 -21
  14. data/lib/jirametrics/cfd_data_builder.rb +46 -44
  15. data/lib/jirametrics/change_item.rb +19 -7
  16. data/lib/jirametrics/chart_base.rb +50 -64
  17. data/lib/jirametrics/cumulative_flow_diagram.rb +59 -46
  18. data/lib/jirametrics/cycle_time_config.rb +35 -46
  19. data/lib/jirametrics/cycletime_histogram.rb +7 -0
  20. data/lib/jirametrics/cycletime_scatterplot.rb +7 -0
  21. data/lib/jirametrics/daily_view.rb +83 -61
  22. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +24 -9
  23. data/lib/jirametrics/daily_wip_chart.rb +72 -45
  24. data/lib/jirametrics/data_quality_report.rb +61 -63
  25. data/lib/jirametrics/dependency_chart.rb +40 -31
  26. data/lib/jirametrics/download_config.rb +0 -3
  27. data/lib/jirametrics/downloader.rb +8 -7
  28. data/lib/jirametrics/downloader_for_cloud.rb +108 -72
  29. data/lib/jirametrics/estimate_accuracy_chart.rb +13 -13
  30. data/lib/jirametrics/examples/standard_project.rb +2 -2
  31. data/lib/jirametrics/expedited_chart.rb +47 -37
  32. data/lib/jirametrics/exporter.rb +19 -10
  33. data/lib/jirametrics/file_config.rb +19 -12
  34. data/lib/jirametrics/file_system.rb +16 -10
  35. data/lib/jirametrics/flow_efficiency_calculator.rb +62 -0
  36. data/lib/jirametrics/flow_efficiency_scatterplot.rb +4 -2
  37. data/lib/jirametrics/github_gateway.rb +23 -9
  38. data/lib/jirametrics/groupable_issue_chart.rb +3 -0
  39. data/lib/jirametrics/html/index.css +6 -0
  40. data/lib/jirametrics/html_report_config.rb +9 -29
  41. data/lib/jirametrics/issue.rb +290 -389
  42. data/lib/jirametrics/issue_collection.rb +1 -0
  43. data/lib/jirametrics/issue_printer.rb +60 -23
  44. data/lib/jirametrics/jira_gateway.rb +29 -18
  45. data/lib/jirametrics/mcp_server.rb +225 -209
  46. data/lib/jirametrics/project_config.rb +158 -134
  47. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +1 -20
  48. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +11 -33
  49. data/lib/jirametrics/rules.rb +1 -0
  50. data/lib/jirametrics/self_or_issue_dispatcher.rb +1 -3
  51. data/lib/jirametrics/sprint_burndown.rb +142 -242
  52. data/lib/jirametrics/sprint_count_measure.rb +42 -0
  53. data/lib/jirametrics/sprint_issue_change_data.rb +1 -0
  54. data/lib/jirametrics/sprint_points_measure.rb +62 -0
  55. data/lib/jirametrics/sprint_summary_stats.rb +16 -0
  56. data/lib/jirametrics/status_collection.rb +2 -2
  57. data/lib/jirametrics/stitcher.rb +21 -16
  58. data/lib/jirametrics/throughput_chart.rb +38 -24
  59. data/lib/jirametrics/time_based_chart.rb +65 -0
  60. data/lib/jirametrics/time_based_histogram.rb +30 -23
  61. data/lib/jirametrics/time_based_scatterplot.rb +11 -2
  62. data/lib/jirametrics/trend_line_calculator.rb +2 -2
  63. data/lib/jirametrics/wip_by_column_chart.rb +73 -32
  64. data/lib/jirametrics.rb +6 -2
  65. metadata +13 -20
@@ -65,6 +65,27 @@ class McpServer
65
65
  }
66
66
  }.freeze
67
67
 
68
+ # Bundles the six history + blocked/stalled query parameters that every tool accepts, so they travel
69
+ # as one argument instead of a six-long positional tail through matches_history? and the handlers.
70
+ HistoryFilter = Data.define(
71
+ :history_field, :history_value, :ever_blocked, :ever_stalled, :currently_blocked, :currently_stalled
72
+ ) do
73
+ def self.from(history_field: nil, history_value: nil, ever_blocked: nil, ever_stalled: nil,
74
+ currently_blocked: nil, currently_stalled: nil, **)
75
+ new(
76
+ history_field: history_field, history_value: history_value,
77
+ ever_blocked: ever_blocked, ever_stalled: ever_stalled,
78
+ currently_blocked: currently_blocked, currently_stalled: currently_stalled
79
+ )
80
+ end
81
+
82
+ def history? = !!(history_field && history_value)
83
+
84
+ def blocked_stalled? = !!(ever_blocked || ever_stalled || currently_blocked || currently_stalled)
85
+
86
+ def matches_change?(changes) = changes.any? { |c| c.field == history_field && c.value == history_value }
87
+ end
88
+
68
89
  def self.resolve_projects server_context, project_filter
69
90
  return nil if project_filter.nil?
70
91
 
@@ -76,93 +97,111 @@ class McpServer
76
97
  board.visible_columns.find { |c| c.status_ids.include?(status_id) }&.name
77
98
  end
78
99
 
79
- def self.time_per_column issue, end_time
80
- changes = issue.status_changes
81
- _, stopped = issue.started_stopped_times
82
- effective_end = stopped && stopped < end_time ? stopped : end_time
83
- board = issue.board
100
+ # The shared iteration spine of every query tool: resolve the project filter, skip disallowed projects,
101
+ # and yield each surviving issue with its project name and data.
102
+ def self.each_allowed_issue server_context, project
103
+ allowed_projects = resolve_projects(server_context, project)
104
+ server_context[:projects].each do |project_name, project_data|
105
+ next if allowed_projects && !allowed_projects.include?(project_name)
84
106
 
85
- result = Hash.new(0.0)
86
-
87
- if changes.empty?
88
- col = column_name_for(board, issue.status.id) || issue.status.name
89
- duration = effective_end - issue.created
90
- result[col] += duration if duration.positive?
91
- return result
107
+ project_data[:issues].each do |issue|
108
+ yield issue, project_name, project_data
109
+ end
92
110
  end
111
+ end
93
112
 
94
- first_change = changes.first
95
- initial_col = column_name_for(board, first_change.old_value_id) || first_change.old_value
96
- initial_duration = first_change.time - issue.created
97
- result[initial_col] += initial_duration if initial_duration.positive?
98
-
99
- changes.each_cons(2) do |prev_change, next_change|
100
- col = column_name_for(board, prev_change.value_id) || prev_change.value
101
- duration = next_change.time - prev_change.time
102
- result[col] += duration if duration.positive?
113
+ # Collects the non-nil rows a block returns for each allowed issue (the row-oriented tools).
114
+ def self.collect_rows server_context, project
115
+ rows = []
116
+ each_allowed_issue(server_context, project) do |issue, project_name, project_data|
117
+ row = yield issue, project_name, project_data
118
+ rows << row if row
103
119
  end
120
+ rows
121
+ end
104
122
 
105
- last_change = changes.last
106
- final_col = column_name_for(board, last_change.value_id) || last_change.value
107
- final_duration = effective_end - last_change.time
108
- result[final_col] += final_duration if final_duration.positive?
123
+ # Renders already-sorted rows as a text Response: the empty-message when there are none, otherwise
124
+ # each row formatted by the block and newline-joined.
125
+ def self.render_rows rows, empty:, &block
126
+ text = rows.empty? ? empty : rows.map(&block).join("\n")
127
+ MCP::Tool::Response.new([{ type: 'text', text: text }])
128
+ end
109
129
 
110
- result
130
+ # Shared current-state filter for the aging and not-yet-started tools.
131
+ def self.matches_current_state? issue, current_status, current_column
132
+ return false if current_status && issue.status.name != current_status
133
+ return false if current_column && column_name_for(issue.board, issue.status.id) != current_column
134
+
135
+ true
111
136
  end
112
137
 
113
- def self.time_per_status issue, end_time
138
+ # Walks an issue's status timeline as spans (created -> first change, between consecutive changes,
139
+ # last change -> effective end) and sums each span's duration under a bucket name. The bucket for a
140
+ # span is chosen by the block, given the (status_name, status_id) that describe the status held during
141
+ # that span: for the opening span that's the first change's old value, and thereafter the change's value.
142
+ def self.accumulate_spans issue, end_time
114
143
  changes = issue.status_changes
115
144
  _, stopped = issue.started_stopped_times
116
145
  effective_end = stopped && stopped < end_time ? stopped : end_time
117
-
118
146
  result = Hash.new(0.0)
119
147
 
120
148
  if changes.empty?
121
- duration = effective_end - issue.created
122
- result[issue.status.name] += duration if duration.positive?
149
+ add_span(result, yield(issue.status.name, issue.status.id), effective_end - issue.created)
123
150
  return result
124
151
  end
125
152
 
126
153
  first_change = changes.first
127
- initial_duration = first_change.time - issue.created
128
- result[first_change.old_value] += initial_duration if initial_duration.positive?
154
+ add_span(result, yield(first_change.old_value, first_change.old_value_id), first_change.time - issue.created)
129
155
 
130
156
  changes.each_cons(2) do |prev_change, next_change|
131
- duration = next_change.time - prev_change.time
132
- result[prev_change.value] += duration if duration.positive?
157
+ add_span(result, yield(prev_change.value, prev_change.value_id), next_change.time - prev_change.time)
133
158
  end
134
159
 
135
160
  last_change = changes.last
136
- final_duration = effective_end - last_change.time
137
- result[last_change.value] += final_duration if final_duration.positive?
138
-
161
+ add_span(result, yield(last_change.value, last_change.value_id), effective_end - last_change.time)
139
162
  result
140
163
  end
141
164
 
165
+ def self.add_span result, name, duration
166
+ result[name] += duration if duration.positive?
167
+ end
168
+
169
+ def self.time_per_column issue, end_time
170
+ board = issue.board
171
+ accumulate_spans(issue, end_time) { |name, id| column_name_for(board, id) || name }
172
+ end
173
+
174
+ def self.time_per_status issue, end_time
175
+ accumulate_spans(issue, end_time) { |name, _id| name }
176
+ end
177
+
142
178
  def self.flow_efficiency_percent issue, end_time
143
179
  active_time, total_time = issue.flow_efficiency_numbers(end_time: end_time)
144
180
  total_time.positive? ? (active_time / total_time * 100).round(1) : nil
145
181
  end
146
182
 
147
- def self.matches_blocked_stalled?(bsc, ever_blocked, ever_stalled, currently_blocked, currently_stalled)
148
- return false if ever_blocked && bsc.none?(&:blocked?)
149
- return false if ever_stalled && bsc.none?(&:stalled?)
150
- return false if currently_blocked && !bsc.last&.blocked?
151
- return false if currently_stalled && !bsc.last&.stalled?
183
+ def self.matches_blocked?(bsc, filter)
184
+ return false if filter.ever_blocked && bsc.none?(&:blocked?)
185
+ return false if filter.currently_blocked && !bsc.last&.blocked?
152
186
 
153
187
  true
154
188
  end
155
189
 
156
- def self.matches_history?(issue, end_time, history_field, history_value,
157
- ever_blocked, ever_stalled, currently_blocked, currently_stalled)
158
- return false if history_field && history_value &&
159
- issue.changes.none? { |c| c.field == history_field && c.value == history_value }
190
+ def self.matches_stalled?(bsc, filter)
191
+ return false if filter.ever_stalled && bsc.none?(&:stalled?)
192
+ return false if filter.currently_stalled && !bsc.last&.stalled?
160
193
 
161
- if ever_blocked || ever_stalled || currently_blocked || currently_stalled
162
- bsc = issue.blocked_stalled_changes(end_time: end_time)
163
- return false unless matches_blocked_stalled?(bsc, ever_blocked, ever_stalled,
164
- currently_blocked, currently_stalled)
165
- end
194
+ true
195
+ end
196
+
197
+ def self.matches_blocked_stalled?(bsc, filter)
198
+ matches_blocked?(bsc, filter) && matches_stalled?(bsc, filter)
199
+ end
200
+
201
+ def self.matches_history?(issue, end_time, filter)
202
+ return false if filter.history? && !filter.matches_change?(issue.changes)
203
+ return false if filter.blocked_stalled? &&
204
+ !matches_blocked_stalled?(issue.blocked_stalled_changes(end_time: end_time), filter)
166
205
 
167
206
  true
168
207
  end
@@ -221,56 +260,45 @@ class McpServer
221
260
  )
222
261
 
223
262
  def self.call(server_context:, min_age_days: nil, project: nil, project_name: nil,
224
- current_status: nil, current_column: nil,
225
- history_field: nil, history_value: nil, ever_blocked: nil, ever_stalled: nil,
226
- currently_blocked: nil, currently_stalled: nil, **)
263
+ current_status: nil, current_column: nil, **history_args)
227
264
  project ||= project_name
228
- rows = []
229
- allowed_projects = McpServer.resolve_projects(server_context, project)
230
-
231
- server_context[:projects].each do |project_name, project_data|
232
- next if allowed_projects && !allowed_projects.include?(project_name)
233
-
234
- today = project_data[:today]
235
- project_data[:issues].each do |issue|
236
- started, stopped = issue.started_stopped_times
237
- next unless started && !stopped
238
- next if current_status && issue.status.name != current_status
239
- next if current_column && McpServer.column_name_for(issue.board, issue.status.id) != current_column
240
-
241
- age = (today - started.to_date).to_i + 1
242
- next if min_age_days && age < min_age_days
243
- unless McpServer.matches_history?(issue, project_data[:end_time],
244
- history_field, history_value, ever_blocked, ever_stalled,
245
- currently_blocked, currently_stalled)
246
- next
247
- end
248
-
249
- rows << {
250
- key: issue.key,
251
- summary: issue.summary,
252
- status: issue.status.name,
253
- type: issue.type,
254
- age_days: age,
255
- flow_efficiency: McpServer.flow_efficiency_percent(issue, project_data[:end_time]),
256
- project: project_name
257
- }
258
- end
259
- end
265
+ filter = McpServer::HistoryFilter.from(**history_args)
260
266
 
267
+ rows = McpServer.collect_rows(server_context, project) do |issue, name, project_data|
268
+ build_row(
269
+ issue: issue, project_name: name, project_data: project_data, min_age_days: min_age_days,
270
+ current_status: current_status, current_column: current_column, filter: filter
271
+ )
272
+ end
261
273
  rows.sort_by! { |r| -r[:age_days] }
262
274
 
263
- if rows.empty?
264
- text = 'No aging work found.'
265
- else
266
- lines = rows.map do |r|
267
- fe = r[:flow_efficiency] ? " | FE: #{r[:flow_efficiency]}%" : ''
268
- "#{r[:key]} | #{r[:project]} | #{r[:type]} | #{r[:status]} | Age: #{r[:age_days]}d#{fe} | #{r[:summary]}"
269
- end
270
- text = lines.join("\n")
271
- end
275
+ McpServer.render_rows(rows, empty: 'No aging work found.') { |r| format_line(r) }
276
+ end
272
277
 
273
- MCP::Tool::Response.new([{ type: 'text', text: text }])
278
+ def self.build_row issue:, project_name:, project_data:, min_age_days:, current_status:, current_column:, filter:
279
+ started, stopped = issue.started_stopped_times
280
+ return nil unless started && !stopped
281
+ return nil unless McpServer.matches_current_state?(issue, current_status, current_column)
282
+
283
+ age = (project_data[:today] - started.to_date).to_i + 1
284
+ return nil if min_age_days && age < min_age_days
285
+ return nil unless McpServer.matches_history?(issue, project_data[:end_time], filter)
286
+
287
+ {
288
+ key: issue.key,
289
+ summary: issue.summary,
290
+ status: issue.status.name,
291
+ type: issue.type,
292
+ age_days: age,
293
+ flow_efficiency: McpServer.flow_efficiency_percent(issue, project_data[:end_time]),
294
+ project: project_name
295
+ }
296
+ end
297
+
298
+ def self.format_line row
299
+ fe = row[:flow_efficiency] ? " | FE: #{row[:flow_efficiency]}%" : ''
300
+ "#{row[:key]} | #{row[:project]} | #{row[:type]} | #{row[:status]} | " \
301
+ "Age: #{row[:age_days]}d#{fe} | #{row[:summary]}"
274
302
  end
275
303
  end
276
304
 
@@ -302,26 +330,42 @@ class McpServer
302
330
  }
303
331
  )
304
332
 
305
- def self.build_row issue, project_name, started, stopped, cutoff, completed_status, completed_resolution,
306
- end_time, history_field, history_value, ever_blocked, ever_stalled,
307
- currently_blocked, currently_stalled
333
+ def self.call(server_context:, days_back: nil, project: nil, project_name: nil,
334
+ completed_status: nil, completed_resolution: nil, **history_args)
335
+ project ||= project_name
336
+ filter = McpServer::HistoryFilter.from(**history_args)
337
+
338
+ rows = McpServer.collect_rows(server_context, project) do |issue, name, project_data|
339
+ build_row(
340
+ issue: issue, project_name: name, project_data: project_data, days_back: days_back,
341
+ completed_status: completed_status, completed_resolution: completed_resolution, filter: filter
342
+ )
343
+ end
344
+ rows.sort_by! { |r| -r[:completed_date].to_time.to_i }
345
+
346
+ McpServer.render_rows(rows, empty: 'No completed work found.') { |r| format_line(r) }
347
+ end
348
+
349
+ def self.build_row(
350
+ issue:, project_name:, project_data:, days_back:, completed_status:, completed_resolution:, filter:
351
+ )
352
+ started, stopped = issue.started_stopped_times
353
+ return nil unless stopped
354
+
308
355
  completed_date = stopped.to_date
309
- return nil if cutoff && completed_date < cutoff
356
+ return nil if past_cutoff?(completed_date, project_data[:today], days_back)
310
357
 
311
358
  status_at_done, resolution_at_done = issue.status_resolution_at_done
312
- return nil if completed_status && status_at_done&.name != completed_status
313
- return nil if completed_resolution && completed_resolution != resolution_at_done
314
- return nil unless McpServer.matches_history?(issue, end_time,
315
- history_field, history_value, ever_blocked, ever_stalled,
316
- currently_blocked, currently_stalled)
359
+ return nil unless matches_completion?(status_at_done, resolution_at_done,
360
+ completed_status, completed_resolution)
361
+ return nil unless McpServer.matches_history?(issue, project_data[:end_time], filter)
317
362
 
318
- cycle_time = started ? (completed_date - started.to_date).to_i + 1 : nil
319
363
  {
320
364
  key: issue.key,
321
365
  summary: issue.summary,
322
366
  type: issue.type,
323
367
  completed_date: completed_date,
324
- cycle_time_days: cycle_time,
368
+ cycle_time_days: started ? (completed_date - started.to_date).to_i + 1 : nil,
325
369
  flow_efficiency: McpServer.flow_efficiency_percent(issue, stopped),
326
370
  status_at_done: status_at_done&.name,
327
371
  resolution_at_done: resolution_at_done,
@@ -329,47 +373,25 @@ class McpServer
329
373
  }
330
374
  end
331
375
 
332
- def self.call(server_context:, days_back: nil, project: nil, project_name: nil,
333
- completed_status: nil, completed_resolution: nil,
334
- history_field: nil, history_value: nil, ever_blocked: nil, ever_stalled: nil,
335
- currently_blocked: nil, currently_stalled: nil, **)
336
- project ||= project_name
337
- rows = []
338
- allowed_projects = McpServer.resolve_projects(server_context, project)
339
-
340
- server_context[:projects].each do |project_name, project_data|
341
- next if allowed_projects && !allowed_projects.include?(project_name)
376
+ def self.past_cutoff? completed_date, today, days_back
377
+ return false unless days_back
342
378
 
343
- today = project_data[:today]
344
- cutoff = today - days_back if days_back
345
-
346
- project_data[:issues].each do |issue|
347
- started, stopped = issue.started_stopped_times
348
- next unless stopped
349
-
350
- row = build_row(issue, project_name, started, stopped, cutoff, completed_status, completed_resolution,
351
- project_data[:end_time], history_field, history_value, ever_blocked, ever_stalled,
352
- currently_blocked, currently_stalled)
353
- rows << row if row
354
- end
355
- end
379
+ completed_date < today - days_back
380
+ end
356
381
 
357
- rows.sort_by! { |r| -r[:completed_date].to_time.to_i }
382
+ def self.matches_completion? status_at_done, resolution_at_done, completed_status, completed_resolution
383
+ return false if completed_status && status_at_done&.name != completed_status
384
+ return false if completed_resolution && completed_resolution != resolution_at_done
358
385
 
359
- if rows.empty?
360
- text = 'No completed work found.'
361
- else
362
- lines = rows.map do |r|
363
- ct = r[:cycle_time_days] ? "#{r[:cycle_time_days]}d" : 'unknown'
364
- fe = r[:flow_efficiency] ? " | FE: #{r[:flow_efficiency]}%" : ''
365
- completion = [r[:status_at_done], r[:resolution_at_done]].compact.join(' / ')
366
- "#{r[:key]} | #{r[:project]} | #{r[:type]} | #{r[:completed_date]} | " \
367
- "Cycle time: #{ct}#{fe} | #{completion} | #{r[:summary]}"
368
- end
369
- text = lines.join("\n")
370
- end
386
+ true
387
+ end
371
388
 
372
- MCP::Tool::Response.new([{ type: 'text', text: text }])
389
+ def self.format_line row
390
+ ct = row[:cycle_time_days] ? "#{row[:cycle_time_days]}d" : 'unknown'
391
+ fe = row[:flow_efficiency] ? " | FE: #{row[:flow_efficiency]}%" : ''
392
+ completion = [row[:status_at_done], row[:resolution_at_done]].compact.join(' / ')
393
+ "#{row[:key]} | #{row[:project]} | #{row[:type]} | #{row[:completed_date]} | " \
394
+ "Cycle time: #{ct}#{fe} | #{completion} | #{row[:summary]}"
373
395
  end
374
396
  end
375
397
 
@@ -397,49 +419,40 @@ class McpServer
397
419
  )
398
420
 
399
421
  def self.call(server_context:, project: nil, project_name: nil, current_status: nil, current_column: nil,
400
- history_field: nil, history_value: nil, ever_blocked: nil, ever_stalled: nil,
401
- currently_blocked: nil, currently_stalled: nil, **)
422
+ **history_args)
402
423
  project ||= project_name
403
- rows = []
404
- allowed_projects = McpServer.resolve_projects(server_context, project)
405
-
406
- server_context[:projects].each do |project_name, project_data|
407
- next if allowed_projects && !allowed_projects.include?(project_name)
408
-
409
- project_data[:issues].each do |issue|
410
- started, stopped = issue.started_stopped_times
411
- next if started || stopped
412
- next if current_status && issue.status.name != current_status
413
- next if current_column && McpServer.column_name_for(issue.board, issue.status.id) != current_column
414
- unless McpServer.matches_history?(issue, project_data[:end_time],
415
- history_field, history_value, ever_blocked, ever_stalled,
416
- currently_blocked, currently_stalled)
417
- next
418
- end
419
-
420
- rows << {
421
- key: issue.key,
422
- summary: issue.summary,
423
- status: issue.status.name,
424
- type: issue.type,
425
- created: issue.created.to_date,
426
- project: project_name
427
- }
428
- end
429
- end
424
+ filter = McpServer::HistoryFilter.from(**history_args)
430
425
 
426
+ rows = McpServer.collect_rows(server_context, project) do |issue, name, project_data|
427
+ build_row(
428
+ issue: issue, project_name: name, project_data: project_data,
429
+ current_status: current_status, current_column: current_column, filter: filter
430
+ )
431
+ end
431
432
  rows.sort_by! { |r| r[:created] }
432
433
 
433
- if rows.empty?
434
- text = 'No unstarted work found.'
435
- else
436
- lines = rows.map do |r|
437
- "#{r[:key]} | #{r[:project]} | #{r[:type]} | #{r[:status]} | Created: #{r[:created]} | #{r[:summary]}"
438
- end
439
- text = lines.join("\n")
440
- end
434
+ McpServer.render_rows(rows, empty: 'No unstarted work found.') { |r| format_line(r) }
435
+ end
441
436
 
442
- MCP::Tool::Response.new([{ type: 'text', text: text }])
437
+ def self.build_row issue:, project_name:, project_data:, current_status:, current_column:, filter:
438
+ started, stopped = issue.started_stopped_times
439
+ return nil if started || stopped
440
+ return nil unless McpServer.matches_current_state?(issue, current_status, current_column)
441
+ return nil unless McpServer.matches_history?(issue, project_data[:end_time], filter)
442
+
443
+ {
444
+ key: issue.key,
445
+ summary: issue.summary,
446
+ status: issue.status.name,
447
+ type: issue.type,
448
+ created: issue.created.to_date,
449
+ project: project_name
450
+ }
451
+ end
452
+
453
+ def self.format_line row
454
+ "#{row[:key]} | #{row[:project]} | #{row[:type]} | #{row[:status]} | " \
455
+ "Created: #{row[:created]} | #{row[:summary]}"
443
456
  end
444
457
  end
445
458
 
@@ -486,42 +499,45 @@ class McpServer
486
499
  project ||= project_name
487
500
  group_by = 'column' if column
488
501
 
489
- totals = Hash.new { |h, k| h[k] = { total_seconds: 0.0, visit_count: 0 } }
490
- allowed_projects = McpServer.resolve_projects(server_context, project)
502
+ totals = accumulate_times(server_context, project, issue_state, group_by)
503
+ rows = totals.map { |name, data| summary_row(name, data) }.sort_by { |r| -r[:avg_days] }
491
504
 
492
- server_context[:projects].each do |project_name, project_data|
493
- next if allowed_projects && !allowed_projects.include?(project_name)
494
-
495
- project_data[:issues].each do |issue|
496
- next unless select_issues(issue, issue_state)
505
+ McpServer.render_rows(rows, empty: 'No data found.') { |r| format_line(r, group_by) }
506
+ end
497
507
 
498
- time_map = if group_by == 'column'
499
- McpServer.time_per_column(issue, project_data[:end_time])
500
- else
501
- McpServer.time_per_status(issue, project_data[:end_time])
502
- end
508
+ def self.accumulate_times server_context, project, issue_state, group_by
509
+ totals = Hash.new { |hash, key| hash[key] = { total_seconds: 0.0, visit_count: 0 } }
510
+ McpServer.each_allowed_issue(server_context, project) do |issue, _name, project_data|
511
+ next unless select_issues(issue, issue_state)
503
512
 
504
- time_map.each do |name, seconds|
505
- totals[name][:total_seconds] += seconds
506
- totals[name][:visit_count] += 1
507
- end
513
+ time_map_for(issue, project_data[:end_time], group_by).each do |name, seconds|
514
+ totals[name][:total_seconds] += seconds
515
+ totals[name][:visit_count] += 1
508
516
  end
509
517
  end
518
+ totals
519
+ end
510
520
 
511
- return MCP::Tool::Response.new([{ type: 'text', text: 'No data found.' }]) if totals.empty?
512
-
513
- rows = totals.map do |name, data|
514
- total_days = (data[:total_seconds] / 86_400.0).round(1)
515
- avg_days = (data[:total_seconds] / data[:visit_count] / 86_400.0).round(1)
516
- { name: name, total_days: total_days, avg_days: avg_days, visit_count: data[:visit_count] }
521
+ def self.time_map_for issue, end_time, group_by
522
+ if group_by == 'column'
523
+ McpServer.time_per_column(issue, end_time)
524
+ else
525
+ McpServer.time_per_status(issue, end_time)
517
526
  end
518
- rows.sort_by! { |r| -r[:avg_days] }
527
+ end
519
528
 
529
+ def self.summary_row name, data
530
+ {
531
+ name: name,
532
+ total_days: (data[:total_seconds] / 86_400.0).round(1),
533
+ avg_days: (data[:total_seconds] / data[:visit_count] / 86_400.0).round(1),
534
+ visit_count: data[:visit_count]
535
+ }
536
+ end
537
+
538
+ def self.format_line row, group_by
520
539
  label = group_by == 'column' ? 'Column' : 'Status'
521
- lines = rows.map do |r|
522
- "#{label}: #{r[:name]} | Avg: #{r[:avg_days]}d | Total: #{r[:total_days]}d | Issues: #{r[:visit_count]}"
523
- end
524
- MCP::Tool::Response.new([{ type: 'text', text: lines.join("\n") }])
540
+ "#{label}: #{row[:name]} | Avg: #{row[:avg_days]}d | Total: #{row[:total_days]}d | Issues: #{row[:visit_count]}"
525
541
  end
526
542
  end
527
543