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 +4 -4
- data/bin/jirametrics-mcp +5 -0
- data/lib/jirametrics/cumulative_flow_diagram.rb +1 -1
- data/lib/jirametrics/daily_view.rb +4 -3
- data/lib/jirametrics/downloader_for_cloud.rb +57 -14
- data/lib/jirametrics/html/index.js +1 -1
- data/lib/jirametrics/issue.rb +85 -1
- data/lib/jirametrics/mcp_server.rb +238 -12
- 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: d93d43ce61e0fcec89c5ee737044cff4007b1f0079be2c6be774c56cb15130e8
|
|
4
|
+
data.tar.gz: ffbe1b15fc8aa08f928e7c322cf53dc5a1646b9da01b7b9e81e8b5bd2c44650d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 66d0c9c0db9b11c4278935a8342cae5f9b6b4e34fffc46d1bad6b719b6ca779374191f05f4139caf28995e8014ab77cfea79945df9abe546405abf9e381e4311
|
|
7
|
+
data.tar.gz: 3f10707f9b44c14dd8dae67ce73cd208cb7554cee147d502f3b99315b49cb524aa6f12662c1624720f204e814a5686e37393f907da8ec43f1befc88398a79cc7
|
data/bin/jirametrics-mcp
ADDED
|
@@ -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
|
|
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.
|
|
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:
|
|
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
|
|
188
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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.
|
|
38
|
+
toggleButton.innerHTML = '▼ ' + element.innerHTML;
|
|
39
39
|
|
|
40
40
|
// Create a content container
|
|
41
41
|
const contentContainer = document.createElement('div');
|
data/lib/jirametrics/issue.rb
CHANGED
|
@@ -210,7 +210,25 @@ class Issue
|
|
|
210
210
|
end
|
|
211
211
|
|
|
212
212
|
def first_time_visible_on_board
|
|
213
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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: '2.
|
|
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
|