jirametrics 2.13 → 2.30
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/aggregate_config.rb +10 -2
- data/lib/jirametrics/aging_work_bar_chart.rb +191 -133
- data/lib/jirametrics/aging_work_in_progress_chart.rb +43 -11
- data/lib/jirametrics/aging_work_table.rb +9 -7
- data/lib/jirametrics/anonymizer.rb +81 -6
- data/lib/jirametrics/atlassian_document_format.rb +101 -97
- data/lib/jirametrics/bar_chart_range.rb +17 -0
- data/lib/jirametrics/blocked_stalled_change.rb +5 -3
- data/lib/jirametrics/board.rb +32 -8
- data/lib/jirametrics/board_config.rb +4 -1
- data/lib/jirametrics/board_feature.rb +14 -0
- data/lib/jirametrics/board_movement_calculator.rb +2 -2
- data/lib/jirametrics/cfd_data_builder.rb +108 -0
- data/lib/jirametrics/change_item.rb +14 -6
- data/lib/jirametrics/chart_base.rb +141 -3
- data/lib/jirametrics/css_variable.rb +1 -1
- data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
- data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +21 -4
- data/lib/jirametrics/cycletime_histogram.rb +15 -101
- data/lib/jirametrics/cycletime_scatterplot.rb +17 -83
- data/lib/jirametrics/daily_view.rb +85 -53
- data/lib/jirametrics/daily_wip_by_age_chart.rb +4 -5
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +14 -4
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
- data/lib/jirametrics/daily_wip_chart.rb +30 -8
- data/lib/jirametrics/data_quality_report.rb +43 -12
- data/lib/jirametrics/dependency_chart.rb +6 -3
- data/lib/jirametrics/download_config.rb +15 -0
- data/lib/jirametrics/downloader.rb +117 -100
- data/lib/jirametrics/downloader_for_cloud.rb +287 -0
- data/lib/jirametrics/downloader_for_data_center.rb +95 -0
- data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
- data/lib/jirametrics/examples/aggregated_project.rb +2 -2
- data/lib/jirametrics/examples/standard_project.rb +41 -28
- data/lib/jirametrics/expedited_chart.rb +3 -1
- data/lib/jirametrics/exporter.rb +26 -6
- data/lib/jirametrics/file_config.rb +9 -11
- data/lib/jirametrics/file_system.rb +59 -3
- data/lib/jirametrics/fix_version.rb +13 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
- data/lib/jirametrics/github_gateway.rb +115 -0
- data/lib/jirametrics/groupable_issue_chart.rb +11 -1
- data/lib/jirametrics/grouping_rules.rb +26 -4
- data/lib/jirametrics/html/aging_work_bar_chart.erb +5 -5
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +3 -1
- data/lib/jirametrics/html/aging_work_table.erb +5 -0
- data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +40 -5
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
- data/lib/jirametrics/html/expedited_chart.erb +6 -14
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
- data/lib/jirametrics/html/index.css +249 -69
- data/lib/jirametrics/html/index.erb +9 -35
- data/lib/jirametrics/html/index.js +164 -0
- data/lib/jirametrics/html/legacy_colors.css +174 -0
- data/lib/jirametrics/html/sprint_burndown.erb +17 -15
- data/lib/jirametrics/html/throughput_chart.erb +42 -11
- data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +15 -11
- data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
- data/lib/jirametrics/html_generator.rb +32 -0
- data/lib/jirametrics/html_report_config.rb +52 -57
- data/lib/jirametrics/issue.rb +304 -101
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +77 -17
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +128 -12
- data/lib/jirametrics/pull_request.rb +30 -0
- data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
- data/lib/jirametrics/pull_request_review.rb +13 -0
- data/lib/jirametrics/raw_javascript.rb +17 -0
- data/lib/jirametrics/settings.json +5 -1
- data/lib/jirametrics/sprint.rb +12 -0
- data/lib/jirametrics/sprint_burndown.rb +10 -4
- data/lib/jirametrics/status.rb +1 -1
- data/lib/jirametrics/stitcher.rb +81 -0
- data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
- data/lib/jirametrics/throughput_chart.rb +73 -23
- data/lib/jirametrics/time_based_histogram.rb +139 -0
- data/lib/jirametrics/time_based_scatterplot.rb +107 -0
- data/lib/jirametrics/wip_by_column_chart.rb +236 -0
- data/lib/jirametrics.rb +83 -69
- metadata +60 -6
|
@@ -3,21 +3,83 @@
|
|
|
3
3
|
require 'cgi'
|
|
4
4
|
require 'json'
|
|
5
5
|
require 'English'
|
|
6
|
+
require 'open3'
|
|
6
7
|
|
|
7
8
|
class JiraGateway
|
|
8
|
-
attr_accessor :ignore_ssl_errors
|
|
9
|
+
attr_accessor :ignore_ssl_errors
|
|
10
|
+
attr_reader :jira_url, :settings, :file_system
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
RETRYABLE_EXIT_CODES = [7, 28, 35, 56].freeze
|
|
13
|
+
MAX_RETRIES = 3
|
|
14
|
+
RETRY_DELAY_SECONDS = 5
|
|
15
|
+
|
|
16
|
+
def initialize file_system:, jira_config:, settings:
|
|
11
17
|
@file_system = file_system
|
|
18
|
+
load_jira_config(jira_config)
|
|
19
|
+
@settings = settings
|
|
20
|
+
@ignore_ssl_errors = settings['ignore_ssl_errors']
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def post_request relative_url:, payload:
|
|
24
|
+
command = make_curl_command url: "#{@jira_url}#{relative_url}", method: 'POST'
|
|
25
|
+
exec_and_parse_response command: command, stdin_data: payload
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def exec_and_parse_response command:, stdin_data:
|
|
29
|
+
log_entry = " #{command.gsub(/\s+/, ' ')}"
|
|
30
|
+
log_entry = sanitize_message log_entry
|
|
31
|
+
@file_system.log log_entry
|
|
32
|
+
|
|
33
|
+
retries = 0
|
|
34
|
+
loop do
|
|
35
|
+
stdout, stderr, status = capture3(command, stdin_data: stdin_data)
|
|
36
|
+
|
|
37
|
+
if status.success?
|
|
38
|
+
@file_system.log "Returned (stderr): #{stderr.inspect}" unless stderr == ''
|
|
39
|
+
raise 'no response from curl on stdout' if stdout == ''
|
|
40
|
+
return parse_response(command: command, result: stdout)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if RETRYABLE_EXIT_CODES.include?(status.exitstatus) && retries < MAX_RETRIES
|
|
44
|
+
retries += 1
|
|
45
|
+
@file_system.log "Transient network error (exit #{status.exitstatus}), retrying in #{RETRY_DELAY_SECONDS}s (attempt #{retries}/#{MAX_RETRIES})..."
|
|
46
|
+
sleep_between_retries
|
|
47
|
+
next
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
@file_system.error "Failed call with exit status #{status.exitstatus}!"
|
|
51
|
+
@file_system.error "Returned (stdout): #{stdout.inspect}"
|
|
52
|
+
@file_system.error "Returned (stderr): #{stderr.inspect}"
|
|
53
|
+
if stderr.include?('401')
|
|
54
|
+
raise 'The request was not authorized. Verify that your authentication token hasn\'t expired'
|
|
55
|
+
end
|
|
56
|
+
raise "Failed call with exit status #{status.exitstatus}. " \
|
|
57
|
+
"See #{@file_system.logfile_name} for details"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def capture3 command, stdin_data:
|
|
62
|
+
# In it's own method so we can mock it out in tests
|
|
63
|
+
Open3.capture3(command, stdin_data: stdin_data)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def sleep_between_retries
|
|
67
|
+
# In its own method so we can mock it out in tests
|
|
68
|
+
sleep RETRY_DELAY_SECONDS
|
|
12
69
|
end
|
|
13
70
|
|
|
14
71
|
def call_url relative_url:
|
|
15
72
|
command = make_curl_command url: "#{@jira_url}#{relative_url}"
|
|
16
|
-
|
|
73
|
+
exec_and_parse_response command: command, stdin_data: nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def parse_response command:, result:
|
|
17
77
|
begin
|
|
18
78
|
json = JSON.parse(result)
|
|
19
79
|
rescue # rubocop:disable Style/RescueStandardError
|
|
20
|
-
|
|
80
|
+
message = "Unable to parse results from #{sanitize_message(command)}"
|
|
81
|
+
@file_system.error message, more: result
|
|
82
|
+
raise message
|
|
21
83
|
end
|
|
22
84
|
|
|
23
85
|
raise "Download failed with: #{JSON.pretty_generate(json)}" unless json_successful?(json)
|
|
@@ -25,18 +87,11 @@ class JiraGateway
|
|
|
25
87
|
json
|
|
26
88
|
end
|
|
27
89
|
|
|
28
|
-
def
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
@file_system.log log_entry
|
|
90
|
+
def sanitize_message message
|
|
91
|
+
token = @jira_api_token || @jira_personal_access_token
|
|
92
|
+
return message unless token # cookie based authentication
|
|
32
93
|
|
|
33
|
-
|
|
34
|
-
@file_system.log result unless $CHILD_STATUS.success?
|
|
35
|
-
return result if $CHILD_STATUS.success?
|
|
36
|
-
|
|
37
|
-
@file_system.log "Failed call with exit status #{$CHILD_STATUS.exitstatus}."
|
|
38
|
-
raise "Failed call with exit status #{$CHILD_STATUS.exitstatus}. " \
|
|
39
|
-
"See #{@file_system.logfile_name} for details"
|
|
94
|
+
message.gsub(token, '[API_TOKEN]')
|
|
40
95
|
end
|
|
41
96
|
|
|
42
97
|
def load_jira_config jira_config
|
|
@@ -56,7 +111,7 @@ class JiraGateway
|
|
|
56
111
|
@cookies = (jira_config['cookies'] || []).collect { |key, value| "#{key}=#{value}" }.join(';')
|
|
57
112
|
end
|
|
58
113
|
|
|
59
|
-
def make_curl_command url:
|
|
114
|
+
def make_curl_command url:, method: 'GET'
|
|
60
115
|
command = +''
|
|
61
116
|
command << 'curl'
|
|
62
117
|
command << ' -L' # follow redirects
|
|
@@ -65,8 +120,13 @@ class JiraGateway
|
|
|
65
120
|
command << " --cookie #{@cookies.inspect}" unless @cookies.empty?
|
|
66
121
|
command << " --user #{@jira_email}:#{@jira_api_token}" if @jira_api_token
|
|
67
122
|
command << " -H \"Authorization: Bearer #{@jira_personal_access_token}\"" if @jira_personal_access_token
|
|
68
|
-
command <<
|
|
123
|
+
command << " --request #{method}"
|
|
124
|
+
if method == 'POST'
|
|
125
|
+
command << ' --data @-'
|
|
126
|
+
command << ' --header "Content-Type: application/json"'
|
|
127
|
+
end
|
|
69
128
|
command << ' --header "Accept: application/json"'
|
|
129
|
+
command << ' --show-error --fail' # Better diagnostics when the server returns an error
|
|
70
130
|
command << " --url \"#{url}\""
|
|
71
131
|
command
|
|
72
132
|
end
|
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'mcp'
|
|
4
|
+
require 'mcp/server/transports/stdio_transport'
|
|
5
|
+
|
|
6
|
+
class McpServer
|
|
7
|
+
def initialize projects:, aggregates: {}, timezone_offset: '+00:00'
|
|
8
|
+
@projects = projects
|
|
9
|
+
@aggregates = aggregates
|
|
10
|
+
@timezone_offset = timezone_offset
|
|
11
|
+
end
|
|
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
|
+
|
|
23
|
+
server = MCP::Server.new(
|
|
24
|
+
name: 'jirametrics',
|
|
25
|
+
version: Gem.loaded_specs['jirametrics']&.version&.to_s || '0.0.0',
|
|
26
|
+
tools: canonical_tools + alias_tools,
|
|
27
|
+
server_context: { projects: @projects, aggregates: @aggregates, timezone_offset: @timezone_offset }
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
transport = MCP::Server::Transports::StdioTransport.new(server)
|
|
31
|
+
transport.open
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
HISTORY_FILTER_SCHEMA = {
|
|
35
|
+
history_field: {
|
|
36
|
+
type: 'string',
|
|
37
|
+
description: 'When combined with history_value, only return issues where this field ever had that value ' \
|
|
38
|
+
'(e.g. "priority", "status"). Both history_field and history_value must be provided together.'
|
|
39
|
+
},
|
|
40
|
+
history_value: {
|
|
41
|
+
type: 'string',
|
|
42
|
+
description: 'The value to look for in the change history of history_field (e.g. "Highest", "Done").'
|
|
43
|
+
},
|
|
44
|
+
ever_blocked: {
|
|
45
|
+
type: 'boolean',
|
|
46
|
+
description: 'When true, only return issues that were ever blocked. Blocked includes flagged items, ' \
|
|
47
|
+
'issues in blocked statuses, and blocking issue links.'
|
|
48
|
+
},
|
|
49
|
+
ever_stalled: {
|
|
50
|
+
type: 'boolean',
|
|
51
|
+
description: 'When true, only return issues that were ever stalled. Stalled means the issue sat ' \
|
|
52
|
+
'inactive for longer than the stalled threshold, or entered a stalled status.'
|
|
53
|
+
},
|
|
54
|
+
currently_blocked: {
|
|
55
|
+
type: 'boolean',
|
|
56
|
+
description: 'When true, only return issues that are currently blocked (as of the data end date).'
|
|
57
|
+
},
|
|
58
|
+
currently_stalled: {
|
|
59
|
+
type: 'boolean',
|
|
60
|
+
description: 'When true, only return issues that are currently stalled (as of the data end date).'
|
|
61
|
+
}
|
|
62
|
+
}.freeze
|
|
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
|
+
|
|
138
|
+
def self.flow_efficiency_percent issue, end_time
|
|
139
|
+
active_time, total_time = issue.flow_efficiency_numbers(end_time: end_time)
|
|
140
|
+
total_time.positive? ? (active_time / total_time * 100).round(1) : nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def self.matches_blocked_stalled?(bsc, ever_blocked, ever_stalled, currently_blocked, currently_stalled)
|
|
144
|
+
return false if ever_blocked && bsc.none?(&:blocked?)
|
|
145
|
+
return false if ever_stalled && bsc.none?(&:stalled?)
|
|
146
|
+
return false if currently_blocked && !bsc.last&.blocked?
|
|
147
|
+
return false if currently_stalled && !bsc.last&.stalled?
|
|
148
|
+
|
|
149
|
+
true
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def self.matches_history?(issue, end_time, history_field, history_value,
|
|
153
|
+
ever_blocked, ever_stalled, currently_blocked, currently_stalled)
|
|
154
|
+
return false if history_field && history_value &&
|
|
155
|
+
issue.changes.none? { |c| c.field == history_field && c.value == history_value }
|
|
156
|
+
|
|
157
|
+
if ever_blocked || ever_stalled || currently_blocked || currently_stalled
|
|
158
|
+
bsc = issue.blocked_stalled_changes(end_time: end_time)
|
|
159
|
+
return false unless matches_blocked_stalled?(bsc, ever_blocked, ever_stalled,
|
|
160
|
+
currently_blocked, currently_stalled)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
true
|
|
164
|
+
end
|
|
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
|
+
|
|
191
|
+
class AgingWorkTool < MCP::Tool
|
|
192
|
+
tool_name 'aging_work'
|
|
193
|
+
description 'Returns all issues that have been started but not yet completed (work in progress), ' \
|
|
194
|
+
'sorted from oldest to newest. Age is the number of days since the issue was started.'
|
|
195
|
+
|
|
196
|
+
input_schema(
|
|
197
|
+
type: 'object',
|
|
198
|
+
properties: {
|
|
199
|
+
min_age_days: {
|
|
200
|
+
type: 'integer',
|
|
201
|
+
description: 'Only return issues at least this many days old. Omit to return all ages.'
|
|
202
|
+
},
|
|
203
|
+
project: {
|
|
204
|
+
type: 'string',
|
|
205
|
+
description: 'Only return issues from this project name. Omit to return all projects.'
|
|
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
|
+
},
|
|
215
|
+
**HISTORY_FILTER_SCHEMA
|
|
216
|
+
}
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def self.call(server_context:, min_age_days: nil, project: nil, project_name: nil,
|
|
220
|
+
current_status: nil, current_column: nil,
|
|
221
|
+
history_field: nil, history_value: nil, ever_blocked: nil, ever_stalled: nil,
|
|
222
|
+
currently_blocked: nil, currently_stalled: nil, **)
|
|
223
|
+
project ||= project_name
|
|
224
|
+
rows = []
|
|
225
|
+
allowed_projects = McpServer.resolve_projects(server_context, project)
|
|
226
|
+
|
|
227
|
+
server_context[:projects].each do |project_name, project_data|
|
|
228
|
+
next if allowed_projects && !allowed_projects.include?(project_name)
|
|
229
|
+
|
|
230
|
+
today = project_data[:today]
|
|
231
|
+
project_data[:issues].each do |issue|
|
|
232
|
+
started, stopped = issue.started_stopped_times
|
|
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
|
|
236
|
+
|
|
237
|
+
age = (today - started.to_date).to_i + 1
|
|
238
|
+
next if min_age_days && age < min_age_days
|
|
239
|
+
unless McpServer.matches_history?(issue, project_data[:end_time],
|
|
240
|
+
history_field, history_value, ever_blocked, ever_stalled,
|
|
241
|
+
currently_blocked, currently_stalled)
|
|
242
|
+
next
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
rows << {
|
|
246
|
+
key: issue.key,
|
|
247
|
+
summary: issue.summary,
|
|
248
|
+
status: issue.status.name,
|
|
249
|
+
type: issue.type,
|
|
250
|
+
age_days: age,
|
|
251
|
+
flow_efficiency: McpServer.flow_efficiency_percent(issue, project_data[:end_time]),
|
|
252
|
+
project: project_name
|
|
253
|
+
}
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
rows.sort_by! { |r| -r[:age_days] }
|
|
258
|
+
|
|
259
|
+
if rows.empty?
|
|
260
|
+
text = 'No aging work found.'
|
|
261
|
+
else
|
|
262
|
+
lines = rows.map do |r|
|
|
263
|
+
fe = r[:flow_efficiency] ? " | FE: #{r[:flow_efficiency]}%" : ''
|
|
264
|
+
"#{r[:key]} | #{r[:project]} | #{r[:type]} | #{r[:status]} | Age: #{r[:age_days]}d#{fe} | #{r[:summary]}"
|
|
265
|
+
end
|
|
266
|
+
text = lines.join("\n")
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
MCP::Tool::Response.new([{ type: 'text', text: text }])
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
class CompletedWorkTool < MCP::Tool
|
|
274
|
+
tool_name 'completed_work'
|
|
275
|
+
description 'Returns issues that have been completed, sorted most recently completed first. ' \
|
|
276
|
+
'Includes cycle time (days from start to completion).'
|
|
277
|
+
|
|
278
|
+
input_schema(
|
|
279
|
+
type: 'object',
|
|
280
|
+
properties: {
|
|
281
|
+
days_back: {
|
|
282
|
+
type: 'integer',
|
|
283
|
+
description: 'Only return issues completed within this many days of the data end date. Omit to return all.'
|
|
284
|
+
},
|
|
285
|
+
project: {
|
|
286
|
+
type: 'string',
|
|
287
|
+
description: 'Only return issues from this project name. Omit to return all projects.'
|
|
288
|
+
},
|
|
289
|
+
completed_status: {
|
|
290
|
+
type: 'string',
|
|
291
|
+
description: 'Only return issues whose status at completion matches this value (e.g. "Cancelled", "Done").'
|
|
292
|
+
},
|
|
293
|
+
completed_resolution: {
|
|
294
|
+
type: 'string',
|
|
295
|
+
description: 'Only return issues whose resolution at completion matches this value (e.g. "Won\'t Do").'
|
|
296
|
+
},
|
|
297
|
+
**HISTORY_FILTER_SCHEMA
|
|
298
|
+
}
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
def self.build_row issue, project_name, started, stopped, cutoff, completed_status, completed_resolution,
|
|
302
|
+
end_time, history_field, history_value, ever_blocked, ever_stalled,
|
|
303
|
+
currently_blocked, currently_stalled
|
|
304
|
+
completed_date = stopped.to_date
|
|
305
|
+
return nil if cutoff && completed_date < cutoff
|
|
306
|
+
|
|
307
|
+
status_at_done, resolution_at_done = issue.status_resolution_at_done
|
|
308
|
+
return nil if completed_status && status_at_done&.name != completed_status
|
|
309
|
+
return nil if completed_resolution && completed_resolution != resolution_at_done
|
|
310
|
+
return nil unless McpServer.matches_history?(issue, end_time,
|
|
311
|
+
history_field, history_value, ever_blocked, ever_stalled,
|
|
312
|
+
currently_blocked, currently_stalled)
|
|
313
|
+
|
|
314
|
+
cycle_time = started ? (completed_date - started.to_date).to_i + 1 : nil
|
|
315
|
+
{
|
|
316
|
+
key: issue.key,
|
|
317
|
+
summary: issue.summary,
|
|
318
|
+
type: issue.type,
|
|
319
|
+
completed_date: completed_date,
|
|
320
|
+
cycle_time_days: cycle_time,
|
|
321
|
+
flow_efficiency: McpServer.flow_efficiency_percent(issue, stopped),
|
|
322
|
+
status_at_done: status_at_done&.name,
|
|
323
|
+
resolution_at_done: resolution_at_done,
|
|
324
|
+
project: project_name
|
|
325
|
+
}
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def self.call(server_context:, days_back: nil, project: nil, project_name: nil,
|
|
329
|
+
completed_status: nil, completed_resolution: nil,
|
|
330
|
+
history_field: nil, history_value: nil, ever_blocked: nil, ever_stalled: nil,
|
|
331
|
+
currently_blocked: nil, currently_stalled: nil, **)
|
|
332
|
+
project ||= project_name
|
|
333
|
+
rows = []
|
|
334
|
+
allowed_projects = McpServer.resolve_projects(server_context, project)
|
|
335
|
+
|
|
336
|
+
server_context[:projects].each do |project_name, project_data|
|
|
337
|
+
next if allowed_projects && !allowed_projects.include?(project_name)
|
|
338
|
+
|
|
339
|
+
today = project_data[:today]
|
|
340
|
+
cutoff = today - days_back if days_back
|
|
341
|
+
|
|
342
|
+
project_data[:issues].each do |issue|
|
|
343
|
+
started, stopped = issue.started_stopped_times
|
|
344
|
+
next unless stopped
|
|
345
|
+
|
|
346
|
+
row = build_row(issue, project_name, started, stopped, cutoff, completed_status, completed_resolution,
|
|
347
|
+
project_data[:end_time], history_field, history_value, ever_blocked, ever_stalled,
|
|
348
|
+
currently_blocked, currently_stalled)
|
|
349
|
+
rows << row if row
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
rows.sort_by! { |r| -r[:completed_date].to_time.to_i }
|
|
354
|
+
|
|
355
|
+
if rows.empty?
|
|
356
|
+
text = 'No completed work found.'
|
|
357
|
+
else
|
|
358
|
+
lines = rows.map do |r|
|
|
359
|
+
ct = r[:cycle_time_days] ? "#{r[:cycle_time_days]}d" : 'unknown'
|
|
360
|
+
fe = r[:flow_efficiency] ? " | FE: #{r[:flow_efficiency]}%" : ''
|
|
361
|
+
completion = [r[:status_at_done], r[:resolution_at_done]].compact.join(' / ')
|
|
362
|
+
"#{r[:key]} | #{r[:project]} | #{r[:type]} | #{r[:completed_date]} | " \
|
|
363
|
+
"Cycle time: #{ct}#{fe} | #{completion} | #{r[:summary]}"
|
|
364
|
+
end
|
|
365
|
+
text = lines.join("\n")
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
MCP::Tool::Response.new([{ type: 'text', text: text }])
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
class NotYetStartedTool < MCP::Tool
|
|
373
|
+
tool_name 'not_yet_started'
|
|
374
|
+
description 'Returns issues that have not yet been started (backlog items), sorted by creation date oldest first.'
|
|
375
|
+
|
|
376
|
+
input_schema(
|
|
377
|
+
type: 'object',
|
|
378
|
+
properties: {
|
|
379
|
+
project: {
|
|
380
|
+
type: 'string',
|
|
381
|
+
description: 'Only return issues from this project name. Omit to return all projects.'
|
|
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
|
+
},
|
|
391
|
+
**HISTORY_FILTER_SCHEMA
|
|
392
|
+
}
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
def self.call(server_context:, project: nil, project_name: nil, current_status: nil, current_column: nil,
|
|
396
|
+
history_field: nil, history_value: nil, ever_blocked: nil, ever_stalled: nil,
|
|
397
|
+
currently_blocked: nil, currently_stalled: nil, **)
|
|
398
|
+
project ||= project_name
|
|
399
|
+
rows = []
|
|
400
|
+
allowed_projects = McpServer.resolve_projects(server_context, project)
|
|
401
|
+
|
|
402
|
+
server_context[:projects].each do |project_name, project_data|
|
|
403
|
+
next if allowed_projects && !allowed_projects.include?(project_name)
|
|
404
|
+
|
|
405
|
+
project_data[:issues].each do |issue|
|
|
406
|
+
started, stopped = issue.started_stopped_times
|
|
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
|
|
410
|
+
unless McpServer.matches_history?(issue, project_data[:end_time],
|
|
411
|
+
history_field, history_value, ever_blocked, ever_stalled,
|
|
412
|
+
currently_blocked, currently_stalled)
|
|
413
|
+
next
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
rows << {
|
|
417
|
+
key: issue.key,
|
|
418
|
+
summary: issue.summary,
|
|
419
|
+
status: issue.status.name,
|
|
420
|
+
type: issue.type,
|
|
421
|
+
created: issue.created.to_date,
|
|
422
|
+
project: project_name
|
|
423
|
+
}
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
rows.sort_by! { |r| r[:created] }
|
|
428
|
+
|
|
429
|
+
if rows.empty?
|
|
430
|
+
text = 'No unstarted work found.'
|
|
431
|
+
else
|
|
432
|
+
lines = rows.map do |r|
|
|
433
|
+
"#{r[:key]} | #{r[:project]} | #{r[:type]} | #{r[:status]} | Created: #{r[:created]} | #{r[:summary]}"
|
|
434
|
+
end
|
|
435
|
+
text = lines.join("\n")
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
MCP::Tool::Response.new([{ type: 'text', text: text }])
|
|
439
|
+
end
|
|
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
|
|
531
|
+
end
|