jirametrics 2.27 → 2.28

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: d93d43ce61e0fcec89c5ee737044cff4007b1f0079be2c6be774c56cb15130e8
4
+ data.tar.gz: ffbe1b15fc8aa08f928e7c322cf53dc5a1646b9da01b7b9e81e8b5bd2c44650d
5
5
  SHA512:
6
- metadata.gz: aad3747a78dcf530df02244720c676632816a30688045d95c9a701ba946a5573f81a2e3955c5035e745fb197f5c6cff58491a63d35db68b3abf35817c78dc0b8
7
- data.tar.gz: fcf17a8c2a4c91e4de45ff15150d1de9147c890d9e80354b490bec2b27fb17ef2b20e7bc6073c503e88fd0ad188c414002d8312500b22dd5207b043d3c253be7
6
+ metadata.gz: 66d0c9c0db9b11c4278935a8342cae5f9b6b4e34fffc46d1bad6b719b6ca779374191f05f4139caf28995e8014ab77cfea79945df9abe546405abf9e381e4311
7
+ data.tar.gz: 3f10707f9b44c14dd8dae67ce73cd208cb7554cee147d502f3b99315b49cb524aa6f12662c1624720f204e814a5686e37393f907da8ec43f1befc88398a79cc7
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'jirametrics'
5
+ JiraMetrics.start(['mcp'] + ARGV)
@@ -70,7 +70,7 @@ class CumulativeFlowDiagram < ChartBase
70
70
  CT and TP cannot be calculated and are hidden; only WIP is shown.
71
71
  </div>
72
72
  <div class="p">
73
- See also: This article on [how to read a CFD](https://blog.mikebowler.ca/2026/03/27/cumulative-flow-diagram/).
73
+ See also: This article on <a href="https://blog.mikebowler.ca/2026/03/27/cumulative-flow-diagram/">how to read a CFD</a>.
74
74
  </div>
75
75
  HTML
76
76
  instance_eval(&block)
@@ -87,13 +87,14 @@ class DailyView < ChartBase
87
87
  lines << ["#{marker} Blocked by flag"] if blocked_stalled.flag
88
88
  lines << ["#{marker} Blocked by status: #{blocked_stalled.status}"] if blocked_stalled.blocked_by_status?
89
89
  blocked_stalled.blocking_issue_keys&.each do |key|
90
- blocking_issue = issues.find { |i| i.key == key }
90
+ blocking_issue = issues.find_by_key key: key, include_hidden: true
91
91
  if blocking_issue
92
- lines << "<section><div class=\"foldable startFolded\">#{marker} Blocked by issue: #{key}</div>"
92
+ lines << "<section><div class=\"foldable startFolded\">#{marker} Blocked by issue: " \
93
+ "#{make_issue_label issue: blocking_issue, done: blocking_issue.done?}</div>"
93
94
  lines << blocking_issue
94
95
  lines << '</section>'
95
96
  else
96
- lines << ["#{marker} Blocked by issue: #{key}"]
97
+ lines << ["#{marker} Blocked by issue: #{key} (no description found)"]
97
98
  end
98
99
  end
99
100
  elsif blocked_stalled.stalled_by_status?
@@ -108,11 +108,24 @@ class DownloaderForCloud < Downloader
108
108
  }
109
109
  issue = Issue.new(raw: issue_json, board: board)
110
110
  data = issue_datas.find { |d| d.key == issue.key }
111
+ unless data
112
+ log " Skipping #{issue.key}: returned by Jira but key not in request (issue may have been moved)"
113
+ next
114
+ end
111
115
  data.up_to_date = true
112
116
  data.last_modified = issue.updated
113
117
  data.issue = issue
114
118
  end
115
119
 
120
+ # Mark any unmatched requests as up_to_date to prevent infinite re-fetching.
121
+ # This happens when Jira returns a different key (moved issue) leaving the original unmatched.
122
+ issue_datas.each do |data|
123
+ next if data.up_to_date
124
+
125
+ log " Skipping #{data.key}: not returned by Jira (issue may have been deleted or moved)"
126
+ data.up_to_date = true
127
+ end
128
+
116
129
  issue_datas
117
130
  end
118
131
 
@@ -168,15 +181,20 @@ class DownloaderForCloud < Downloader
168
181
 
169
182
  issue_data_hash = search_for_issues jql: jql, board_id: board.id, path: path
170
183
 
184
+ checked_for_related = Set.new
185
+ in_related_phase = false
186
+
171
187
  loop do
172
188
  related_issue_keys = Set.new
173
189
  stale = issue_data_hash.values.reject { |data| data.up_to_date }
174
190
  unless stale.empty?
175
- log_start ' Downloading more issues '
191
+ log_start ' Downloading more issues ' unless in_related_phase
176
192
  stale.each_slice(100) do |slice|
177
193
  slice = bulk_fetch_issues(issue_datas: slice, board: board, in_initial_query: true)
178
194
  progress_dot
179
195
  slice.each do |data|
196
+ next unless data.issue
197
+
180
198
  @file_system.save_json(
181
199
  json: data.issue.raw, filename: data.cache_path
182
200
  )
@@ -184,22 +202,25 @@ class DownloaderForCloud < Downloader
184
202
  # to parse the file just to find the timestamp
185
203
  @file_system.utime time: data.issue.updated, file: data.cache_path
186
204
 
187
- issue = data.issue
188
- next unless issue
189
-
190
- parent_key = issue.parent_key(project_config: @download_config.project_config)
191
- related_issue_keys << parent_key if parent_key
192
-
193
- # Sub-tasks
194
- issue.raw['fields']['subtasks']&.each do |raw_subtask|
195
- related_issue_keys << raw_subtask['key']
196
- end
205
+ collect_related_issue_keys issue: data.issue, related_issue_keys: related_issue_keys
206
+ checked_for_related << data.key
197
207
  end
198
208
  end
199
- end_progress
209
+ end_progress unless in_related_phase
200
210
  end
201
211
 
202
- # Remove all the ones we already downloaded
212
+ # Also scan up-to-date cached issues we haven't checked yet — they may reference
213
+ # related issues that are not in the primary query result.
214
+ issue_data_hash.each_value do |data|
215
+ next if checked_for_related.include?(data.key)
216
+ next unless @file_system.file_exist?(data.cache_path)
217
+
218
+ checked_for_related << data.key
219
+ raw = @file_system.load_json(data.cache_path)
220
+ collect_related_issue_keys issue: Issue.new(raw: raw, board: board), related_issue_keys: related_issue_keys
221
+ end
222
+
223
+ # Remove all the ones we already have
203
224
  related_issue_keys.reject! { |key| issue_data_hash[key] }
204
225
 
205
226
  related_issue_keys.each do |key|
@@ -211,9 +232,15 @@ class DownloaderForCloud < Downloader
211
232
  end
212
233
  break if related_issue_keys.empty?
213
234
 
214
- log " Downloading linked issues for board #{board.id}", both: true
235
+ unless in_related_phase
236
+ in_related_phase = true
237
+ log " Identifying related issues (parents, subtasks, links) for board #{board.id}", both: true
238
+ log_start ' Downloading more issues '
239
+ end
215
240
  end
216
241
 
242
+ end_progress if in_related_phase
243
+
217
244
  delete_issues_from_cache_that_are_not_in_server(
218
245
  issue_data_hash: issue_data_hash, path: path
219
246
  )
@@ -238,6 +265,22 @@ class DownloaderForCloud < Downloader
238
265
  end
239
266
  end
240
267
 
268
+ def collect_related_issue_keys issue:, related_issue_keys:
269
+ parent_key = issue.parent_key(project_config: @download_config.project_config)
270
+ related_issue_keys << parent_key if parent_key
271
+
272
+ issue.raw['fields']['subtasks']&.each do |raw_subtask|
273
+ related_issue_keys << raw_subtask['key']
274
+ end
275
+
276
+ issue.raw['fields']['issuelinks']&.each do |link|
277
+ next if link['type']['name'] == 'Cloners'
278
+
279
+ linked = link['inwardIssue'] || link['outwardIssue']
280
+ related_issue_keys << linked['key'] if linked
281
+ end
282
+ end
283
+
241
284
  def last_modified filename:
242
285
  File.mtime(filename) if File.exist?(filename)
243
286
  end
@@ -35,7 +35,7 @@ function makeFoldable() {
35
35
  const toggleButton = document.createElement(element.tagName); //'button');
36
36
  toggleButton.id = toggleId;
37
37
  toggleButton.className = 'foldable-toggle-btn';
38
- toggleButton.innerHTML = '▼ ' + element.textContent;
38
+ toggleButton.innerHTML = '▼ ' + element.innerHTML;
39
39
 
40
40
  // Create a content container
41
41
  const contentContainer = document.createElement('div');
@@ -210,7 +210,25 @@ class Issue
210
210
  end
211
211
 
212
212
  def first_time_visible_on_board
213
- first_time_in_status(*board.visible_columns.collect(&:status_ids).flatten)
213
+ visible_status_ids = board.visible_columns.collect(&:status_ids).flatten
214
+ return first_time_in_status(*visible_status_ids) unless board.scrum?
215
+
216
+ # For scrum boards, an issue is only visible when BOTH conditions are true simultaneously:
217
+ # 1. Its status is in a visible column
218
+ # 2. It is in an active sprint
219
+ # At each moment one condition becomes true, check if the other is already true.
220
+ candidates = []
221
+
222
+ status_changes.each do |change|
223
+ next unless visible_status_ids.include?(change.value_id)
224
+ candidates << change if in_active_sprint_at?(change.time)
225
+ end
226
+
227
+ sprint_entry_events.each do |effective_time, representative_change|
228
+ candidates << representative_change if in_visible_status_at?(effective_time, visible_status_ids)
229
+ end
230
+
231
+ candidates.min_by(&:time)
214
232
  end
215
233
 
216
234
  def reasons_not_visible_on_board
@@ -815,6 +833,72 @@ class Issue
815
833
 
816
834
  private
817
835
 
836
+ # Returns [[effective_time, change_item]] for each moment the issue entered an active sprint.
837
+ # Skips sprints that were removed before they activated.
838
+ def sprint_entry_events
839
+ data_clazz = Struct.new(:sprint_id, :sprint_start, :add_time, :change)
840
+ events = []
841
+ in_sprint = []
842
+
843
+ @changes.each do |change|
844
+ next unless change.sprint?
845
+
846
+ (change.value_id - change.old_value_id).each do |sprint_id|
847
+ sprint_start, = find_sprint_start_end(sprint_id: sprint_id, change: change)
848
+ in_sprint << data_clazz.new(sprint_id, sprint_start, change.time, change) if sprint_start
849
+ end
850
+
851
+ (change.old_value_id - change.value_id).each do |sprint_id|
852
+ data = in_sprint.find { |d| d.sprint_id == sprint_id }
853
+ next unless data
854
+
855
+ in_sprint.delete(data)
856
+ next if data.sprint_start >= change.time # sprint hadn't activated before removal
857
+
858
+ effective_time = [data.add_time, data.sprint_start].max
859
+ events << [effective_time, sprint_change_at(effective_time, data.change)]
860
+ end
861
+ end
862
+
863
+ in_sprint.each do |data|
864
+ effective_time = [data.add_time, data.sprint_start].max
865
+ events << [effective_time, sprint_change_at(effective_time, data.change)]
866
+ end
867
+
868
+ events
869
+ end
870
+
871
+ def sprint_change_at effective_time, change
872
+ return change if effective_time == change.time
873
+
874
+ ChangeItem.new(
875
+ raw: { 'field' => 'Sprint', 'toString' => 'Sprint activated', 'to' => '0', 'from' => nil, 'fromString' => nil },
876
+ author_raw: nil,
877
+ time: effective_time,
878
+ artificial: true
879
+ )
880
+ end
881
+
882
+ def in_active_sprint_at? time
883
+ active_ids = []
884
+ @changes.each do |change|
885
+ break if change.time > time
886
+ next unless change.sprint?
887
+
888
+ (change.value_id - change.old_value_id).each do |sprint_id|
889
+ sprint_start, = find_sprint_start_end(sprint_id: sprint_id, change: change)
890
+ active_ids << sprint_id if sprint_start && sprint_start <= time
891
+ end
892
+ (change.old_value_id - change.value_id).each { |id| active_ids.delete(id) }
893
+ end
894
+ active_ids.any?
895
+ end
896
+
897
+ def in_visible_status_at? time, visible_status_ids
898
+ last = status_changes.reverse.find { |c| c.time <= time }
899
+ last && visible_status_ids.include?(last.value_id)
900
+ end
901
+
818
902
  def load_history_into_changes
819
903
  @raw['changelog']['histories']&.each do |history|
820
904
  created = parse_time(history['created'])
@@ -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,35 @@ 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, project_name: nil,
220
+ current_status: nil, current_column: nil,
103
221
  history_field: nil, history_value: nil, ever_blocked: nil, ever_stalled: nil,
104
- currently_blocked: nil, currently_stalled: nil)
222
+ currently_blocked: nil, currently_stalled: nil, **)
223
+ project ||= project_name
105
224
  rows = []
225
+ allowed_projects = McpServer.resolve_projects(server_context, project)
106
226
 
107
227
  server_context[:projects].each do |project_name, project_data|
108
- next if project && project_name != project
228
+ next if allowed_projects && !allowed_projects.include?(project_name)
109
229
 
110
230
  today = project_data[:today]
111
231
  project_data[:issues].each do |issue|
112
232
  started, stopped = issue.started_stopped_times
113
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
114
236
 
115
237
  age = (today - started.to_date).to_i + 1
116
238
  next if min_age_days && age < min_age_days
@@ -203,14 +325,16 @@ class McpServer
203
325
  }
204
326
  end
205
327
 
206
- def self.call(server_context:, days_back: nil, project: nil,
328
+ def self.call(server_context:, days_back: nil, project: nil, project_name: nil,
207
329
  completed_status: nil, completed_resolution: nil,
208
330
  history_field: nil, history_value: nil, ever_blocked: nil, ever_stalled: nil,
209
- currently_blocked: nil, currently_stalled: nil)
331
+ currently_blocked: nil, currently_stalled: nil, **)
332
+ project ||= project_name
210
333
  rows = []
334
+ allowed_projects = McpServer.resolve_projects(server_context, project)
211
335
 
212
336
  server_context[:projects].each do |project_name, project_data|
213
- next if project && project_name != project
337
+ next if allowed_projects && !allowed_projects.include?(project_name)
214
338
 
215
339
  today = project_data[:today]
216
340
  cutoff = today - days_back if days_back
@@ -256,21 +380,33 @@ class McpServer
256
380
  type: 'string',
257
381
  description: 'Only return issues from this project name. Omit to return all projects.'
258
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
+ },
259
391
  **HISTORY_FILTER_SCHEMA
260
392
  }
261
393
  )
262
394
 
263
- def self.call(server_context:, project: nil,
395
+ def self.call(server_context:, project: nil, project_name: nil, current_status: nil, current_column: nil,
264
396
  history_field: nil, history_value: nil, ever_blocked: nil, ever_stalled: nil,
265
- currently_blocked: nil, currently_stalled: nil)
397
+ currently_blocked: nil, currently_stalled: nil, **)
398
+ project ||= project_name
266
399
  rows = []
400
+ allowed_projects = McpServer.resolve_projects(server_context, project)
267
401
 
268
402
  server_context[:projects].each do |project_name, project_data|
269
- next if project && project_name != project
403
+ next if allowed_projects && !allowed_projects.include?(project_name)
270
404
 
271
405
  project_data[:issues].each do |issue|
272
406
  started, stopped = issue.started_stopped_times
273
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
274
410
  unless McpServer.matches_history?(issue, project_data[:end_time],
275
411
  history_field, history_value, ever_blocked, ever_stalled,
276
412
  currently_blocked, currently_stalled)
@@ -302,4 +438,94 @@ class McpServer
302
438
  MCP::Tool::Response.new([{ type: 'text', text: text }])
303
439
  end
304
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
305
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
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.28'
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