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.
- checksums.yaml +4 -4
- data/lib/jirametrics/aggregate_config.rb +42 -29
- data/lib/jirametrics/aging_work_bar_chart.rb +52 -40
- data/lib/jirametrics/aging_work_in_progress_chart.rb +65 -60
- data/lib/jirametrics/aging_work_table.rb +36 -33
- data/lib/jirametrics/anonymizer.rb +31 -86
- data/lib/jirametrics/atlassian_document_format.rb +11 -4
- data/lib/jirametrics/blocked_stalled_by_date_builder.rb +64 -0
- data/lib/jirametrics/blocked_stalled_change.rb +5 -1
- data/lib/jirametrics/blocked_stalled_change_stream_builder.rb +194 -0
- data/lib/jirametrics/board.rb +23 -9
- data/lib/jirametrics/board_config.rb +0 -7
- data/lib/jirametrics/board_movement_calculator.rb +24 -21
- data/lib/jirametrics/cfd_data_builder.rb +46 -44
- data/lib/jirametrics/change_item.rb +19 -7
- data/lib/jirametrics/chart_base.rb +50 -64
- data/lib/jirametrics/cumulative_flow_diagram.rb +59 -46
- data/lib/jirametrics/cycle_time_config.rb +35 -46
- data/lib/jirametrics/cycletime_histogram.rb +7 -0
- data/lib/jirametrics/cycletime_scatterplot.rb +7 -0
- data/lib/jirametrics/daily_view.rb +83 -61
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +24 -9
- data/lib/jirametrics/daily_wip_chart.rb +72 -45
- data/lib/jirametrics/data_quality_report.rb +61 -63
- data/lib/jirametrics/dependency_chart.rb +40 -31
- data/lib/jirametrics/download_config.rb +0 -3
- data/lib/jirametrics/downloader.rb +8 -7
- data/lib/jirametrics/downloader_for_cloud.rb +108 -72
- data/lib/jirametrics/estimate_accuracy_chart.rb +13 -13
- data/lib/jirametrics/examples/standard_project.rb +2 -2
- data/lib/jirametrics/expedited_chart.rb +47 -37
- data/lib/jirametrics/exporter.rb +19 -10
- data/lib/jirametrics/file_config.rb +19 -12
- data/lib/jirametrics/file_system.rb +16 -10
- data/lib/jirametrics/flow_efficiency_calculator.rb +62 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +4 -2
- data/lib/jirametrics/github_gateway.rb +23 -9
- data/lib/jirametrics/groupable_issue_chart.rb +3 -0
- data/lib/jirametrics/html/index.css +6 -0
- data/lib/jirametrics/html_report_config.rb +9 -29
- data/lib/jirametrics/issue.rb +290 -389
- data/lib/jirametrics/issue_collection.rb +1 -0
- data/lib/jirametrics/issue_printer.rb +60 -23
- data/lib/jirametrics/jira_gateway.rb +29 -18
- data/lib/jirametrics/mcp_server.rb +225 -209
- data/lib/jirametrics/project_config.rb +158 -134
- data/lib/jirametrics/pull_request_cycle_time_histogram.rb +1 -20
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +11 -33
- data/lib/jirametrics/rules.rb +1 -0
- data/lib/jirametrics/self_or_issue_dispatcher.rb +1 -3
- data/lib/jirametrics/sprint_burndown.rb +142 -242
- data/lib/jirametrics/sprint_count_measure.rb +42 -0
- data/lib/jirametrics/sprint_issue_change_data.rb +1 -0
- data/lib/jirametrics/sprint_points_measure.rb +62 -0
- data/lib/jirametrics/sprint_summary_stats.rb +16 -0
- data/lib/jirametrics/status_collection.rb +2 -2
- data/lib/jirametrics/stitcher.rb +21 -16
- data/lib/jirametrics/throughput_chart.rb +38 -24
- data/lib/jirametrics/time_based_chart.rb +65 -0
- data/lib/jirametrics/time_based_histogram.rb +30 -23
- data/lib/jirametrics/time_based_scatterplot.rb +11 -2
- data/lib/jirametrics/trend_line_calculator.rb +2 -2
- data/lib/jirametrics/wip_by_column_chart.rb +73 -32
- data/lib/jirametrics.rb +6 -2
- 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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
148
|
-
return false if ever_blocked && bsc.none?(&:blocked?)
|
|
149
|
-
return false if
|
|
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.
|
|
157
|
-
|
|
158
|
-
return false if
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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.
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
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
|
|
313
|
-
|
|
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:
|
|
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.
|
|
333
|
-
|
|
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
|
-
|
|
344
|
-
|
|
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
|
-
|
|
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
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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
|
-
|
|
401
|
-
currently_blocked: nil, currently_stalled: nil, **)
|
|
422
|
+
**history_args)
|
|
402
423
|
project ||= project_name
|
|
403
|
-
|
|
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
|
-
|
|
434
|
-
|
|
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
|
-
|
|
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 =
|
|
490
|
-
|
|
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
|
-
|
|
493
|
-
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|