jirametrics 2.27 → 2.28pre2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/bin/jirametrics-mcp +5 -0
- data/lib/jirametrics/mcp_server.rb +229 -11
- data/lib/jirametrics/project_config.rb +6 -0
- data/lib/jirametrics.rb +15 -2
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '0409b8154ccd9182692080f022a6abc0112fb737034eb214f29a0af09a0cb3c5'
|
|
4
|
+
data.tar.gz: d3a8d6080925d02572e0f0010c02dfef139e4b4b99234f3675c6ffae6898a08d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a2ecb538d8ffc5f23f784966f809bb3643349d3f9799bd589b601d54370182b3f8b78a688c61a6e5d3cdfa12dd7614480353f58808070ee6987dcd2629100e3a
|
|
7
|
+
data.tar.gz: 310bf1b0b51a14ab393060e4fc782cb413eeb64f473012f17337b0fc36500243105ac6277705f4c8ff76c6faf2ccc981046934ea5639d53cdcd99023c08b5811
|
data/bin/jirametrics-mcp
ADDED
|
@@ -4,17 +4,27 @@ require 'mcp'
|
|
|
4
4
|
require 'mcp/server/transports/stdio_transport'
|
|
5
5
|
|
|
6
6
|
class McpServer
|
|
7
|
-
def initialize projects:, timezone_offset: '+00:00'
|
|
7
|
+
def initialize projects:, aggregates: {}, timezone_offset: '+00:00'
|
|
8
8
|
@projects = projects
|
|
9
|
+
@aggregates = aggregates
|
|
9
10
|
@timezone_offset = timezone_offset
|
|
10
11
|
end
|
|
11
12
|
|
|
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
|
+
|
|
13
23
|
server = MCP::Server.new(
|
|
14
24
|
name: 'jirametrics',
|
|
15
25
|
version: Gem.loaded_specs['jirametrics']&.version&.to_s || '0.0.0',
|
|
16
|
-
tools:
|
|
17
|
-
server_context: { projects: @projects, timezone_offset: @timezone_offset }
|
|
26
|
+
tools: canonical_tools + alias_tools,
|
|
27
|
+
server_context: { projects: @projects, aggregates: @aggregates, timezone_offset: @timezone_offset }
|
|
18
28
|
)
|
|
19
29
|
|
|
20
30
|
transport = MCP::Server::Transports::StdioTransport.new(server)
|
|
@@ -51,6 +61,80 @@ class McpServer
|
|
|
51
61
|
}
|
|
52
62
|
}.freeze
|
|
53
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
|
+
|
|
54
138
|
def self.flow_efficiency_percent issue, end_time
|
|
55
139
|
active_time, total_time = issue.flow_efficiency_numbers(end_time: end_time)
|
|
56
140
|
total_time.positive? ? (active_time / total_time * 100).round(1) : nil
|
|
@@ -79,6 +163,31 @@ class McpServer
|
|
|
79
163
|
true
|
|
80
164
|
end
|
|
81
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
|
+
|
|
82
191
|
class AgingWorkTool < MCP::Tool
|
|
83
192
|
tool_name 'aging_work'
|
|
84
193
|
description 'Returns all issues that have been started but not yet completed (work in progress), ' \
|
|
@@ -95,22 +204,33 @@ class McpServer
|
|
|
95
204
|
type: 'string',
|
|
96
205
|
description: 'Only return issues from this project name. Omit to return all projects.'
|
|
97
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
|
+
},
|
|
98
215
|
**HISTORY_FILTER_SCHEMA
|
|
99
216
|
}
|
|
100
217
|
)
|
|
101
218
|
|
|
102
|
-
def self.call(server_context:, min_age_days: nil, project: nil,
|
|
219
|
+
def self.call(server_context:, min_age_days: nil, project: nil, current_status: nil, current_column: nil,
|
|
103
220
|
history_field: nil, history_value: nil, ever_blocked: nil, ever_stalled: nil,
|
|
104
|
-
currently_blocked: nil, currently_stalled: nil)
|
|
221
|
+
currently_blocked: nil, currently_stalled: nil, **)
|
|
105
222
|
rows = []
|
|
223
|
+
allowed_projects = McpServer.resolve_projects(server_context, project)
|
|
106
224
|
|
|
107
225
|
server_context[:projects].each do |project_name, project_data|
|
|
108
|
-
next if
|
|
226
|
+
next if allowed_projects && !allowed_projects.include?(project_name)
|
|
109
227
|
|
|
110
228
|
today = project_data[:today]
|
|
111
229
|
project_data[:issues].each do |issue|
|
|
112
230
|
started, stopped = issue.started_stopped_times
|
|
113
231
|
next unless started && !stopped
|
|
232
|
+
next if current_status && issue.status.name != current_status
|
|
233
|
+
next if current_column && McpServer.column_name_for(issue.board, issue.status.id) != current_column
|
|
114
234
|
|
|
115
235
|
age = (today - started.to_date).to_i + 1
|
|
116
236
|
next if min_age_days && age < min_age_days
|
|
@@ -206,11 +326,12 @@ class McpServer
|
|
|
206
326
|
def self.call(server_context:, days_back: nil, project: nil,
|
|
207
327
|
completed_status: nil, completed_resolution: nil,
|
|
208
328
|
history_field: nil, history_value: nil, ever_blocked: nil, ever_stalled: nil,
|
|
209
|
-
currently_blocked: nil, currently_stalled: nil)
|
|
329
|
+
currently_blocked: nil, currently_stalled: nil, **)
|
|
210
330
|
rows = []
|
|
331
|
+
allowed_projects = McpServer.resolve_projects(server_context, project)
|
|
211
332
|
|
|
212
333
|
server_context[:projects].each do |project_name, project_data|
|
|
213
|
-
next if
|
|
334
|
+
next if allowed_projects && !allowed_projects.include?(project_name)
|
|
214
335
|
|
|
215
336
|
today = project_data[:today]
|
|
216
337
|
cutoff = today - days_back if days_back
|
|
@@ -256,21 +377,32 @@ class McpServer
|
|
|
256
377
|
type: 'string',
|
|
257
378
|
description: 'Only return issues from this project name. Omit to return all projects.'
|
|
258
379
|
},
|
|
380
|
+
current_status: {
|
|
381
|
+
type: 'string',
|
|
382
|
+
description: 'Only return issues currently in this status (e.g. "To Do", "Backlog").'
|
|
383
|
+
},
|
|
384
|
+
current_column: {
|
|
385
|
+
type: 'string',
|
|
386
|
+
description: 'Only return issues whose current status maps to this board column.'
|
|
387
|
+
},
|
|
259
388
|
**HISTORY_FILTER_SCHEMA
|
|
260
389
|
}
|
|
261
390
|
)
|
|
262
391
|
|
|
263
|
-
def self.call(server_context:, project: nil,
|
|
392
|
+
def self.call(server_context:, project: nil, current_status: nil, current_column: nil,
|
|
264
393
|
history_field: nil, history_value: nil, ever_blocked: nil, ever_stalled: nil,
|
|
265
|
-
currently_blocked: nil, currently_stalled: nil)
|
|
394
|
+
currently_blocked: nil, currently_stalled: nil, **)
|
|
266
395
|
rows = []
|
|
396
|
+
allowed_projects = McpServer.resolve_projects(server_context, project)
|
|
267
397
|
|
|
268
398
|
server_context[:projects].each do |project_name, project_data|
|
|
269
|
-
next if
|
|
399
|
+
next if allowed_projects && !allowed_projects.include?(project_name)
|
|
270
400
|
|
|
271
401
|
project_data[:issues].each do |issue|
|
|
272
402
|
started, stopped = issue.started_stopped_times
|
|
273
403
|
next if started || stopped
|
|
404
|
+
next if current_status && issue.status.name != current_status
|
|
405
|
+
next if current_column && McpServer.column_name_for(issue.board, issue.status.id) != current_column
|
|
274
406
|
unless McpServer.matches_history?(issue, project_data[:end_time],
|
|
275
407
|
history_field, history_value, ever_blocked, ever_stalled,
|
|
276
408
|
currently_blocked, currently_stalled)
|
|
@@ -302,4 +434,90 @@ class McpServer
|
|
|
302
434
|
MCP::Tool::Response.new([{ type: 'text', text: text }])
|
|
303
435
|
end
|
|
304
436
|
end
|
|
437
|
+
|
|
438
|
+
class StatusTimeAnalysisTool < MCP::Tool
|
|
439
|
+
tool_name 'status_time_analysis'
|
|
440
|
+
description 'Aggregates the time issues spend in each status or column, ranked by average days. ' \
|
|
441
|
+
'Useful for identifying bottlenecks. Before calling this tool, always ask the user ' \
|
|
442
|
+
'which issues they want to include: aging (in progress), completed, not yet started, ' \
|
|
443
|
+
'or all. Do not assume — the answer changes the result significantly.'
|
|
444
|
+
|
|
445
|
+
input_schema(
|
|
446
|
+
type: 'object',
|
|
447
|
+
properties: {
|
|
448
|
+
project: {
|
|
449
|
+
type: 'string',
|
|
450
|
+
description: 'Only include issues from this project name. Omit to include all projects.'
|
|
451
|
+
},
|
|
452
|
+
issue_state: {
|
|
453
|
+
type: 'string',
|
|
454
|
+
enum: %w[all aging completed not_started],
|
|
455
|
+
description: 'Which issues to include: "aging" (in progress), "completed", ' \
|
|
456
|
+
'"not_started" (backlog), or "all" (default).'
|
|
457
|
+
},
|
|
458
|
+
group_by: {
|
|
459
|
+
type: 'string',
|
|
460
|
+
enum: %w[status column],
|
|
461
|
+
description: 'Whether to group results by status name (default) or board column.'
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
def self.select_issues issue, issue_state
|
|
467
|
+
started, stopped = issue.started_stopped_times
|
|
468
|
+
case issue_state
|
|
469
|
+
when 'aging' then started && !stopped
|
|
470
|
+
when 'completed' then !!stopped
|
|
471
|
+
when 'not_started' then !started && !stopped
|
|
472
|
+
else true
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def self.call(server_context:, project: nil, issue_state: 'all', group_by: 'status', **)
|
|
477
|
+
totals = Hash.new { |h, k| h[k] = { total_seconds: 0.0, visit_count: 0 } }
|
|
478
|
+
allowed_projects = McpServer.resolve_projects(server_context, project)
|
|
479
|
+
|
|
480
|
+
server_context[:projects].each do |project_name, project_data|
|
|
481
|
+
next if allowed_projects && !allowed_projects.include?(project_name)
|
|
482
|
+
|
|
483
|
+
project_data[:issues].each do |issue|
|
|
484
|
+
next unless select_issues(issue, issue_state)
|
|
485
|
+
|
|
486
|
+
time_map = if group_by == 'column'
|
|
487
|
+
McpServer.time_per_column(issue, project_data[:end_time])
|
|
488
|
+
else
|
|
489
|
+
McpServer.time_per_status(issue, project_data[:end_time])
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
time_map.each do |name, seconds|
|
|
493
|
+
totals[name][:total_seconds] += seconds
|
|
494
|
+
totals[name][:visit_count] += 1
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
return MCP::Tool::Response.new([{ type: 'text', text: 'No data found.' }]) if totals.empty?
|
|
500
|
+
|
|
501
|
+
rows = totals.map do |name, data|
|
|
502
|
+
total_days = (data[:total_seconds] / 86_400.0).round(1)
|
|
503
|
+
avg_days = (data[:total_seconds] / data[:visit_count] / 86_400.0).round(1)
|
|
504
|
+
{ name: name, total_days: total_days, avg_days: avg_days, visit_count: data[:visit_count] }
|
|
505
|
+
end
|
|
506
|
+
rows.sort_by! { |r| -r[:avg_days] }
|
|
507
|
+
|
|
508
|
+
label = group_by == 'column' ? 'Column' : 'Status'
|
|
509
|
+
lines = rows.map do |r|
|
|
510
|
+
"#{label}: #{r[:name]} | Avg: #{r[:avg_days]}d | Total: #{r[:total_days]}d | Issues: #{r[:visit_count]}"
|
|
511
|
+
end
|
|
512
|
+
MCP::Tool::Response.new([{ type: 'text', text: lines.join("\n") }])
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# Alternative tool names used by AI agents other than Claude.
|
|
517
|
+
# Each entry maps an alias name to the canonical tool class it delegates to.
|
|
518
|
+
# The alias inherits the canonical tool's schema and call behaviour automatically.
|
|
519
|
+
# To add a new alias, append one line: 'alias_name' => CanonicalToolClass
|
|
520
|
+
ALIASES = {
|
|
521
|
+
'board_list' => ListProjectsTool
|
|
522
|
+
}.freeze
|
|
305
523
|
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
|
data/lib/jirametrics.rb
CHANGED
|
@@ -56,12 +56,19 @@ class JiraMetrics < Thor
|
|
|
56
56
|
option :name
|
|
57
57
|
desc 'mcp', 'Start in MCP (Model Context Protocol) server mode'
|
|
58
58
|
def mcp
|
|
59
|
+
# Redirect stdout to stderr for the entire startup phase so that any
|
|
60
|
+
# incidental output (from config files, gem loading, etc.) does not
|
|
61
|
+
# corrupt the JSON-RPC channel before the MCP transport takes over.
|
|
62
|
+
original_stdout = $stdout.dup
|
|
63
|
+
$stdout.reopen($stderr)
|
|
64
|
+
|
|
59
65
|
load_config options[:config]
|
|
60
66
|
require 'jirametrics/mcp_server'
|
|
61
67
|
|
|
62
68
|
Exporter.instance.file_system.log_only = true
|
|
63
69
|
|
|
64
70
|
projects = {}
|
|
71
|
+
aggregates = {}
|
|
65
72
|
Exporter.instance.each_project_config(name_filter: options[:name] || '*') do |project|
|
|
66
73
|
project.evaluate_next_level
|
|
67
74
|
project.run load_only: true
|
|
@@ -71,13 +78,19 @@ class JiraMetrics < Thor
|
|
|
71
78
|
end_time: project.time_range.end
|
|
72
79
|
}
|
|
73
80
|
rescue StandardError => e
|
|
74
|
-
|
|
81
|
+
if e.message.start_with? 'This is an aggregated project'
|
|
82
|
+
names = project.aggregate_project_names
|
|
83
|
+
aggregates[project.name] = names if names.any?
|
|
84
|
+
next
|
|
85
|
+
end
|
|
75
86
|
next if e.message.start_with? 'No data found'
|
|
76
87
|
|
|
77
88
|
raise
|
|
78
89
|
end
|
|
79
90
|
|
|
80
|
-
|
|
91
|
+
$stdout.reopen(original_stdout)
|
|
92
|
+
original_stdout.close
|
|
93
|
+
McpServer.new(projects: projects, aggregates: aggregates, timezone_offset: Exporter.instance.timezone_offset).run
|
|
81
94
|
end
|
|
82
95
|
|
|
83
96
|
option :config
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jirametrics
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 2.28pre2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mike Bowler
|
|
@@ -83,10 +83,12 @@ description: Extract metrics from Jira and export to either a report or to CSV f
|
|
|
83
83
|
email: mbowler@gargoylesoftware.com
|
|
84
84
|
executables:
|
|
85
85
|
- jirametrics
|
|
86
|
+
- jirametrics-mcp
|
|
86
87
|
extensions: []
|
|
87
88
|
extra_rdoc_files: []
|
|
88
89
|
files:
|
|
89
90
|
- bin/jirametrics
|
|
91
|
+
- bin/jirametrics-mcp
|
|
90
92
|
- lib/jirametrics.rb
|
|
91
93
|
- lib/jirametrics/aggregate_config.rb
|
|
92
94
|
- lib/jirametrics/aging_work_bar_chart.rb
|