jirametrics 2.22 → 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/aggregate_config.rb +10 -2
- data/lib/jirametrics/aging_work_bar_chart.rb +20 -6
- data/lib/jirametrics/aging_work_table.rb +4 -5
- data/lib/jirametrics/anonymizer.rb +74 -1
- data/lib/jirametrics/atlassian_document_format.rb +93 -93
- data/lib/jirametrics/blocked_stalled_change.rb +5 -3
- data/lib/jirametrics/board.rb +20 -8
- 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 +4 -3
- data/lib/jirametrics/chart_base.rb +94 -2
- 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} +1 -2
- data/lib/jirametrics/cycletime_histogram.rb +15 -103
- data/lib/jirametrics/cycletime_scatterplot.rb +13 -98
- data/lib/jirametrics/daily_view.rb +36 -12
- data/lib/jirametrics/daily_wip_by_age_chart.rb +1 -1
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +1 -1
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
- data/lib/jirametrics/daily_wip_chart.rb +29 -7
- data/lib/jirametrics/data_quality_report.rb +38 -12
- data/lib/jirametrics/dependency_chart.rb +2 -2
- data/lib/jirametrics/download_config.rb +15 -0
- data/lib/jirametrics/downloader.rb +87 -5
- data/lib/jirametrics/downloader_for_cloud.rb +52 -10
- data/lib/jirametrics/downloader_for_data_center.rb +2 -1
- 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 +29 -19
- data/lib/jirametrics/expedited_chart.rb +3 -1
- data/lib/jirametrics/exporter.rb +3 -1
- data/lib/jirametrics/file_system.rb +35 -2
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
- data/lib/jirametrics/github_gateway.rb +115 -0
- data/lib/jirametrics/groupable_issue_chart.rb +4 -0
- data/lib/jirametrics/grouping_rules.rb +26 -4
- data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -4
- data/lib/jirametrics/html/aging_work_table.erb +3 -0
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +38 -5
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +2 -12
- data/lib/jirametrics/html/expedited_chart.erb +3 -13
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +2 -8
- data/lib/jirametrics/html/index.css +117 -0
- data/lib/jirametrics/html/index.erb +6 -0
- data/lib/jirametrics/html/index.js +52 -2
- data/lib/jirametrics/html/sprint_burndown.erb +7 -13
- data/lib/jirametrics/html/throughput_chart.erb +40 -9
- data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +59 -59
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +11 -7
- data/lib/jirametrics/html_generator.rb +2 -1
- data/lib/jirametrics/html_report_config.rb +23 -16
- data/lib/jirametrics/issue.rb +101 -96
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +6 -3
- data/lib/jirametrics/mcp_server.rb +305 -0
- data/lib/jirametrics/project_config.rb +80 -7
- 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 +4 -0
- data/lib/jirametrics/settings.json +3 -1
- data/lib/jirametrics/sprint_burndown.rb +3 -1
- data/lib/jirametrics/status.rb +1 -1
- data/lib/jirametrics/stitcher.rb +7 -1
- 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.rb +28 -0
- metadata +47 -5
|
@@ -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
|
|
@@ -6,7 +6,7 @@ require 'jirametrics/status_collection'
|
|
|
6
6
|
class ProjectConfig
|
|
7
7
|
attr_reader :target_path, :jira_config, :all_boards, :possible_statuses,
|
|
8
8
|
:download_config, :file_configs, :exporter, :data_version, :name, :board_configs,
|
|
9
|
-
:settings, :aggregate_config, :discarded_changes_data, :users
|
|
9
|
+
:settings, :aggregate_config, :discarded_changes_data, :users, :fix_versions
|
|
10
10
|
attr_accessor :time_range, :jira_url, :id
|
|
11
11
|
|
|
12
12
|
def initialize exporter:, jira_config:, block:, target_path: '.', name: '', id: nil
|
|
@@ -23,6 +23,7 @@ class ProjectConfig
|
|
|
23
23
|
@settings = load_settings
|
|
24
24
|
@id = id
|
|
25
25
|
@has_loaded_data = false
|
|
26
|
+
@fix_versions = []
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
def evaluate_next_level
|
|
@@ -40,17 +41,20 @@ class ProjectConfig
|
|
|
40
41
|
@id = guess_project_id
|
|
41
42
|
load_project_metadata
|
|
42
43
|
load_sprints
|
|
44
|
+
load_fix_versions
|
|
43
45
|
load_users
|
|
46
|
+
resolve_blocked_stalled_status_settings
|
|
44
47
|
end
|
|
45
48
|
|
|
46
49
|
def run load_only: false
|
|
47
50
|
return if @exporter.downloading?
|
|
48
51
|
|
|
49
52
|
load_data unless aggregated_project?
|
|
50
|
-
anonymize_data if @anonymizer_needed
|
|
51
53
|
|
|
52
54
|
return if load_only
|
|
53
55
|
|
|
56
|
+
anonymize_data if @anonymizer_needed
|
|
57
|
+
|
|
54
58
|
@file_configs.each do |file_config|
|
|
55
59
|
file_config.run
|
|
56
60
|
end
|
|
@@ -67,7 +71,10 @@ class ProjectConfig
|
|
|
67
71
|
file_system.deprecated message: 'stalled color should be set via css now', date: '2024-05-03'
|
|
68
72
|
end
|
|
69
73
|
|
|
70
|
-
settings
|
|
74
|
+
settings['blocked_statuses'] = StatusCollection.new
|
|
75
|
+
settings['stalled_statuses'] = StatusCollection.new
|
|
76
|
+
|
|
77
|
+
stringify_keys(settings)
|
|
71
78
|
end
|
|
72
79
|
|
|
73
80
|
def guess_project_id
|
|
@@ -143,6 +150,17 @@ class ProjectConfig
|
|
|
143
150
|
@file_prefix
|
|
144
151
|
end
|
|
145
152
|
|
|
153
|
+
def validate_discard_status status_name
|
|
154
|
+
return if status_name == :backlog
|
|
155
|
+
return if possible_statuses.empty? # not yet downloaded; skip validation
|
|
156
|
+
|
|
157
|
+
found = possible_statuses.find_all_by_name status_name
|
|
158
|
+
return unless found.empty?
|
|
159
|
+
|
|
160
|
+
raise "discard_changes_before: Status #{status_name.inspect} not found. " \
|
|
161
|
+
"Possible statuses are: #{possible_statuses}"
|
|
162
|
+
end
|
|
163
|
+
|
|
146
164
|
def raise_if_prefix_already_used prefix
|
|
147
165
|
@exporter.project_configs.each do |project|
|
|
148
166
|
next unless project.get_file_prefix(raise_if_not_set: false) == prefix && project.target_path == target_path
|
|
@@ -267,9 +285,16 @@ class ProjectConfig
|
|
|
267
285
|
end
|
|
268
286
|
|
|
269
287
|
def load_board board_id:, filename:
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
)
|
|
288
|
+
raw = file_system.load_json(filename)
|
|
289
|
+
|
|
290
|
+
features_filename = File.join(@target_path, "#{get_file_prefix}_board_#{board_id}_features.json")
|
|
291
|
+
features = if file_system.file_exist?(features_filename)
|
|
292
|
+
BoardFeature.from_raw(file_system.load_json(features_filename))
|
|
293
|
+
else
|
|
294
|
+
[]
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
board = Board.new(raw: raw, possible_statuses: @possible_statuses, features: features)
|
|
273
298
|
board.project_config = self
|
|
274
299
|
@all_boards[board_id] = board
|
|
275
300
|
end
|
|
@@ -326,6 +351,13 @@ class ProjectConfig
|
|
|
326
351
|
end
|
|
327
352
|
end
|
|
328
353
|
|
|
354
|
+
def load_fix_versions
|
|
355
|
+
filename = File.join(@target_path, "#{get_file_prefix}_fix_versions.json")
|
|
356
|
+
return unless file_system.file_exist?(filename)
|
|
357
|
+
|
|
358
|
+
@fix_versions = file_system.load_json(filename).map { |raw| FixVersion.new(raw) }
|
|
359
|
+
end
|
|
360
|
+
|
|
329
361
|
def load_project_metadata
|
|
330
362
|
filename = File.join @target_path, "#{get_file_prefix}_meta.json"
|
|
331
363
|
json = file_system.load_json(filename)
|
|
@@ -362,6 +394,19 @@ class ProjectConfig
|
|
|
362
394
|
json.each { |user_data| @users << User.new(raw: user_data) }
|
|
363
395
|
end
|
|
364
396
|
|
|
397
|
+
def attach_github_prs
|
|
398
|
+
filename = File.join(@target_path, "#{get_file_prefix}_github_prs.json")
|
|
399
|
+
return unless File.exist?(filename)
|
|
400
|
+
|
|
401
|
+
prs_by_issue_key = Hash.new { |h, k| h[k] = [] }
|
|
402
|
+
file_system.load_json(filename).each do |raw|
|
|
403
|
+
pr = PullRequest.new(raw: raw)
|
|
404
|
+
pr.issue_keys.each { |key| prs_by_issue_key[key] << pr }
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
@issues.each { |issue| issue.github_prs = prs_by_issue_key[issue.key] }
|
|
408
|
+
end
|
|
409
|
+
|
|
365
410
|
def atlassian_document_format
|
|
366
411
|
@atlassian_document_format ||= AtlassianDocumentFormat.new(
|
|
367
412
|
users: @users, timezone_offset: exporter.timezone_offset
|
|
@@ -444,6 +489,7 @@ class ProjectConfig
|
|
|
444
489
|
# attached them in the appropriate places, remove any that aren't part of that initial set.
|
|
445
490
|
issues.reject! { |i| !i.in_initial_query? } # rubocop:disable Style/InverseMethods
|
|
446
491
|
@issues = issues
|
|
492
|
+
attach_github_prs
|
|
447
493
|
end
|
|
448
494
|
|
|
449
495
|
@issues
|
|
@@ -563,6 +609,8 @@ class ProjectConfig
|
|
|
563
609
|
if status_becomes
|
|
564
610
|
status_becomes = [status_becomes] unless status_becomes.is_a? Array
|
|
565
611
|
|
|
612
|
+
status_becomes.each { |status_name| validate_discard_status status_name }
|
|
613
|
+
|
|
566
614
|
block = lambda do |issue|
|
|
567
615
|
trigger_statuses = status_becomes.collect do |status_name|
|
|
568
616
|
if status_name == :backlog
|
|
@@ -588,7 +636,7 @@ class ProjectConfig
|
|
|
588
636
|
cutoff_time = block.call(issue)
|
|
589
637
|
next if cutoff_time.nil?
|
|
590
638
|
|
|
591
|
-
original_start_time = issue.
|
|
639
|
+
original_start_time = issue.started_stopped_times.first
|
|
592
640
|
next if original_start_time.nil?
|
|
593
641
|
|
|
594
642
|
issue.discard_changes_before cutoff_time
|
|
@@ -606,4 +654,29 @@ class ProjectConfig
|
|
|
606
654
|
|
|
607
655
|
cycletimes_touched.each { |c| c.flush_cache }
|
|
608
656
|
end
|
|
657
|
+
|
|
658
|
+
def stringify_keys value
|
|
659
|
+
case value
|
|
660
|
+
when Hash then value.transform_keys(&:to_s).transform_values { |v| stringify_keys(v) }
|
|
661
|
+
when Array then value.map { |v| stringify_keys(v) }
|
|
662
|
+
else value
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
def resolve_blocked_stalled_status_settings
|
|
667
|
+
%w[blocked_statuses stalled_statuses].each do |key|
|
|
668
|
+
next if @settings[key].is_a?(StatusCollection)
|
|
669
|
+
|
|
670
|
+
collection = StatusCollection.new
|
|
671
|
+
@settings[key].each do |identifier|
|
|
672
|
+
statuses = @possible_statuses.find_all_by_name(identifier)
|
|
673
|
+
if statuses.empty?
|
|
674
|
+
file_system.warning "Status #{identifier.inspect} in #{key} not found. Ignoring."
|
|
675
|
+
else
|
|
676
|
+
statuses.each { |status| collection << status }
|
|
677
|
+
end
|
|
678
|
+
end
|
|
679
|
+
@settings[key] = collection
|
|
680
|
+
end
|
|
681
|
+
end
|
|
609
682
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require 'jirametrics/pull_request_review'
|
|
5
|
+
|
|
6
|
+
class PullRequest
|
|
7
|
+
attr_reader :raw
|
|
8
|
+
|
|
9
|
+
def initialize raw:
|
|
10
|
+
@raw = raw
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def number = @raw['number']
|
|
14
|
+
def repo = @raw['repo']
|
|
15
|
+
def url = @raw['url']
|
|
16
|
+
def title = @raw['title']
|
|
17
|
+
def branch = @raw['branch']
|
|
18
|
+
def state = @raw['state']
|
|
19
|
+
def issue_keys = @raw['issue_keys']
|
|
20
|
+
|
|
21
|
+
def opened_at = Time.parse(@raw['opened_at'])
|
|
22
|
+
def closed_at = @raw['closed_at'] ? Time.parse(@raw['closed_at']) : nil
|
|
23
|
+
def merged_at = @raw['merged_at'] ? Time.parse(@raw['merged_at']) : nil
|
|
24
|
+
|
|
25
|
+
def reviews = (@raw['reviews'] || []).map { |r| PullRequestReview.new(raw: r) }
|
|
26
|
+
def additions = @raw['additions']
|
|
27
|
+
def deletions = @raw['deletions']
|
|
28
|
+
def changed_files = @raw['changed_files']
|
|
29
|
+
def lines_changed = (additions || 0) + (deletions || 0)
|
|
30
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jirametrics/groupable_issue_chart'
|
|
4
|
+
|
|
5
|
+
class PullRequestCycleTimeHistogram < TimeBasedHistogram
|
|
6
|
+
def initialize block
|
|
7
|
+
super()
|
|
8
|
+
|
|
9
|
+
@cycletime_unit = :days
|
|
10
|
+
@x_axis_title = 'Cycle time in days'
|
|
11
|
+
|
|
12
|
+
header_text 'PR Histogram'
|
|
13
|
+
description_text <<-HTML
|
|
14
|
+
<div class="p">
|
|
15
|
+
This cycletime Histogram shows how many pull requests completed in a certain timeframe. This can be
|
|
16
|
+
useful for determining how many different types of work are flowing through, based on the
|
|
17
|
+
lengths of time they take.
|
|
18
|
+
</div>
|
|
19
|
+
HTML
|
|
20
|
+
|
|
21
|
+
init_configuration_block(block) do
|
|
22
|
+
grouping_rules do |pull_request, rule|
|
|
23
|
+
rule.label = pull_request.repo
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def cycletime_unit unit
|
|
29
|
+
unless %i[minutes hours days].include?(unit)
|
|
30
|
+
raise ArgumentError, "cycletime_unit must be :minutes, :hours, or :days, got #{unit.inspect}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@cycletime_unit = unit
|
|
34
|
+
@x_axis_title = "Cycle time in #{unit}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def all_items
|
|
38
|
+
result = []
|
|
39
|
+
issues.each do |issue|
|
|
40
|
+
next unless issue.github_prs
|
|
41
|
+
|
|
42
|
+
issue.github_prs.each do |pr|
|
|
43
|
+
next unless pr.closed_at
|
|
44
|
+
|
|
45
|
+
result << pr
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
result.uniq
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def value_for_item item
|
|
52
|
+
divisor = { minutes: 60.0, hours: 3600.0, days: 86_400.0 }[@cycletime_unit]
|
|
53
|
+
((item.closed_at - item.opened_at) / divisor).ceil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def label_cycletime value
|
|
57
|
+
case @cycletime_unit
|
|
58
|
+
when :minutes then label_minutes(value)
|
|
59
|
+
when :hours then label_hours(value)
|
|
60
|
+
when :days then label_days(value)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def title_for_item count:, value:
|
|
65
|
+
"#{count} PR#{'s' unless count == 1} closed in #{label_cycletime value}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def sort_items items
|
|
69
|
+
items.sort_by(&:opened_at)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def label_for_item item, hint:
|
|
73
|
+
label = "#{item.number} #{item.title}"
|
|
74
|
+
label << hint if hint
|
|
75
|
+
label
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jirametrics/groupable_issue_chart'
|
|
4
|
+
|
|
5
|
+
class PullRequestCycleTimeScatterplot < TimeBasedScatterplot
|
|
6
|
+
def initialize block
|
|
7
|
+
super()
|
|
8
|
+
|
|
9
|
+
@cycletime_unit = :days
|
|
10
|
+
@y_axis_title = 'Cycle time in days'
|
|
11
|
+
|
|
12
|
+
header_text 'Pull Request (PR) Scatterplot'
|
|
13
|
+
description_text <<-HTML
|
|
14
|
+
<div class="p">
|
|
15
|
+
This graph shows the cycle time for all closed pull requests (time from opened to closed).
|
|
16
|
+
</div>
|
|
17
|
+
#{describe_non_working_days}
|
|
18
|
+
HTML
|
|
19
|
+
|
|
20
|
+
init_configuration_block(block) do
|
|
21
|
+
grouping_rules do |pull_request, rule|
|
|
22
|
+
rule.label = pull_request.repo
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def cycletime_unit unit
|
|
28
|
+
unless %i[minutes hours days].include?(unit)
|
|
29
|
+
raise ArgumentError, "cycletime_unit must be :minutes, :hours, or :days, got #{unit.inspect}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@cycletime_unit = unit
|
|
33
|
+
@y_axis_title = "Cycle time in #{unit}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def all_items
|
|
37
|
+
result = []
|
|
38
|
+
issues.each do |issue|
|
|
39
|
+
issue.github_prs&.each do |pr|
|
|
40
|
+
result << pr if pr.closed_at
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
result
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def x_value pull_request
|
|
47
|
+
pull_request.closed_at
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def y_value pull_request
|
|
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
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def label_cycletime value
|
|
63
|
+
case @cycletime_unit
|
|
64
|
+
when :minutes then label_minutes(value)
|
|
65
|
+
when :hours then label_hours(value)
|
|
66
|
+
when :days then label_days(value)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def title_value pull_request, rules: nil
|
|
71
|
+
age_label = label_cycletime y_value(pull_request)
|
|
72
|
+
keys = pull_request.issue_keys.join(', ')
|
|
73
|
+
"#{keys} | #{pull_request.title} | #{rules.label} | Age:#{age_label}#{lines_changed_text(pull_request)}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def lines_changed_text pull_request
|
|
77
|
+
return '' unless pull_request.changed_files
|
|
78
|
+
|
|
79
|
+
additions = pull_request.additions || 0
|
|
80
|
+
deletions = pull_request.deletions || 0
|
|
81
|
+
text = +' | Lines changed: ['
|
|
82
|
+
text << "+#{to_human_readable additions}" unless additions.zero?
|
|
83
|
+
text << ' ' if additions != 0 && deletions != 0
|
|
84
|
+
text << "-#{to_human_readable deletions}" unless deletions.zero?
|
|
85
|
+
text << "], Files changed: #{to_human_readable pull_request.changed_files}"
|
|
86
|
+
text
|
|
87
|
+
end
|
|
88
|
+
end
|