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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c87212eebf4e22145b8e66741174658bffc3cb1e47bd45bfe3f7590586a42200
4
- data.tar.gz: 00fb6c4375e7e6858359fa754af42c697028198dedd2ae8100c98d0564f59db8
3
+ metadata.gz: '0409b8154ccd9182692080f022a6abc0112fb737034eb214f29a0af09a0cb3c5'
4
+ data.tar.gz: d3a8d6080925d02572e0f0010c02dfef139e4b4b99234f3675c6ffae6898a08d
5
5
  SHA512:
6
- metadata.gz: aad3747a78dcf530df02244720c676632816a30688045d95c9a701ba946a5573f81a2e3955c5035e745fb197f5c6cff58491a63d35db68b3abf35817c78dc0b8
7
- data.tar.gz: fcf17a8c2a4c91e4de45ff15150d1de9147c890d9e80354b490bec2b27fb17ef2b20e7bc6073c503e88fd0ad188c414002d8312500b22dd5207b043d3c253be7
6
+ metadata.gz: a2ecb538d8ffc5f23f784966f809bb3643349d3f9799bd589b601d54370182b3f8b78a688c61a6e5d3cdfa12dd7614480353f58808070ee6987dcd2629100e3a
7
+ data.tar.gz: 310bf1b0b51a14ab393060e4fc782cb413eeb64f473012f17337b0fc36500243105ac6277705f4c8ff76c6faf2ccc981046934ea5639d53cdcd99023c08b5811
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'jirametrics'
5
+ JiraMetrics.start(['mcp'] + ARGV)
@@ -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: [AgingWorkTool, CompletedWorkTool, NotYetStartedTool],
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 project && project_name != project
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 project && project_name != project
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 project && project_name != project
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
- next if e.message.start_with? 'This is an aggregated project'
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
- McpServer.new(projects: projects, timezone_offset: Exporter.instance.timezone_offset).run
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: '2.27'
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