jirametrics 2.31 → 3.1
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 +42 -29
- data/lib/jirametrics/aging_work_bar_chart.rb +52 -40
- data/lib/jirametrics/aging_work_in_progress_chart.rb +65 -60
- data/lib/jirametrics/aging_work_table.rb +36 -33
- data/lib/jirametrics/anonymizer.rb +31 -86
- data/lib/jirametrics/atlassian_document_format.rb +11 -4
- data/lib/jirametrics/blocked_stalled_by_date_builder.rb +64 -0
- data/lib/jirametrics/blocked_stalled_change.rb +5 -1
- data/lib/jirametrics/blocked_stalled_change_stream_builder.rb +194 -0
- data/lib/jirametrics/board.rb +23 -9
- data/lib/jirametrics/board_config.rb +0 -7
- data/lib/jirametrics/board_movement_calculator.rb +24 -21
- data/lib/jirametrics/cfd_data_builder.rb +46 -44
- data/lib/jirametrics/change_item.rb +19 -7
- data/lib/jirametrics/chart_base.rb +51 -65
- data/lib/jirametrics/cumulative_flow_diagram.rb +60 -47
- data/lib/jirametrics/cycle_time_config.rb +35 -46
- data/lib/jirametrics/cycletime_histogram.rb +7 -0
- data/lib/jirametrics/cycletime_scatterplot.rb +7 -0
- data/lib/jirametrics/daily_view.rb +83 -61
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +24 -9
- data/lib/jirametrics/daily_wip_chart.rb +72 -45
- data/lib/jirametrics/data_quality_report.rb +70 -66
- data/lib/jirametrics/dependency_chart.rb +40 -31
- data/lib/jirametrics/download_config.rb +0 -3
- data/lib/jirametrics/downloader.rb +39 -14
- data/lib/jirametrics/downloader_for_cloud.rb +110 -74
- data/lib/jirametrics/estimate_accuracy_chart.rb +13 -13
- data/lib/jirametrics/examples/standard_project.rb +2 -2
- data/lib/jirametrics/expedited_chart.rb +47 -37
- data/lib/jirametrics/exporter.rb +203 -12
- data/lib/jirametrics/file_config.rb +19 -12
- data/lib/jirametrics/file_system.rb +16 -10
- data/lib/jirametrics/flow_efficiency_calculator.rb +62 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +4 -2
- data/lib/jirametrics/github_gateway.rb +52 -12
- data/lib/jirametrics/groupable_issue_chart.rb +3 -0
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +3 -3
- data/lib/jirametrics/html/index.css +10 -4
- data/lib/jirametrics/html_report_config.rb +9 -29
- data/lib/jirametrics/issue.rb +293 -411
- data/lib/jirametrics/issue_collection.rb +1 -0
- data/lib/jirametrics/issue_printer.rb +60 -23
- data/lib/jirametrics/jira_gateway.rb +56 -18
- data/lib/jirametrics/mcp_server.rb +226 -210
- data/lib/jirametrics/project_config.rb +206 -133
- data/lib/jirametrics/pull_request_cycle_time_histogram.rb +1 -20
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +11 -33
- data/lib/jirametrics/rules.rb +1 -0
- data/lib/jirametrics/self_or_issue_dispatcher.rb +1 -3
- data/lib/jirametrics/sprint_burndown.rb +142 -242
- data/lib/jirametrics/sprint_count_measure.rb +42 -0
- data/lib/jirametrics/sprint_issue_change_data.rb +1 -0
- data/lib/jirametrics/sprint_points_measure.rb +62 -0
- data/lib/jirametrics/sprint_summary_stats.rb +16 -0
- data/lib/jirametrics/status_collection.rb +29 -5
- data/lib/jirametrics/stitcher.rb +21 -16
- data/lib/jirametrics/throughput_chart.rb +38 -24
- data/lib/jirametrics/time_based_chart.rb +65 -0
- data/lib/jirametrics/time_based_histogram.rb +30 -23
- data/lib/jirametrics/time_based_scatterplot.rb +11 -2
- data/lib/jirametrics/trend_line_calculator.rb +2 -2
- data/lib/jirametrics/wip_by_column_chart.rb +75 -34
- data/lib/jirametrics.rb +35 -4
- metadata +13 -20
data/lib/jirametrics/exporter.rb
CHANGED
|
@@ -6,11 +6,22 @@ class Exporter
|
|
|
6
6
|
attr_reader :project_configs
|
|
7
7
|
attr_accessor :file_system
|
|
8
8
|
|
|
9
|
+
def self.logfile_name
|
|
10
|
+
@logfile_name ||= 'jirametrics.log'
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# The MCP server sets this to a separate file so starting the server doesn't clobber the
|
|
14
|
+
# export/debug log (jirametrics.log) that someone may be mid-debug on.
|
|
15
|
+
class << self
|
|
16
|
+
attr_writer :logfile_name
|
|
17
|
+
end
|
|
18
|
+
|
|
9
19
|
def self.configure &block
|
|
10
|
-
|
|
11
|
-
|
|
20
|
+
# No block form: FileSystem holds this descriptor open for the whole run and writes to it as we
|
|
21
|
+
# go, so it must outlive this method.
|
|
22
|
+
logfile = File.open(logfile_name, 'w') # rubocop:disable Style/FileOpen
|
|
12
23
|
rescue Errno::EACCES
|
|
13
|
-
# FileSystem can't be used here
|
|
24
|
+
# FileSystem can't be used here - it hasn't been created yet (it depends on this logfile).
|
|
14
25
|
warn "Error: Cannot write to #{File.expand_path(logfile_name)}. " \
|
|
15
26
|
'Please ensure the current directory is writable.'
|
|
16
27
|
exit 1
|
|
@@ -71,6 +82,179 @@ class Exporter
|
|
|
71
82
|
puts "Full output from downloader in #{file_system.logfile_name}"
|
|
72
83
|
end
|
|
73
84
|
|
|
85
|
+
def verify_jira_connections name_filter:
|
|
86
|
+
results = []
|
|
87
|
+
begin
|
|
88
|
+
# Probe under log_only so the gateway's raw curl chatter (and its error dumps) stay in the
|
|
89
|
+
# logfile rather than cluttering the console; we surface a clean summary line below.
|
|
90
|
+
file_system.log_only = true
|
|
91
|
+
jira_connections_to_verify(name_filter: name_filter).each do |config, settings|
|
|
92
|
+
gateway = JiraGateway.new(file_system: file_system, jira_config: config, settings: settings)
|
|
93
|
+
results << gateway.verify_connection
|
|
94
|
+
end
|
|
95
|
+
ensure
|
|
96
|
+
file_system.log_only = false
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
if results.empty?
|
|
100
|
+
file_system.error 'No Jira connection found in the configuration to verify'
|
|
101
|
+
return results
|
|
102
|
+
end
|
|
103
|
+
results.each { |result| file_system.log result.message, also_write_to_stderr: true }
|
|
104
|
+
results
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The [jira_config, settings] pairs to verify, deduplicated by URL. We do NOT evaluate_next_level:
|
|
108
|
+
# running a project block (e.g. standard_project) forces an issue-data load that doesn't exist
|
|
109
|
+
# before the first download, and verify must work pre-download. When no project supplied a
|
|
110
|
+
# connection we fall back to the top-level jira_config, so verify can run as a first setup step on
|
|
111
|
+
# just the credentials file. (The fallback has no per-project settings, so a config-block-only
|
|
112
|
+
# setting like ignore_ssl_errors isn't applied. That only affects self-signed
|
|
113
|
+
# Data Center instances.)
|
|
114
|
+
def jira_connections_to_verify name_filter:
|
|
115
|
+
connections = []
|
|
116
|
+
seen_urls = []
|
|
117
|
+
each_project_config(name_filter: name_filter) do |project|
|
|
118
|
+
url = project.jira_config && project.jira_config['url']
|
|
119
|
+
next if url.nil? || seen_urls.include?(url)
|
|
120
|
+
|
|
121
|
+
seen_urls << url
|
|
122
|
+
connections << [project.jira_config, project.settings]
|
|
123
|
+
end
|
|
124
|
+
connections << [jira_config, {}] if connections.empty? && jira_config
|
|
125
|
+
connections
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def boards board_id:, name_filter: nil
|
|
129
|
+
gateway = JiraGateway.new(file_system: file_system, jira_config: jira_config, settings: {})
|
|
130
|
+
# Keep the gateway's raw curl chatter in the logfile; list_boards/describe_board switch this off
|
|
131
|
+
# right before they print their own clean output, and the rescue turns a failed Jira call into a
|
|
132
|
+
# one-line message with next steps instead of a stack trace.
|
|
133
|
+
file_system.log_only = true
|
|
134
|
+
if board_id.nil?
|
|
135
|
+
list_boards gateway, name_filter
|
|
136
|
+
else
|
|
137
|
+
describe_board gateway, board_id
|
|
138
|
+
end
|
|
139
|
+
true
|
|
140
|
+
rescue StandardError
|
|
141
|
+
file_system.log_only = false
|
|
142
|
+
file_system.error boards_error_message(board_id)
|
|
143
|
+
false
|
|
144
|
+
ensure
|
|
145
|
+
file_system.log_only = false
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def boards_error_message board_id
|
|
149
|
+
if board_id
|
|
150
|
+
"Couldn't read board #{board_id} from Jira. Check the id (run `jirametrics boards` to list them) " \
|
|
151
|
+
"and your credentials (`jirametrics verify`). Details in #{file_system.logfile_name}."
|
|
152
|
+
else
|
|
153
|
+
"Couldn't list boards from Jira. Check your credentials with `jirametrics verify`. " \
|
|
154
|
+
"Details in #{file_system.logfile_name}."
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def list_boards gateway, name_filter
|
|
159
|
+
boards = fetch_all_boards gateway
|
|
160
|
+
boards.select! { |board| File.fnmatch(name_filter, board['name'].to_s, File::FNM_CASEFOLD) } if name_filter
|
|
161
|
+
boards.sort_by! { |board| board['name'].to_s.strip.downcase }
|
|
162
|
+
file_system.log_only = false # gateway calls done; turn logging back on to print results
|
|
163
|
+
|
|
164
|
+
if boards.empty?
|
|
165
|
+
file_system.log(
|
|
166
|
+
name_filter ? "No boards match #{name_filter.inspect}." : 'No boards found for this Jira connection.',
|
|
167
|
+
also_write_to_stderr: true
|
|
168
|
+
)
|
|
169
|
+
return
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
lines = ['Boards you can access:', '']
|
|
173
|
+
boards.each { |board| lines << " #{board['id']}: #{board['name'].inspect} (#{board['type']})" }
|
|
174
|
+
lines << ''
|
|
175
|
+
lines << "Run `jirametrics boards <id>` to see a board's columns and choose cycletime points."
|
|
176
|
+
file_system.log lines.join("\n"), also_write_to_stderr: true
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def fetch_all_boards gateway
|
|
180
|
+
boards = []
|
|
181
|
+
start_at = 0
|
|
182
|
+
loop do
|
|
183
|
+
json = gateway.call_url relative_url: "/rest/agile/1.0/board?startAt=#{start_at}&maxResults=50"
|
|
184
|
+
values = json['values'] || []
|
|
185
|
+
boards.concat values
|
|
186
|
+
break if json['isLast'] || values.empty?
|
|
187
|
+
|
|
188
|
+
start_at += values.length
|
|
189
|
+
end
|
|
190
|
+
boards
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def describe_board gateway, board_id
|
|
194
|
+
statuses = StatusCollection.new
|
|
195
|
+
gateway.call_url(relative_url: '/rest/api/2/status').each do |snippet|
|
|
196
|
+
statuses << Status.from_raw(snippet)
|
|
197
|
+
end
|
|
198
|
+
raw = gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/configuration"
|
|
199
|
+
features = board_features(gateway, board_id, raw)
|
|
200
|
+
file_system.log_only = false # gateway calls done; turn logging back on to print results
|
|
201
|
+
board = Board.new raw: raw, possible_statuses: statuses, features: features
|
|
202
|
+
file_system.log format_board(board, board_id), also_write_to_stderr: true
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Only a team-managed ("simple") board needs the features lookup to tell sprints from kanban; for
|
|
206
|
+
# classic scrum/kanban the board type alone settles it, so we skip the extra request.
|
|
207
|
+
def board_features gateway, board_id, raw
|
|
208
|
+
return [] unless raw['type'] == 'simple'
|
|
209
|
+
|
|
210
|
+
BoardFeature.from_raw gateway.call_url(relative_url: "/rest/agile/1.0/board/#{board_id}/features")
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def format_board board, board_id
|
|
214
|
+
lines = ["Board #{board_id}: #{board.name.inspect} (#{board_kind_label board})", '']
|
|
215
|
+
|
|
216
|
+
backlog = board.backlog_statuses
|
|
217
|
+
unless backlog.empty?
|
|
218
|
+
lines << 'Not shown on the board (treated as not started):'
|
|
219
|
+
backlog.each { |status| lines << " - #{format_status status}" }
|
|
220
|
+
lines << ''
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
lines << 'Columns, left to right, with the statuses in each (shown as "name":id with its category):'
|
|
224
|
+
lines << ''
|
|
225
|
+
board.visible_columns.each do |column|
|
|
226
|
+
lines << " #{column.name}"
|
|
227
|
+
column.status_ids.each do |id|
|
|
228
|
+
status = board.possible_statuses.find_by_id id
|
|
229
|
+
lines << " - #{status ? format_status(status) : "unknown status id #{id}"}"
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
lines << ''
|
|
234
|
+
lines << 'To set cycle time, choose the column where work is "started" and where it is "finished":'
|
|
235
|
+
lines << " start_at first_time_in_or_right_of_column '<started column>'"
|
|
236
|
+
lines << " stop_at first_time_in_or_right_of_column '<finished column>'"
|
|
237
|
+
if board.scrum?
|
|
238
|
+
lines << ''
|
|
239
|
+
lines << 'This board uses sprints. If no column cleanly marks when work starts, you can start the'
|
|
240
|
+
lines << 'clock when an item is first added to a sprint instead:'
|
|
241
|
+
lines << ' start_at first_time_added_to_active_sprint'
|
|
242
|
+
end
|
|
243
|
+
lines.join "\n"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# A team-managed ("simple") board can be scrum- or kanban-flavoured depending on its sprints
|
|
247
|
+
# feature; the bare "simple" from Jira doesn't say which, so spell it out.
|
|
248
|
+
def board_kind_label board
|
|
249
|
+
return board.board_type unless board.board_type == 'simple'
|
|
250
|
+
|
|
251
|
+
board.scrum? ? 'team-managed, uses sprints' : 'team-managed, no sprints'
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def format_status status
|
|
255
|
+
"#{status.name.inspect}:#{status.id} (#{status.category.name})"
|
|
256
|
+
end
|
|
257
|
+
|
|
74
258
|
def info key, name_filter:
|
|
75
259
|
selected = []
|
|
76
260
|
file_system.log_only = true
|
|
@@ -78,12 +262,7 @@ class Exporter
|
|
|
78
262
|
project.evaluate_next_level
|
|
79
263
|
|
|
80
264
|
project.run load_only: true
|
|
81
|
-
|
|
82
|
-
selected << [project, issue] if key == issue.key
|
|
83
|
-
issue.subtasks.each do |subtask|
|
|
84
|
-
selected << [project, subtask] if key == subtask.key
|
|
85
|
-
end
|
|
86
|
-
end
|
|
265
|
+
selected.concat matching_issues_in(project, key)
|
|
87
266
|
rescue => e # rubocop:disable Style/RescueStandardError
|
|
88
267
|
# This happens when we're attempting to load an aggregated project because it hasn't been
|
|
89
268
|
# properly initialized. Since we don't care about aggregated projects, we just ignore it.
|
|
@@ -101,6 +280,17 @@ class Exporter
|
|
|
101
280
|
end
|
|
102
281
|
end
|
|
103
282
|
|
|
283
|
+
def matching_issues_in project, key
|
|
284
|
+
matches = []
|
|
285
|
+
project.issues.each do |issue|
|
|
286
|
+
matches << [project, issue] if key == issue.key
|
|
287
|
+
issue.subtasks.each do |subtask|
|
|
288
|
+
matches << [project, subtask] if key == subtask.key
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
matches
|
|
292
|
+
end
|
|
293
|
+
|
|
104
294
|
def stitch stitch_file
|
|
105
295
|
Stitcher.new(file_system: file_system).run(stitch_file: stitch_file)
|
|
106
296
|
end
|
|
@@ -139,7 +329,8 @@ class Exporter
|
|
|
139
329
|
@jira_config = file_system.load_json(filename, fail_on_error: false)
|
|
140
330
|
raise "Unable to load Jira configuration file and cannot continue: #{filename.inspect}" if @jira_config.nil?
|
|
141
331
|
|
|
142
|
-
|
|
332
|
+
match = %r{^(?<base_url>.+)/+$}.match(@jira_config['url'])
|
|
333
|
+
@jira_config['url'] = match[:base_url] if match
|
|
143
334
|
end
|
|
144
335
|
@jira_config
|
|
145
336
|
end
|
|
@@ -153,8 +344,8 @@ class Exporter
|
|
|
153
344
|
unless args.empty?
|
|
154
345
|
dates = []
|
|
155
346
|
args.each do |arg|
|
|
156
|
-
if
|
|
157
|
-
Date.parse(
|
|
347
|
+
if /^(?<from>\d{4}-\d{2}-\d{2})\.\.(?<to>\d{4}-\d{2}-\d{2})$/ =~ arg
|
|
348
|
+
Date.parse(from).upto(Date.parse(to)).each { |date| dates << date }
|
|
158
349
|
else
|
|
159
350
|
dates << Date.parse(arg)
|
|
160
351
|
end
|
|
@@ -5,11 +5,12 @@ require 'csv'
|
|
|
5
5
|
class FileConfig
|
|
6
6
|
attr_reader :project_config, :issues
|
|
7
7
|
|
|
8
|
-
def initialize project_config:, block:, today: Date.today
|
|
8
|
+
def initialize project_config:, block:, today: Date.today, issues: nil
|
|
9
9
|
@project_config = project_config
|
|
10
10
|
@block = block
|
|
11
11
|
@columns = nil
|
|
12
12
|
@today = today
|
|
13
|
+
@issues = issues
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def run
|
|
@@ -65,22 +66,28 @@ class FileConfig
|
|
|
65
66
|
# most common usecase - the Team Dashboard from FocusedObjective.com. The rule for that one
|
|
66
67
|
# is that all empty values in the first column should be at the bottom.
|
|
67
68
|
def sort_output all_lines
|
|
68
|
-
all_lines.each_with_index.sort do |(
|
|
69
|
-
result =
|
|
70
|
-
a[1..] <=> b[1..]
|
|
71
|
-
elsif a[0].nil?
|
|
72
|
-
1
|
|
73
|
-
elsif b[0].nil?
|
|
74
|
-
-1
|
|
75
|
-
else
|
|
76
|
-
a[0] <=> b[0]
|
|
77
|
-
end
|
|
69
|
+
all_lines.each_with_index.sort do |(left, left_idx), (right, right_idx)|
|
|
70
|
+
result = compare_rows left, right
|
|
78
71
|
|
|
79
72
|
# When objects aren't comparable, preserve original order for a stable sort.
|
|
80
|
-
result.nil? || result.zero? ?
|
|
73
|
+
result.nil? || result.zero? ? left_idx <=> right_idx : result
|
|
81
74
|
end.map(&:first)
|
|
82
75
|
end
|
|
83
76
|
|
|
77
|
+
# Compare two rows by their first column, falling back to the remaining columns on a tie, and
|
|
78
|
+
# sorting nil first columns to the bottom.
|
|
79
|
+
def compare_rows left, right
|
|
80
|
+
if left[0] == right[0]
|
|
81
|
+
left[1..] <=> right[1..]
|
|
82
|
+
elsif left[0].nil?
|
|
83
|
+
1
|
|
84
|
+
elsif right[0].nil?
|
|
85
|
+
-1
|
|
86
|
+
else
|
|
87
|
+
left[0] <=> right[0]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
84
91
|
def columns &block
|
|
85
92
|
assert_only_one_filetype_config_set
|
|
86
93
|
@columns = ColumnsConfig.new file_config: self, block: block
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
|
|
5
|
+
# This is the stderr display/logging layer: it writes progress and log output straight to $stderr
|
|
6
|
+
# (see the $stderr.print/.flush calls). That output isn't warnings, so $stderr.puts is intentional
|
|
7
|
+
# throughout rather than warn.
|
|
8
|
+
# rubocop:disable Style/StderrPuts
|
|
5
9
|
class FileSystem
|
|
6
10
|
attr_accessor :logfile, :logfile_name, :log_only
|
|
7
11
|
|
|
@@ -14,18 +18,14 @@ class FileSystem
|
|
|
14
18
|
end
|
|
15
19
|
|
|
16
20
|
# Effectively the same as File.read except it forces the encoding to UTF-8
|
|
17
|
-
def load filename
|
|
18
|
-
if filename.end_with?('.json') && !supress_deprecation
|
|
19
|
-
deprecated(message: 'call load_json instead', date: '2024-11-13')
|
|
20
|
-
end
|
|
21
|
-
|
|
21
|
+
def load filename
|
|
22
22
|
File.read filename, encoding: 'UTF-8'
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def load_json filename, fail_on_error: true
|
|
26
26
|
return nil if fail_on_error == false && File.exist?(filename) == false
|
|
27
27
|
|
|
28
|
-
JSON.parse load(filename
|
|
28
|
+
JSON.parse load(filename)
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
def save_json json:, filename:
|
|
@@ -70,7 +70,7 @@ class FileSystem
|
|
|
70
70
|
# set up. Quick escape here so that we don't dump the error twice.
|
|
71
71
|
return if logfile == $stdout
|
|
72
72
|
|
|
73
|
-
$stderr.puts message
|
|
73
|
+
$stderr.puts message
|
|
74
74
|
end
|
|
75
75
|
|
|
76
76
|
def log_start message
|
|
@@ -98,21 +98,26 @@ class FileSystem
|
|
|
98
98
|
def end_progress
|
|
99
99
|
return if log_only
|
|
100
100
|
|
|
101
|
-
$stderr.puts ''
|
|
101
|
+
$stderr.puts ''
|
|
102
102
|
end
|
|
103
103
|
|
|
104
104
|
# In some Jira instances, a sizeable portion of the JSON is made up of empty fields. I've seen
|
|
105
105
|
# cases where this simple compression will drop the filesize by half.
|
|
106
106
|
def compress node
|
|
107
107
|
if node.is_a? Hash
|
|
108
|
-
node.reject! { |_key, value|
|
|
108
|
+
node.reject! { |_key, value| prunable? value }
|
|
109
109
|
node.each_value { |value| compress value }
|
|
110
110
|
elsif node.is_a? Array
|
|
111
|
-
node.each { |
|
|
111
|
+
node.each { |element| compress element }
|
|
112
112
|
end
|
|
113
113
|
node
|
|
114
114
|
end
|
|
115
115
|
|
|
116
|
+
# A value worth dropping from the compressed output: nothing there, or an empty array.
|
|
117
|
+
def prunable? value
|
|
118
|
+
value.nil? || (value.is_a?(Array) && value.empty?)
|
|
119
|
+
end
|
|
120
|
+
|
|
116
121
|
def foreach root, &block
|
|
117
122
|
Dir.foreach root, &block
|
|
118
123
|
end
|
|
@@ -139,3 +144,4 @@ class FileSystem
|
|
|
139
144
|
log text, also_write_to_stderr: true
|
|
140
145
|
end
|
|
141
146
|
end
|
|
147
|
+
# rubocop:enable Style/StderrPuts
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Walks a stream of BlockedStalledChange entries and totals up the value-add (active) time between
|
|
4
|
+
# issue_start and end_time. It's handed the already-computed stream and the resolved start/end so it
|
|
5
|
+
# never reaches back through the issue's collaborators.
|
|
6
|
+
#
|
|
7
|
+
# Returns [value_add_seconds, total_seconds].
|
|
8
|
+
class FlowEfficiencyCalculator
|
|
9
|
+
def initialize blocked_stalled_changes:, issue_start:, end_time:
|
|
10
|
+
@blocked_stalled_changes = blocked_stalled_changes
|
|
11
|
+
@issue_start = issue_start
|
|
12
|
+
@end_time = end_time
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def calculate
|
|
16
|
+
@active_start = nil
|
|
17
|
+
@value_add_time = 0.0
|
|
18
|
+
|
|
19
|
+
@blocked_stalled_changes.each_with_index do |change, index|
|
|
20
|
+
break if change.time > @end_time
|
|
21
|
+
|
|
22
|
+
process change, index
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
close_final_active_period
|
|
26
|
+
|
|
27
|
+
[@value_add_time, @end_time - @issue_start]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def process change, index
|
|
31
|
+
if index.zero?
|
|
32
|
+
@active_start = change.time if change.active?
|
|
33
|
+
return
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
process_transition change
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def process_transition change
|
|
40
|
+
# Already active and we just got another active.
|
|
41
|
+
return if @active_start && change.active?
|
|
42
|
+
|
|
43
|
+
if change.active?
|
|
44
|
+
@active_start = change.time
|
|
45
|
+
elsif @active_start && change.time >= @issue_start
|
|
46
|
+
# Not active now but we have been. Record the active time.
|
|
47
|
+
record_active_period ending_at: change.time
|
|
48
|
+
@active_start = nil
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def record_active_period ending_at:
|
|
53
|
+
@value_add_time += ending_at - [@issue_start, @active_start].max
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def close_final_active_period
|
|
57
|
+
return unless @active_start
|
|
58
|
+
|
|
59
|
+
change_delta = @end_time - [@issue_start, @active_start].max
|
|
60
|
+
@value_add_time += change_delta if change_delta.positive?
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -7,7 +7,8 @@ class FlowEfficiencyScatterplot < ChartBase
|
|
|
7
7
|
|
|
8
8
|
attr_accessor :possible_statuses
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
# Long only because of the inline description_text heredoc and one-time setup; splitting wouldn't help.
|
|
11
|
+
def initialize block # rubocop:disable Metrics/MethodLength
|
|
11
12
|
super()
|
|
12
13
|
|
|
13
14
|
header_text 'Flow Efficiency'
|
|
@@ -63,7 +64,8 @@ class FlowEfficiencyScatterplot < ChartBase
|
|
|
63
64
|
end
|
|
64
65
|
|
|
65
66
|
if data_sets.empty?
|
|
66
|
-
return "<h1 class='foldable'>#{@header_text}</h1
|
|
67
|
+
return "<h1 class='foldable'>#{@header_text}</h1>" \
|
|
68
|
+
'<div>No data matched the selected criteria. Nothing to show.</div>'
|
|
67
69
|
end
|
|
68
70
|
|
|
69
71
|
wrap_and_render(binding, __FILE__)
|
|
@@ -28,12 +28,27 @@ class GithubGateway
|
|
|
28
28
|
|
|
29
29
|
def fetch_pull_requests since: nil
|
|
30
30
|
raw_prs = @raw_pr_cache[[@repo, since]] ||= fetch_raw_pull_requests(since: since)
|
|
31
|
+
verify_repo_reachable_when_empty(raw_prs, since: since)
|
|
31
32
|
prefetch_commit_messages(raw_prs)
|
|
32
33
|
raw_prs.filter_map { |pr| build_pr_data(pr) }
|
|
33
34
|
end
|
|
34
35
|
|
|
36
|
+
# `gh pr list --search` (the path taken whenever a `since` date is set) goes through GitHub's
|
|
37
|
+
# search API, which returns an empty result with a SUCCESS status for a repo we can't actually
|
|
38
|
+
# access, rather than erroring the way a direct repo query does. Downstream that empty would
|
|
39
|
+
# overwrite previously downloaded PR data. So when a search comes back empty, confirm the repo is
|
|
40
|
+
# genuinely reachable; if it isn't, raise so the caller treats it as a failed download and keeps
|
|
41
|
+
# the existing data instead of replacing it with nothing.
|
|
42
|
+
def verify_repo_reachable_when_empty raw_prs, since:
|
|
43
|
+
return unless since && raw_prs.empty?
|
|
44
|
+
return if repo_reachable?
|
|
45
|
+
|
|
46
|
+
raise "GitHub returned no pull requests for #{@repo} and the repository is not reachable " \
|
|
47
|
+
'(check your access to it and the URL in github_repos)'
|
|
48
|
+
end
|
|
49
|
+
|
|
35
50
|
def fetch_raw_pull_requests since: nil
|
|
36
|
-
# NOTE: 'commits' is intentionally excluded
|
|
51
|
+
# NOTE: 'commits' is intentionally excluded - including it triggers GitHub's GraphQL node
|
|
37
52
|
# limit (authors sub-connection × PRs × commits exceeds 500,000 nodes). Branch name,
|
|
38
53
|
# title, and body are sufficient for issue key extraction in the vast majority of cases.
|
|
39
54
|
json_fields = %w[number title body headRefName createdAt closedAt mergedAt
|
|
@@ -139,14 +154,18 @@ class GithubGateway
|
|
|
139
154
|
# fallback fetches the complete set rather than us caching a partial answer.
|
|
140
155
|
next if commits['totalCount'] && commits['totalCount'] > nodes.size
|
|
141
156
|
|
|
142
|
-
messages_by_number[number] = nodes
|
|
143
|
-
commit = node['commit'] || {}
|
|
144
|
-
[commit['messageHeadline'], commit['messageBody']].compact
|
|
145
|
-
end
|
|
157
|
+
messages_by_number[number] = commit_messages_from(nodes)
|
|
146
158
|
end
|
|
147
159
|
messages_by_number
|
|
148
160
|
end
|
|
149
161
|
|
|
162
|
+
def commit_messages_from nodes
|
|
163
|
+
nodes.flat_map do |node|
|
|
164
|
+
commit = node['commit'] || {}
|
|
165
|
+
[commit['messageHeadline'], commit['messageBody']].compact
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
150
169
|
def owner_and_name
|
|
151
170
|
# @repo may be a full URL (https://github.com/owner/name.git) or an owner/name slug.
|
|
152
171
|
@repo.sub(%r{\Ahttps?://[^/]+/}, '').delete_suffix('.git').split('/', 2)
|
|
@@ -173,12 +192,31 @@ class GithubGateway
|
|
|
173
192
|
Regexp.new("\\b(?:#{keys_pattern})-\\d+(?![A-Za-z0-9])")
|
|
174
193
|
end
|
|
175
194
|
|
|
195
|
+
# Cached per repo (reachability doesn't depend on the `since` window) so we probe at most once even
|
|
196
|
+
# when several projects share the same repo. Uses key? rather than ||= because a false verdict must
|
|
197
|
+
# stick and not trigger a re-probe on every project.
|
|
198
|
+
def repo_reachable?
|
|
199
|
+
key = [@repo, :reachable]
|
|
200
|
+
return @raw_pr_cache[key] if @raw_pr_cache.key?(key)
|
|
201
|
+
|
|
202
|
+
@raw_pr_cache[key] = probe_repo_reachable
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def probe_repo_reachable
|
|
206
|
+
# This is expected to fail for an inaccessible repo, and the caller turns that into a single
|
|
207
|
+
# actionable warning, so suppress run_command's own failure warning to avoid stacking messages.
|
|
208
|
+
run_command(['repo', 'view', @repo, '--json', 'name'], warn_on_failure: false)
|
|
209
|
+
true
|
|
210
|
+
rescue StandardError
|
|
211
|
+
false
|
|
212
|
+
end
|
|
213
|
+
|
|
176
214
|
def monotonic_time
|
|
177
215
|
# In its own method so we can mock it out in tests
|
|
178
216
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
179
217
|
end
|
|
180
218
|
|
|
181
|
-
def run_command args
|
|
219
|
+
def run_command args, warn_on_failure: true
|
|
182
220
|
attempts = 0
|
|
183
221
|
loop do
|
|
184
222
|
attempts += 1
|
|
@@ -195,6 +233,11 @@ class GithubGateway
|
|
|
195
233
|
unless status.success?
|
|
196
234
|
error_message = " GitHub CLI command failed for #{@repo} " \
|
|
197
235
|
"(attempt #{attempts}/#{MAX_RETRIES}): #{stderr.strip}"
|
|
236
|
+
# stderr is a String (from Open3.capture3), so this include? is String#include? - a substring
|
|
237
|
+
# match ("does the error text contain this phrase?"), not Array membership. Style/ArrayIntersect
|
|
238
|
+
# can't tell the two apart; its `.intersect?(stderr)` rewrite would be an exact-equality check
|
|
239
|
+
# that never matches, silently killing the retry-on-transient-error logic.
|
|
240
|
+
# rubocop:disable Style/ArrayIntersect
|
|
198
241
|
if attempts < MAX_RETRIES && TRANSIENT_ERROR_PATTERNS.any? { |pattern| stderr.include?(pattern) }
|
|
199
242
|
delay = 2**attempts
|
|
200
243
|
@file_system.log error_message
|
|
@@ -202,15 +245,12 @@ class GithubGateway
|
|
|
202
245
|
sleep delay
|
|
203
246
|
next
|
|
204
247
|
end
|
|
205
|
-
|
|
248
|
+
# rubocop:enable Style/ArrayIntersect
|
|
249
|
+
@file_system.warning error_message if warn_on_failure
|
|
206
250
|
raise "GitHub CLI command failed for #{@repo}: #{stderr}"
|
|
207
251
|
end
|
|
208
252
|
|
|
209
|
-
|
|
210
|
-
if result.nil? || (result.is_a?(Array) && result.empty?)
|
|
211
|
-
@file_system.warning "No data was found in GitHub for #{@repo}. Is that what you expected?"
|
|
212
|
-
end
|
|
213
|
-
return result
|
|
253
|
+
return JSON.parse(stdout)
|
|
214
254
|
end
|
|
215
255
|
end
|
|
216
256
|
end
|
|
@@ -4,6 +4,9 @@ require 'jirametrics/rules'
|
|
|
4
4
|
require 'jirametrics/grouping_rules'
|
|
5
5
|
|
|
6
6
|
module GroupableIssueChart
|
|
7
|
+
attr_accessor :issue_hints, :issue_periods
|
|
8
|
+
attr_reader :group_by_block
|
|
9
|
+
|
|
7
10
|
def init_configuration_block user_provided_block, &default_block
|
|
8
11
|
instance_eval(&user_provided_block)
|
|
9
12
|
instance_eval(&default_block) unless @group_by_block
|
|
@@ -67,7 +67,7 @@ if (!Chart.Tooltip.positioners.legendItem) {
|
|
|
67
67
|
const endX = chart.scales.x.getPixelForValue(new Date(win.end_date).getTime());
|
|
68
68
|
|
|
69
69
|
// Draw hatched slices over the correction window.
|
|
70
|
-
// For stacked line charts, PointElement has no .base
|
|
70
|
+
// For stacked line charts, PointElement has no .base - derive the band bottom from the
|
|
71
71
|
// dataset directly below in the visual stack (dataset_index - 1, since datasets are
|
|
72
72
|
// stored reversed), or chart.chartArea.bottom for the lowest dataset.
|
|
73
73
|
// Use a trapezoid clip path per slice so hatching stays within the actual band boundary
|
|
@@ -330,7 +330,7 @@ if (!Chart.Tooltip.positioners.legendItem) {
|
|
|
330
330
|
const parent = canvas.parentNode;
|
|
331
331
|
if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative';
|
|
332
332
|
// Read canvas.offsetTop/Left after parent is positioned so they are
|
|
333
|
-
// relative to parent
|
|
333
|
+
// relative to parent - this accounts for the label/checkbox above the
|
|
334
334
|
// canvas that would otherwise shift the overlay upward.
|
|
335
335
|
const overlay = document.createElement('canvas');
|
|
336
336
|
overlay.width = canvas.width;
|
|
@@ -426,7 +426,7 @@ if (!Chart.Tooltip.positioners.legendItem) {
|
|
|
426
426
|
const fm = chart._flowMetrics;
|
|
427
427
|
if (!fm) return;
|
|
428
428
|
drawTrendLines(chart, fm);
|
|
429
|
-
// Triangle is on the overlay canvas
|
|
429
|
+
// Triangle is on the overlay canvas - no drawing needed here.
|
|
430
430
|
}
|
|
431
431
|
};
|
|
432
432
|
})();
|