jirametrics 2.26.1 → 2.27
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/lib/jirametrics/chart_base.rb +1 -1
- data/lib/jirametrics/cumulative_flow_diagram.rb +3 -0
- data/lib/jirametrics/examples/aggregated_project.rb +1 -1
- data/lib/jirametrics/examples/standard_project.rb +2 -2
- data/lib/jirametrics/file_system.rb +10 -3
- data/lib/jirametrics/github_gateway.rb +13 -4
- data/lib/jirametrics/html/time_based_scatterplot.erb +8 -3
- data/lib/jirametrics/mcp_server.rb +305 -0
- data/lib/jirametrics/pull_request_cycle_time_histogram.rb +2 -2
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +13 -5
- data/lib/jirametrics/stitcher.rb +5 -0
- data/lib/jirametrics.rb +28 -0
- metadata +30 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c87212eebf4e22145b8e66741174658bffc3cb1e47bd45bfe3f7590586a42200
|
|
4
|
+
data.tar.gz: 00fb6c4375e7e6858359fa754af42c697028198dedd2ae8100c98d0564f59db8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: aad3747a78dcf530df02244720c676632816a30688045d95c9a701ba946a5573f81a2e3955c5035e745fb197f5c6cff58491a63d35db68b3abf35817c78dc0b8
|
|
7
|
+
data.tar.gz: fcf17a8c2a4c91e4de45ff15150d1de9147c890d9e80354b490bec2b27fb17ef2b20e7bc6073c503e88fd0ad188c414002d8312500b22dd5207b043d3c253be7
|
|
@@ -377,7 +377,7 @@ class ChartBase
|
|
|
377
377
|
end
|
|
378
378
|
|
|
379
379
|
def seam_start type = 'chart'
|
|
380
|
-
"\n<!-- seam-start | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type}
|
|
380
|
+
"\n<!-- seam-start | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type} -->\n"
|
|
381
381
|
end
|
|
382
382
|
|
|
383
383
|
def seam_end type = 'chart'
|
|
@@ -69,6 +69,9 @@ class CumulativeFlowDiagram < ChartBase
|
|
|
69
69
|
When the cursor is near the right edge and that point falls outside the visible date range,
|
|
70
70
|
CT and TP cannot be calculated and are hidden; only WIP is shown.
|
|
71
71
|
</div>
|
|
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/).
|
|
74
|
+
</div>
|
|
72
75
|
HTML
|
|
73
76
|
instance_eval(&block)
|
|
74
77
|
end
|
|
@@ -9,7 +9,7 @@ class Exporter
|
|
|
9
9
|
show_experimental_charts: false, github_repos: nil
|
|
10
10
|
exporter = self
|
|
11
11
|
project name: name do
|
|
12
|
-
|
|
12
|
+
file_system.log name
|
|
13
13
|
file_prefix file_prefix
|
|
14
14
|
|
|
15
15
|
self.anonymize if anonymize
|
|
@@ -35,7 +35,7 @@ class Exporter
|
|
|
35
35
|
download do
|
|
36
36
|
self.rolling_date_count(rolling_date_count) if rolling_date_count
|
|
37
37
|
self.no_earlier_than(no_earlier_than) if no_earlier_than
|
|
38
|
-
github_repo github_repos if github_repos
|
|
38
|
+
github_repo *github_repos if github_repos
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
issues.reject! do |issue|
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
|
|
5
5
|
class FileSystem
|
|
6
|
-
attr_accessor :logfile, :logfile_name
|
|
6
|
+
attr_accessor :logfile, :logfile_name, :log_only
|
|
7
7
|
|
|
8
8
|
def initialize
|
|
9
9
|
# In almost all cases, this will be immediately replaced in the Exporter
|
|
10
10
|
# but if we fail before we get that far, this will at least let a useful
|
|
11
11
|
# error show up on the console.
|
|
12
12
|
@logfile = $stdout
|
|
13
|
+
@log_only = false
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
# Effectively the same as File.read except it forces the encoding to UTF-8
|
|
@@ -59,7 +60,7 @@ class FileSystem
|
|
|
59
60
|
|
|
60
61
|
logfile.puts message
|
|
61
62
|
logfile.puts more if more
|
|
62
|
-
return
|
|
63
|
+
return if log_only || !also_write_to_stderr
|
|
63
64
|
|
|
64
65
|
# Obscure edge-case where we're trying to log something before logging is even
|
|
65
66
|
# set up. Quick escape here so that we don't dump the error twice.
|
|
@@ -70,23 +71,29 @@ class FileSystem
|
|
|
70
71
|
|
|
71
72
|
def log_start message
|
|
72
73
|
logfile.puts message
|
|
73
|
-
return if logfile == $stdout
|
|
74
|
+
return if log_only || logfile == $stdout
|
|
74
75
|
|
|
75
76
|
$stderr.print message
|
|
76
77
|
$stderr.flush
|
|
77
78
|
end
|
|
78
79
|
|
|
79
80
|
def start_progress
|
|
81
|
+
return if log_only
|
|
82
|
+
|
|
80
83
|
$stderr.print ' '
|
|
81
84
|
$stderr.flush
|
|
82
85
|
end
|
|
83
86
|
|
|
84
87
|
def progress_dot
|
|
88
|
+
return if log_only
|
|
89
|
+
|
|
85
90
|
$stderr.print '.'
|
|
86
91
|
$stderr.flush
|
|
87
92
|
end
|
|
88
93
|
|
|
89
94
|
def end_progress
|
|
95
|
+
return if log_only
|
|
96
|
+
|
|
90
97
|
$stderr.puts '' # rubocop:disable Style/StderrPuts
|
|
91
98
|
end
|
|
92
99
|
|
|
@@ -64,9 +64,10 @@ class GithubGateway
|
|
|
64
64
|
raw_pr['body']
|
|
65
65
|
]
|
|
66
66
|
|
|
67
|
-
sources.compact
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
keys = sources.compact.flat_map { |s| s.scan(@issue_key_pattern) }.uniq
|
|
68
|
+
return keys unless keys.empty?
|
|
69
|
+
|
|
70
|
+
commit_messages_for(raw_pr['number']).flat_map { |msg| msg.scan(@issue_key_pattern) }.uniq
|
|
70
71
|
end
|
|
71
72
|
|
|
72
73
|
def extract_reviews raw_reviews
|
|
@@ -83,11 +84,19 @@ class GithubGateway
|
|
|
83
84
|
|
|
84
85
|
private
|
|
85
86
|
|
|
87
|
+
def commit_messages_for pr_number
|
|
88
|
+
args = ['pr', 'view', pr_number.to_s, '--json', 'commits', '--repo', @repo]
|
|
89
|
+
result = run_command(args)
|
|
90
|
+
(result['commits'] || []).flat_map do |commit|
|
|
91
|
+
[commit['messageHeadline'], commit['messageBody']].compact
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
86
95
|
def build_issue_key_pattern
|
|
87
96
|
return nil if @project_keys.empty?
|
|
88
97
|
|
|
89
98
|
keys_pattern = @project_keys.map { |k| Regexp.escape(k) }.join('|')
|
|
90
|
-
Regexp.new("\\b(?:#{keys_pattern})-\\d
|
|
99
|
+
Regexp.new("\\b(?:#{keys_pattern})-\\d+(?![A-Za-z0-9])")
|
|
91
100
|
end
|
|
92
101
|
|
|
93
102
|
def run_command args
|
|
@@ -28,15 +28,20 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
|
28
28
|
max: "<%= (date_range.end + 1).to_s %>"
|
|
29
29
|
},
|
|
30
30
|
y: {
|
|
31
|
+
min: 0,
|
|
32
|
+
max: <%= (@highest_y_value * 1.1).ceil %>,
|
|
31
33
|
scaleLabel: {
|
|
32
|
-
display: true
|
|
33
|
-
min: 0,
|
|
34
|
-
max: <%= @highest_y_value %>
|
|
34
|
+
display: true
|
|
35
35
|
},
|
|
36
36
|
<%= render_axis_title :y %>
|
|
37
37
|
grid: {
|
|
38
38
|
color: <%= CssVariable['--grid-line-color'].to_json %>
|
|
39
39
|
},
|
|
40
|
+
ticks: {
|
|
41
|
+
callback: function(value, index, ticks) {
|
|
42
|
+
return index === ticks.length - 1 ? null : value;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
40
45
|
}
|
|
41
46
|
},
|
|
42
47
|
plugins: {
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'mcp'
|
|
4
|
+
require 'mcp/server/transports/stdio_transport'
|
|
5
|
+
|
|
6
|
+
class McpServer
|
|
7
|
+
def initialize projects:, timezone_offset: '+00:00'
|
|
8
|
+
@projects = projects
|
|
9
|
+
@timezone_offset = timezone_offset
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def run
|
|
13
|
+
server = MCP::Server.new(
|
|
14
|
+
name: 'jirametrics',
|
|
15
|
+
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 }
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
transport = MCP::Server::Transports::StdioTransport.new(server)
|
|
21
|
+
transport.open
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
HISTORY_FILTER_SCHEMA = {
|
|
25
|
+
history_field: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description: 'When combined with history_value, only return issues where this field ever had that value ' \
|
|
28
|
+
'(e.g. "priority", "status"). Both history_field and history_value must be provided together.'
|
|
29
|
+
},
|
|
30
|
+
history_value: {
|
|
31
|
+
type: 'string',
|
|
32
|
+
description: 'The value to look for in the change history of history_field (e.g. "Highest", "Done").'
|
|
33
|
+
},
|
|
34
|
+
ever_blocked: {
|
|
35
|
+
type: 'boolean',
|
|
36
|
+
description: 'When true, only return issues that were ever blocked. Blocked includes flagged items, ' \
|
|
37
|
+
'issues in blocked statuses, and blocking issue links.'
|
|
38
|
+
},
|
|
39
|
+
ever_stalled: {
|
|
40
|
+
type: 'boolean',
|
|
41
|
+
description: 'When true, only return issues that were ever stalled. Stalled means the issue sat ' \
|
|
42
|
+
'inactive for longer than the stalled threshold, or entered a stalled status.'
|
|
43
|
+
},
|
|
44
|
+
currently_blocked: {
|
|
45
|
+
type: 'boolean',
|
|
46
|
+
description: 'When true, only return issues that are currently blocked (as of the data end date).'
|
|
47
|
+
},
|
|
48
|
+
currently_stalled: {
|
|
49
|
+
type: 'boolean',
|
|
50
|
+
description: 'When true, only return issues that are currently stalled (as of the data end date).'
|
|
51
|
+
}
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
def self.flow_efficiency_percent issue, end_time
|
|
55
|
+
active_time, total_time = issue.flow_efficiency_numbers(end_time: end_time)
|
|
56
|
+
total_time.positive? ? (active_time / total_time * 100).round(1) : nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.matches_blocked_stalled?(bsc, ever_blocked, ever_stalled, currently_blocked, currently_stalled)
|
|
60
|
+
return false if ever_blocked && bsc.none?(&:blocked?)
|
|
61
|
+
return false if ever_stalled && bsc.none?(&:stalled?)
|
|
62
|
+
return false if currently_blocked && !bsc.last&.blocked?
|
|
63
|
+
return false if currently_stalled && !bsc.last&.stalled?
|
|
64
|
+
|
|
65
|
+
true
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.matches_history?(issue, end_time, history_field, history_value,
|
|
69
|
+
ever_blocked, ever_stalled, currently_blocked, currently_stalled)
|
|
70
|
+
return false if history_field && history_value &&
|
|
71
|
+
issue.changes.none? { |c| c.field == history_field && c.value == history_value }
|
|
72
|
+
|
|
73
|
+
if ever_blocked || ever_stalled || currently_blocked || currently_stalled
|
|
74
|
+
bsc = issue.blocked_stalled_changes(end_time: end_time)
|
|
75
|
+
return false unless matches_blocked_stalled?(bsc, ever_blocked, ever_stalled,
|
|
76
|
+
currently_blocked, currently_stalled)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
true
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
class AgingWorkTool < MCP::Tool
|
|
83
|
+
tool_name 'aging_work'
|
|
84
|
+
description 'Returns all issues that have been started but not yet completed (work in progress), ' \
|
|
85
|
+
'sorted from oldest to newest. Age is the number of days since the issue was started.'
|
|
86
|
+
|
|
87
|
+
input_schema(
|
|
88
|
+
type: 'object',
|
|
89
|
+
properties: {
|
|
90
|
+
min_age_days: {
|
|
91
|
+
type: 'integer',
|
|
92
|
+
description: 'Only return issues at least this many days old. Omit to return all ages.'
|
|
93
|
+
},
|
|
94
|
+
project: {
|
|
95
|
+
type: 'string',
|
|
96
|
+
description: 'Only return issues from this project name. Omit to return all projects.'
|
|
97
|
+
},
|
|
98
|
+
**HISTORY_FILTER_SCHEMA
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def self.call(server_context:, min_age_days: nil, project: nil,
|
|
103
|
+
history_field: nil, history_value: nil, ever_blocked: nil, ever_stalled: nil,
|
|
104
|
+
currently_blocked: nil, currently_stalled: nil)
|
|
105
|
+
rows = []
|
|
106
|
+
|
|
107
|
+
server_context[:projects].each do |project_name, project_data|
|
|
108
|
+
next if project && project_name != project
|
|
109
|
+
|
|
110
|
+
today = project_data[:today]
|
|
111
|
+
project_data[:issues].each do |issue|
|
|
112
|
+
started, stopped = issue.started_stopped_times
|
|
113
|
+
next unless started && !stopped
|
|
114
|
+
|
|
115
|
+
age = (today - started.to_date).to_i + 1
|
|
116
|
+
next if min_age_days && age < min_age_days
|
|
117
|
+
unless McpServer.matches_history?(issue, project_data[:end_time],
|
|
118
|
+
history_field, history_value, ever_blocked, ever_stalled,
|
|
119
|
+
currently_blocked, currently_stalled)
|
|
120
|
+
next
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
rows << {
|
|
124
|
+
key: issue.key,
|
|
125
|
+
summary: issue.summary,
|
|
126
|
+
status: issue.status.name,
|
|
127
|
+
type: issue.type,
|
|
128
|
+
age_days: age,
|
|
129
|
+
flow_efficiency: McpServer.flow_efficiency_percent(issue, project_data[:end_time]),
|
|
130
|
+
project: project_name
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
rows.sort_by! { |r| -r[:age_days] }
|
|
136
|
+
|
|
137
|
+
if rows.empty?
|
|
138
|
+
text = 'No aging work found.'
|
|
139
|
+
else
|
|
140
|
+
lines = rows.map do |r|
|
|
141
|
+
fe = r[:flow_efficiency] ? " | FE: #{r[:flow_efficiency]}%" : ''
|
|
142
|
+
"#{r[:key]} | #{r[:project]} | #{r[:type]} | #{r[:status]} | Age: #{r[:age_days]}d#{fe} | #{r[:summary]}"
|
|
143
|
+
end
|
|
144
|
+
text = lines.join("\n")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
MCP::Tool::Response.new([{ type: 'text', text: text }])
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
class CompletedWorkTool < MCP::Tool
|
|
152
|
+
tool_name 'completed_work'
|
|
153
|
+
description 'Returns issues that have been completed, sorted most recently completed first. ' \
|
|
154
|
+
'Includes cycle time (days from start to completion).'
|
|
155
|
+
|
|
156
|
+
input_schema(
|
|
157
|
+
type: 'object',
|
|
158
|
+
properties: {
|
|
159
|
+
days_back: {
|
|
160
|
+
type: 'integer',
|
|
161
|
+
description: 'Only return issues completed within this many days of the data end date. Omit to return all.'
|
|
162
|
+
},
|
|
163
|
+
project: {
|
|
164
|
+
type: 'string',
|
|
165
|
+
description: 'Only return issues from this project name. Omit to return all projects.'
|
|
166
|
+
},
|
|
167
|
+
completed_status: {
|
|
168
|
+
type: 'string',
|
|
169
|
+
description: 'Only return issues whose status at completion matches this value (e.g. "Cancelled", "Done").'
|
|
170
|
+
},
|
|
171
|
+
completed_resolution: {
|
|
172
|
+
type: 'string',
|
|
173
|
+
description: 'Only return issues whose resolution at completion matches this value (e.g. "Won\'t Do").'
|
|
174
|
+
},
|
|
175
|
+
**HISTORY_FILTER_SCHEMA
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def self.build_row issue, project_name, started, stopped, cutoff, completed_status, completed_resolution,
|
|
180
|
+
end_time, history_field, history_value, ever_blocked, ever_stalled,
|
|
181
|
+
currently_blocked, currently_stalled
|
|
182
|
+
completed_date = stopped.to_date
|
|
183
|
+
return nil if cutoff && completed_date < cutoff
|
|
184
|
+
|
|
185
|
+
status_at_done, resolution_at_done = issue.status_resolution_at_done
|
|
186
|
+
return nil if completed_status && status_at_done&.name != completed_status
|
|
187
|
+
return nil if completed_resolution && completed_resolution != resolution_at_done
|
|
188
|
+
return nil unless McpServer.matches_history?(issue, end_time,
|
|
189
|
+
history_field, history_value, ever_blocked, ever_stalled,
|
|
190
|
+
currently_blocked, currently_stalled)
|
|
191
|
+
|
|
192
|
+
cycle_time = started ? (completed_date - started.to_date).to_i + 1 : nil
|
|
193
|
+
{
|
|
194
|
+
key: issue.key,
|
|
195
|
+
summary: issue.summary,
|
|
196
|
+
type: issue.type,
|
|
197
|
+
completed_date: completed_date,
|
|
198
|
+
cycle_time_days: cycle_time,
|
|
199
|
+
flow_efficiency: McpServer.flow_efficiency_percent(issue, stopped),
|
|
200
|
+
status_at_done: status_at_done&.name,
|
|
201
|
+
resolution_at_done: resolution_at_done,
|
|
202
|
+
project: project_name
|
|
203
|
+
}
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def self.call(server_context:, days_back: nil, project: nil,
|
|
207
|
+
completed_status: nil, completed_resolution: nil,
|
|
208
|
+
history_field: nil, history_value: nil, ever_blocked: nil, ever_stalled: nil,
|
|
209
|
+
currently_blocked: nil, currently_stalled: nil)
|
|
210
|
+
rows = []
|
|
211
|
+
|
|
212
|
+
server_context[:projects].each do |project_name, project_data|
|
|
213
|
+
next if project && project_name != project
|
|
214
|
+
|
|
215
|
+
today = project_data[:today]
|
|
216
|
+
cutoff = today - days_back if days_back
|
|
217
|
+
|
|
218
|
+
project_data[:issues].each do |issue|
|
|
219
|
+
started, stopped = issue.started_stopped_times
|
|
220
|
+
next unless stopped
|
|
221
|
+
|
|
222
|
+
row = build_row(issue, project_name, started, stopped, cutoff, completed_status, completed_resolution,
|
|
223
|
+
project_data[:end_time], history_field, history_value, ever_blocked, ever_stalled,
|
|
224
|
+
currently_blocked, currently_stalled)
|
|
225
|
+
rows << row if row
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
rows.sort_by! { |r| -r[:completed_date].to_time.to_i }
|
|
230
|
+
|
|
231
|
+
if rows.empty?
|
|
232
|
+
text = 'No completed work found.'
|
|
233
|
+
else
|
|
234
|
+
lines = rows.map do |r|
|
|
235
|
+
ct = r[:cycle_time_days] ? "#{r[:cycle_time_days]}d" : 'unknown'
|
|
236
|
+
fe = r[:flow_efficiency] ? " | FE: #{r[:flow_efficiency]}%" : ''
|
|
237
|
+
completion = [r[:status_at_done], r[:resolution_at_done]].compact.join(' / ')
|
|
238
|
+
"#{r[:key]} | #{r[:project]} | #{r[:type]} | #{r[:completed_date]} | " \
|
|
239
|
+
"Cycle time: #{ct}#{fe} | #{completion} | #{r[:summary]}"
|
|
240
|
+
end
|
|
241
|
+
text = lines.join("\n")
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
MCP::Tool::Response.new([{ type: 'text', text: text }])
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
class NotYetStartedTool < MCP::Tool
|
|
249
|
+
tool_name 'not_yet_started'
|
|
250
|
+
description 'Returns issues that have not yet been started (backlog items), sorted by creation date oldest first.'
|
|
251
|
+
|
|
252
|
+
input_schema(
|
|
253
|
+
type: 'object',
|
|
254
|
+
properties: {
|
|
255
|
+
project: {
|
|
256
|
+
type: 'string',
|
|
257
|
+
description: 'Only return issues from this project name. Omit to return all projects.'
|
|
258
|
+
},
|
|
259
|
+
**HISTORY_FILTER_SCHEMA
|
|
260
|
+
}
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
def self.call(server_context:, project: nil,
|
|
264
|
+
history_field: nil, history_value: nil, ever_blocked: nil, ever_stalled: nil,
|
|
265
|
+
currently_blocked: nil, currently_stalled: nil)
|
|
266
|
+
rows = []
|
|
267
|
+
|
|
268
|
+
server_context[:projects].each do |project_name, project_data|
|
|
269
|
+
next if project && project_name != project
|
|
270
|
+
|
|
271
|
+
project_data[:issues].each do |issue|
|
|
272
|
+
started, stopped = issue.started_stopped_times
|
|
273
|
+
next if started || stopped
|
|
274
|
+
unless McpServer.matches_history?(issue, project_data[:end_time],
|
|
275
|
+
history_field, history_value, ever_blocked, ever_stalled,
|
|
276
|
+
currently_blocked, currently_stalled)
|
|
277
|
+
next
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
rows << {
|
|
281
|
+
key: issue.key,
|
|
282
|
+
summary: issue.summary,
|
|
283
|
+
status: issue.status.name,
|
|
284
|
+
type: issue.type,
|
|
285
|
+
created: issue.created.to_date,
|
|
286
|
+
project: project_name
|
|
287
|
+
}
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
rows.sort_by! { |r| r[:created] }
|
|
292
|
+
|
|
293
|
+
if rows.empty?
|
|
294
|
+
text = 'No unstarted work found.'
|
|
295
|
+
else
|
|
296
|
+
lines = rows.map do |r|
|
|
297
|
+
"#{r[:key]} | #{r[:project]} | #{r[:type]} | #{r[:status]} | Created: #{r[:created]} | #{r[:summary]}"
|
|
298
|
+
end
|
|
299
|
+
text = lines.join("\n")
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
MCP::Tool::Response.new([{ type: 'text', text: text }])
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
@@ -19,8 +19,8 @@ class PullRequestCycleTimeHistogram < TimeBasedHistogram
|
|
|
19
19
|
HTML
|
|
20
20
|
|
|
21
21
|
init_configuration_block(block) do
|
|
22
|
-
grouping_rules do |pull_request,
|
|
23
|
-
|
|
22
|
+
grouping_rules do |pull_request, rule|
|
|
23
|
+
rule.label = pull_request.repo
|
|
24
24
|
end
|
|
25
25
|
end
|
|
26
26
|
end
|
|
@@ -18,8 +18,8 @@ class PullRequestCycleTimeScatterplot < TimeBasedScatterplot
|
|
|
18
18
|
HTML
|
|
19
19
|
|
|
20
20
|
init_configuration_block(block) do
|
|
21
|
-
grouping_rules do |pull_request,
|
|
22
|
-
|
|
21
|
+
grouping_rules do |pull_request, rule|
|
|
22
|
+
rule.label = pull_request.repo
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
end
|
|
@@ -48,8 +48,15 @@ class PullRequestCycleTimeScatterplot < TimeBasedScatterplot
|
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def y_value pull_request
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
if @cycletime_unit == :days
|
|
52
|
+
tz = timezone_offset || '+00:00'
|
|
53
|
+
opened = pull_request.opened_at.getlocal(tz).to_date
|
|
54
|
+
closed = pull_request.closed_at.getlocal(tz).to_date
|
|
55
|
+
(closed - opened).to_i + 1
|
|
56
|
+
else
|
|
57
|
+
divisor = { minutes: 60, hours: 3600 }[@cycletime_unit]
|
|
58
|
+
((pull_request.closed_at - pull_request.opened_at) / divisor).round
|
|
59
|
+
end
|
|
53
60
|
end
|
|
54
61
|
|
|
55
62
|
def label_cycletime value
|
|
@@ -62,7 +69,8 @@ class PullRequestCycleTimeScatterplot < TimeBasedScatterplot
|
|
|
62
69
|
|
|
63
70
|
def title_value pull_request, rules: nil
|
|
64
71
|
age_label = label_cycletime y_value(pull_request)
|
|
65
|
-
|
|
72
|
+
keys = pull_request.issue_keys.join(', ')
|
|
73
|
+
"#{keys} | #{pull_request.title} | #{rules.label} | Age:#{age_label}#{lines_changed_text(pull_request)}"
|
|
66
74
|
end
|
|
67
75
|
|
|
68
76
|
def lines_changed_text pull_request
|
data/lib/jirametrics/stitcher.rb
CHANGED
|
@@ -60,6 +60,11 @@ class Stitcher < HtmlGenerator
|
|
|
60
60
|
if matches[:seam] == 'start'
|
|
61
61
|
content = +''
|
|
62
62
|
else
|
|
63
|
+
if content.nil? || content.strip.empty?
|
|
64
|
+
file_system.warning "Seam found with no content in #{filename.inspect}: " \
|
|
65
|
+
"id=#{matches[:id].strip.inspect}, class=#{matches[:clazz].strip.inspect}, " \
|
|
66
|
+
"title=#{matches[:title].strip.inspect}"
|
|
67
|
+
end
|
|
63
68
|
@all_stitches << Stitcher::StitchContent.new(
|
|
64
69
|
file: filename, title: matches[:title], type: matches[:type], content: content
|
|
65
70
|
)
|
data/lib/jirametrics.rb
CHANGED
|
@@ -52,6 +52,34 @@ class JiraMetrics < Thor
|
|
|
52
52
|
Exporter.instance.info(key, name_filter: options[:name] || '*')
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
option :config
|
|
56
|
+
option :name
|
|
57
|
+
desc 'mcp', 'Start in MCP (Model Context Protocol) server mode'
|
|
58
|
+
def mcp
|
|
59
|
+
load_config options[:config]
|
|
60
|
+
require 'jirametrics/mcp_server'
|
|
61
|
+
|
|
62
|
+
Exporter.instance.file_system.log_only = true
|
|
63
|
+
|
|
64
|
+
projects = {}
|
|
65
|
+
Exporter.instance.each_project_config(name_filter: options[:name] || '*') do |project|
|
|
66
|
+
project.evaluate_next_level
|
|
67
|
+
project.run load_only: true
|
|
68
|
+
projects[project.name || 'default'] = {
|
|
69
|
+
issues: project.issues,
|
|
70
|
+
today: project.time_range.end.to_date,
|
|
71
|
+
end_time: project.time_range.end
|
|
72
|
+
}
|
|
73
|
+
rescue StandardError => e
|
|
74
|
+
next if e.message.start_with? 'This is an aggregated project'
|
|
75
|
+
next if e.message.start_with? 'No data found'
|
|
76
|
+
|
|
77
|
+
raise
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
McpServer.new(projects: projects, timezone_offset: Exporter.instance.timezone_offset).run
|
|
81
|
+
end
|
|
82
|
+
|
|
55
83
|
option :config
|
|
56
84
|
desc 'stitch', 'Dump information about one issue'
|
|
57
85
|
def stitch stitch_file = 'stitcher.erb'
|
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.27'
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mike Bowler
|
|
@@ -9,6 +9,34 @@ bindir: bin
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: mutant-rspec
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: mcp
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
12
40
|
- !ruby/object:Gem::Dependency
|
|
13
41
|
name: random-word
|
|
14
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -131,6 +159,7 @@ files:
|
|
|
131
159
|
- lib/jirametrics/issue_link.rb
|
|
132
160
|
- lib/jirametrics/issue_printer.rb
|
|
133
161
|
- lib/jirametrics/jira_gateway.rb
|
|
162
|
+
- lib/jirametrics/mcp_server.rb
|
|
134
163
|
- lib/jirametrics/project_config.rb
|
|
135
164
|
- lib/jirametrics/pull_request.rb
|
|
136
165
|
- lib/jirametrics/pull_request_cycle_time_histogram.rb
|