jirametrics 3.0 → 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/atlassian_document_format.rb +1 -1
- data/lib/jirametrics/chart_base.rb +1 -1
- data/lib/jirametrics/cumulative_flow_diagram.rb +1 -1
- data/lib/jirametrics/data_quality_report.rb +9 -3
- data/lib/jirametrics/downloader.rb +31 -7
- data/lib/jirametrics/downloader_for_cloud.rb +3 -3
- data/lib/jirametrics/exporter.rb +184 -2
- data/lib/jirametrics/github_gateway.rb +39 -13
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +3 -3
- data/lib/jirametrics/html/index.css +4 -4
- data/lib/jirametrics/issue.rb +3 -22
- data/lib/jirametrics/jira_gateway.rb +29 -2
- data/lib/jirametrics/mcp_server.rb +1 -1
- data/lib/jirametrics/project_config.rb +49 -0
- data/lib/jirametrics/status_collection.rb +27 -3
- data/lib/jirametrics/wip_by_column_chart.rb +3 -3
- data/lib/jirametrics.rb +29 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 39b2d5045ef29e8ea0b33e8b9134ba66a564a6c92f0c607f4bcc706512659393
|
|
4
|
+
data.tar.gz: 1b40c073695046589fe0d8ca996d0337eb81fda0ffb44ac7875f238369a88d11
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e614e62715f71e657557718fbebf5174199b46b30b3f9fa93de230862830d4a902336ef28262e30fe77082e244222ca823f4c41314d4ac424a98c8524540a0da
|
|
7
|
+
data.tar.gz: bba299cd94305c9bd0cac2f271235eca287b44b33a77e7e86597aecb662373e71b82b91a074b1ba1e773a4011620a7335cf33b1540810ffe283b89be6554b18f
|
|
@@ -39,7 +39,7 @@ class AtlassianDocumentFormat
|
|
|
39
39
|
# https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/
|
|
40
40
|
#
|
|
41
41
|
# Flat node-type dispatch: ~25 trivial branches mapping an ADF node type to its [prefix, suffix]
|
|
42
|
-
# pair (adf_node_render does the recursion). The complexity here is breadth, not depth
|
|
42
|
+
# pair (adf_node_render does the recursion). The complexity here is breadth, not depth - a lookup
|
|
43
43
|
# table would only trade a scannable case for a hash plus indirection, so we keep the case.
|
|
44
44
|
def adf_node_to_html node # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
|
|
45
45
|
adf_node_render(node) do |n|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class ChartBase
|
|
4
|
-
# Okabe-Ito palette
|
|
4
|
+
# Okabe-Ito palette - perceptually distinct under the most common forms of colour blindness.
|
|
5
5
|
# Ordered from most- to least-commonly useful for chart series.
|
|
6
6
|
OKABE_ITO_PALETTE = %w[
|
|
7
7
|
#0072B2
|
|
@@ -46,7 +46,7 @@ class CumulativeFlowDiagram < ChartBase
|
|
|
46
46
|
total work entered; the top edge of the rightmost band shows total work completed.
|
|
47
47
|
</div>
|
|
48
48
|
<div class="p">
|
|
49
|
-
A widening band means work is piling up in that stage
|
|
49
|
+
A widening band means work is piling up in that stage - a bottleneck. Parallel top edges
|
|
50
50
|
(bands staying the same width) indicate smooth flow. Steep rises in the leftmost band
|
|
51
51
|
without corresponding rises on the right mean new work is arriving faster than it is
|
|
52
52
|
being finished.
|
|
@@ -28,9 +28,15 @@ class DataQualityReport < ChartBase
|
|
|
28
28
|
description_text <<-HTML
|
|
29
29
|
<p>
|
|
30
30
|
We have a tendency to assume that anything we see in a chart is 100% accurate, although that's
|
|
31
|
-
not always true.
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
not always true. The accuracy of a chart depends both on how good the underlying data was and
|
|
32
|
+
on how much of that data actually made it into the chart.
|
|
33
|
+
</p>
|
|
34
|
+
<p>
|
|
35
|
+
The items highlighted below are not necessarily bad data. Some are genuine contradictions worth
|
|
36
|
+
fixing in Jira, such as an item finishing before it started. But many are simply places where the
|
|
37
|
+
way your team actually works bumps up against the simplified start-to-finish model this tool uses
|
|
38
|
+
to compute flow metrics. Those aren't mistakes; we flag them so you can read the charts with the
|
|
39
|
+
right context, not because anyone did anything wrong.
|
|
34
40
|
</p>
|
|
35
41
|
HTML
|
|
36
42
|
end
|
|
@@ -340,21 +340,45 @@ class Downloader
|
|
|
340
340
|
return
|
|
341
341
|
end
|
|
342
342
|
|
|
343
|
-
prs =
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
343
|
+
prs = []
|
|
344
|
+
any_repo_failed = false
|
|
345
|
+
@download_config.github_repos.each do |repo|
|
|
346
|
+
prs.concat(
|
|
347
|
+
GithubGateway.new(
|
|
348
|
+
repo: repo,
|
|
349
|
+
project_keys: project_keys,
|
|
350
|
+
file_system: @file_system,
|
|
351
|
+
raw_pr_cache: @github_pr_cache
|
|
352
|
+
).fetch_pull_requests(since: @download_date_range&.begin)
|
|
353
|
+
)
|
|
354
|
+
rescue StandardError => e
|
|
355
|
+
any_repo_failed = true
|
|
356
|
+
warn_about_failed_pr_download(repo, e)
|
|
350
357
|
end
|
|
351
358
|
|
|
359
|
+
# A successful-but-empty fetch is a legitimate result and gets saved. But if any repo raised, we
|
|
360
|
+
# can't produce a complete file, so we leave the previously downloaded data untouched.
|
|
361
|
+
return if any_repo_failed
|
|
362
|
+
|
|
352
363
|
@file_system.save_json(
|
|
353
364
|
json: prs.map(&:raw),
|
|
354
365
|
filename: File.join(@target_path, "#{file_prefix}_github_prs.json")
|
|
355
366
|
)
|
|
356
367
|
end
|
|
357
368
|
|
|
369
|
+
# The same github_repos are downloaded for every project, so an unreachable repo would otherwise
|
|
370
|
+
# warn once per project (dozens of identical messages). The github_pr_cache is shared across all
|
|
371
|
+
# projects in a run, so we use it to warn about each repo at most once.
|
|
372
|
+
def warn_about_failed_pr_download repo, error
|
|
373
|
+
warned_key = [repo, :download_failure_warned]
|
|
374
|
+
return if @github_pr_cache[warned_key]
|
|
375
|
+
|
|
376
|
+
@github_pr_cache[warned_key] = true
|
|
377
|
+
@file_system.warning "Failed to download pull requests from #{repo}: #{error.message}. " \
|
|
378
|
+
'Left the existing PR data in place rather than overwriting it with an incomplete set. ' \
|
|
379
|
+
'Fix the access problem (often a `gh auth login` / repo-access issue) and re-run download to refresh it.'
|
|
380
|
+
end
|
|
381
|
+
|
|
358
382
|
def extract_project_keys_from_downloaded_issues
|
|
359
383
|
path = File.join(@target_path, "#{file_prefix}_issues")
|
|
360
384
|
return [] unless @file_system.dir_exist?(path)
|
|
@@ -168,7 +168,7 @@ class DownloaderForCloud < Downloader
|
|
|
168
168
|
log " #{key} worklogs: page startAt=#{start_at}, " \
|
|
169
169
|
"received=#{worklogs.size}, fetched=#{all_worklogs.size}/#{total}"
|
|
170
170
|
break if all_worklogs.size >= total
|
|
171
|
-
# Guard against Jira reporting a higher total than it will actually return
|
|
171
|
+
# Guard against Jira reporting a higher total than it will actually return - seen when
|
|
172
172
|
# worklogs are deleted or access-restricted after the initial fetch. Without this,
|
|
173
173
|
# start_at never advances and we loop forever requesting the same empty page.
|
|
174
174
|
break if worklogs.empty?
|
|
@@ -344,7 +344,7 @@ class DownloaderForCloud < Downloader
|
|
|
344
344
|
end
|
|
345
345
|
end
|
|
346
346
|
|
|
347
|
-
# Scan up-to-date cached primary issues we haven't checked yet
|
|
347
|
+
# Scan up-to-date cached primary issues we haven't checked yet - they may reference related
|
|
348
348
|
# issues that are not in the primary query result. We only follow links one hop out from the
|
|
349
349
|
# primary issues, so related (non-primary) cached issues are not followed (just logged).
|
|
350
350
|
def scan_cached_issues_for_related issue_data_hash:, board:, checked_for_related:, related_issue_keys:
|
|
@@ -400,7 +400,7 @@ class DownloaderForCloud < Downloader
|
|
|
400
400
|
|
|
401
401
|
# We only follow links one hop out from the primary (board) issues. If a related issue
|
|
402
402
|
# itself references further issues we haven't already downloaded, we deliberately don't
|
|
403
|
-
# follow them
|
|
403
|
+
# follow them - but log it so we can diagnose later if an export fails because a
|
|
404
404
|
# second-hop issue was missing. See GitHub #72.
|
|
405
405
|
def log_unfollowed_related_keys issue:, issue_data_hash:
|
|
406
406
|
onward = related_keys_for(issue).reject { |key| issue_data_hash[key] }
|
data/lib/jirametrics/exporter.rb
CHANGED
|
@@ -6,13 +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
|
-
logfile_name = 'jirametrics.log'
|
|
11
20
|
# No block form: FileSystem holds this descriptor open for the whole run and writes to it as we
|
|
12
21
|
# go, so it must outlive this method.
|
|
13
22
|
logfile = File.open(logfile_name, 'w') # rubocop:disable Style/FileOpen
|
|
14
23
|
rescue Errno::EACCES
|
|
15
|
-
# FileSystem can't be used here
|
|
24
|
+
# FileSystem can't be used here - it hasn't been created yet (it depends on this logfile).
|
|
16
25
|
warn "Error: Cannot write to #{File.expand_path(logfile_name)}. " \
|
|
17
26
|
'Please ensure the current directory is writable.'
|
|
18
27
|
exit 1
|
|
@@ -73,6 +82,179 @@ class Exporter
|
|
|
73
82
|
puts "Full output from downloader in #{file_system.logfile_name}"
|
|
74
83
|
end
|
|
75
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
|
+
|
|
76
258
|
def info key, name_filter:
|
|
77
259
|
selected = []
|
|
78
260
|
file_system.log_only = true
|
|
@@ -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
|
|
@@ -177,12 +192,31 @@ class GithubGateway
|
|
|
177
192
|
Regexp.new("\\b(?:#{keys_pattern})-\\d+(?![A-Za-z0-9])")
|
|
178
193
|
end
|
|
179
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
|
+
|
|
180
214
|
def monotonic_time
|
|
181
215
|
# In its own method so we can mock it out in tests
|
|
182
216
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
183
217
|
end
|
|
184
218
|
|
|
185
|
-
def run_command args
|
|
219
|
+
def run_command args, warn_on_failure: true
|
|
186
220
|
attempts = 0
|
|
187
221
|
loop do
|
|
188
222
|
attempts += 1
|
|
@@ -199,7 +233,7 @@ class GithubGateway
|
|
|
199
233
|
unless status.success?
|
|
200
234
|
error_message = " GitHub CLI command failed for #{@repo} " \
|
|
201
235
|
"(attempt #{attempts}/#{MAX_RETRIES}): #{stderr.strip}"
|
|
202
|
-
# stderr is a String (from Open3.capture3), so this include? is String#include?
|
|
236
|
+
# stderr is a String (from Open3.capture3), so this include? is String#include? - a substring
|
|
203
237
|
# match ("does the error text contain this phrase?"), not Array membership. Style/ArrayIntersect
|
|
204
238
|
# can't tell the two apart; its `.intersect?(stderr)` rewrite would be an exact-equality check
|
|
205
239
|
# that never matches, silently killing the retry-on-transient-error logic.
|
|
@@ -212,19 +246,11 @@ class GithubGateway
|
|
|
212
246
|
next
|
|
213
247
|
end
|
|
214
248
|
# rubocop:enable Style/ArrayIntersect
|
|
215
|
-
@file_system.warning error_message
|
|
249
|
+
@file_system.warning error_message if warn_on_failure
|
|
216
250
|
raise "GitHub CLI command failed for #{@repo}: #{stderr}"
|
|
217
251
|
end
|
|
218
252
|
|
|
219
|
-
return
|
|
220
|
-
end
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
def parse_command_result stdout
|
|
224
|
-
result = JSON.parse(stdout)
|
|
225
|
-
if result.nil? || (result.is_a?(Array) && result.empty?)
|
|
226
|
-
@file_system.warning "No data was found in GitHub for #{@repo}. Is that what you expected?"
|
|
253
|
+
return JSON.parse(stdout)
|
|
227
254
|
end
|
|
228
|
-
result
|
|
229
255
|
end
|
|
230
256
|
end
|
|
@@ -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
|
})();
|
|
@@ -228,7 +228,7 @@ div.child_issue {
|
|
|
228
228
|
padding: 0.5em;
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
-
/* Dark CSS variables
|
|
231
|
+
/* Dark CSS variables - shared by the media query and the forced dark theme */
|
|
232
232
|
html[data-theme="dark"] {
|
|
233
233
|
--warning-banner: #9F2B00;
|
|
234
234
|
--non-working-days-color: #2f2f2f;
|
|
@@ -240,7 +240,7 @@ html[data-theme="dark"] {
|
|
|
240
240
|
--grid-line-color: #424242;
|
|
241
241
|
--expedited-color: #E69F00; /* Okabe-Ito orange for dark bg */
|
|
242
242
|
--blocked-color: #E69F00; /* Okabe-Ito orange for dark bg */
|
|
243
|
-
--stalled-color: #F0E442; /* Okabe-Ito yellow
|
|
243
|
+
--stalled-color: #F0E442; /* Okabe-Ito yellow - distinct from blocked */
|
|
244
244
|
--wip-chart-active-color: #56B4E9; /* sky blue for dark bg */
|
|
245
245
|
--status-category-inprogress-color: #56B4E9; /* sky blue for dark bg */
|
|
246
246
|
--status-category-done-color: #2DCB9A; /* lighter bluish green for dark bg */
|
|
@@ -284,7 +284,7 @@ html[data-theme="dark"] {
|
|
|
284
284
|
}
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
-
/* Force light mode
|
|
287
|
+
/* Force light mode - overrides the dark media query when user explicitly picks light */
|
|
288
288
|
html[data-theme="light"] {
|
|
289
289
|
--warning-banner: yellow;
|
|
290
290
|
--non-working-days-color: #F0F0F0;
|
|
@@ -380,7 +380,7 @@ html[data-theme="light"] {
|
|
|
380
380
|
|
|
381
381
|
--expedited-color: #E69F00; /* Okabe-Ito orange for dark bg */
|
|
382
382
|
--blocked-color: #E69F00; /* Okabe-Ito orange for dark bg */
|
|
383
|
-
--stalled-color: #F0E442; /* Okabe-Ito yellow
|
|
383
|
+
--stalled-color: #F0E442; /* Okabe-Ito yellow - distinct from blocked */
|
|
384
384
|
--dead-color: black;
|
|
385
385
|
--wip-chart-active-color: #56B4E9; /* sky blue for dark bg */
|
|
386
386
|
|
data/lib/jirametrics/issue.rb
CHANGED
|
@@ -177,27 +177,8 @@ class Issue
|
|
|
177
177
|
end
|
|
178
178
|
|
|
179
179
|
def find_or_create_status id:, name:
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
unless status
|
|
183
|
-
# Have to pull this list before the call to fabricate or else the warning will incorrectly
|
|
184
|
-
# list this status as one it actually found
|
|
185
|
-
found_statuses = board.possible_statuses.to_s
|
|
186
|
-
|
|
187
|
-
status = board.possible_statuses.fabricate_status_for id: id, name: name
|
|
188
|
-
|
|
189
|
-
message = +'The history for issue '
|
|
190
|
-
message << key
|
|
191
|
-
message << ' references the status ('
|
|
192
|
-
message << "#{name.inspect}:#{id.inspect}"
|
|
193
|
-
message << ') that can\'t be found. We are guessing that this belongs to the '
|
|
194
|
-
message << status.category.to_s
|
|
195
|
-
message << ' status category but that may be wrong. See https://jirametrics.org/faq/#q1 for more '
|
|
196
|
-
message << 'details on defining statuses.'
|
|
197
|
-
board.project_config.file_system.warning message, more: "The statuses we did find are: #{found_statuses}"
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
status
|
|
180
|
+
board.possible_statuses.find_by_id(id) ||
|
|
181
|
+
board.possible_statuses.fabricate_status_for(id: id, name: name, example_issue_key: key)
|
|
201
182
|
end
|
|
202
183
|
|
|
203
184
|
def first_status_change_after_created
|
|
@@ -831,7 +812,7 @@ class Issue
|
|
|
831
812
|
else
|
|
832
813
|
# Otherwise, we look at what the first one had changed away from.
|
|
833
814
|
first_status = first_change.old_value
|
|
834
|
-
# old_value_id should never be nil
|
|
815
|
+
# old_value_id should never be nil - a status change must have a 'from' id - but Jira has
|
|
835
816
|
# been seen in production omitting the 'from' field entirely. Fall back to 0 so the
|
|
836
817
|
# downstream fabricate/warn path handles it rather than crashing.
|
|
837
818
|
first_status_id = first_change.old_value_id || 0
|
|
@@ -71,8 +71,20 @@ class JiraGateway
|
|
|
71
71
|
'Jira account may have been deactivated due to inactivity. Check your Jira subscription ' \
|
|
72
72
|
'and try again later.'
|
|
73
73
|
end
|
|
74
|
-
raise "
|
|
75
|
-
|
|
74
|
+
raise "#{failure_summary(status: status, stderr: stderr)}. See #{@file_system.logfile_name} for details"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Turn curl's opaque exit code into something self-describing: prefer the HTTP status Jira
|
|
78
|
+
# returned, then curl's own error text (e.g. "Could not resolve host"), falling back to the exit
|
|
79
|
+
# code only when neither is present.
|
|
80
|
+
def failure_summary status:, stderr:
|
|
81
|
+
http_status = stderr[/returned error: (\d{3})/, 1]
|
|
82
|
+
return "Jira returned HTTP #{http_status}" if http_status
|
|
83
|
+
|
|
84
|
+
curl_message = stderr[/curl: \(\d+\) (.+)/, 1]
|
|
85
|
+
return "Jira call failed: #{curl_message.strip}" if curl_message
|
|
86
|
+
|
|
87
|
+
"Failed call with exit status #{status.exitstatus}"
|
|
76
88
|
end
|
|
77
89
|
|
|
78
90
|
def capture3 command, stdin_data:
|
|
@@ -95,6 +107,21 @@ class JiraGateway
|
|
|
95
107
|
exec_and_parse_response command: command, stdin_data: nil
|
|
96
108
|
end
|
|
97
109
|
|
|
110
|
+
VerifyResult = Data.define(:ok, :url, :message)
|
|
111
|
+
|
|
112
|
+
# Confirm that our credentials actually authenticate against Jira. We deliberately hit /myself,
|
|
113
|
+
# which requires authentication on every Jira edition (anonymous callers get a 401). An endpoint
|
|
114
|
+
# that also answers anonymously would happily "succeed" with bad credentials, which defeats the
|
|
115
|
+
# whole point. Cloud speaks v3, Server/Data Center speaks v2. Returns a VerifyResult rather than
|
|
116
|
+
# raising so the caller can report on every configured connection and set its own exit status.
|
|
117
|
+
def verify_connection
|
|
118
|
+
json = call_url relative_url: "/rest/api/#{cloud? ? 3 : 2}/myself"
|
|
119
|
+
user = json['displayName'] || json['name'] || json['emailAddress'] || 'unknown user'
|
|
120
|
+
VerifyResult.new(ok: true, url: @jira_url, message: "Verified #{@jira_url} (authenticated as #{user})")
|
|
121
|
+
rescue StandardError => e
|
|
122
|
+
VerifyResult.new(ok: false, url: @jira_url, message: "Could not authenticate to #{@jira_url}: #{e.message}")
|
|
123
|
+
end
|
|
124
|
+
|
|
98
125
|
def parse_response command:, result:
|
|
99
126
|
begin
|
|
100
127
|
json = JSON.parse(result)
|
|
@@ -461,7 +461,7 @@ class McpServer
|
|
|
461
461
|
description 'Aggregates the time issues spend in each status or column, ranked by average days. ' \
|
|
462
462
|
'Useful for identifying bottlenecks. Before calling this tool, always ask the user ' \
|
|
463
463
|
'which issues they want to include: aging (in progress), completed, not yet started, ' \
|
|
464
|
-
'or all. Do not assume
|
|
464
|
+
'or all. Do not assume - the answer changes the result significantly.'
|
|
465
465
|
|
|
466
466
|
input_schema(
|
|
467
467
|
type: 'object',
|
|
@@ -56,6 +56,55 @@ class ProjectConfig
|
|
|
56
56
|
anonymize_data if @anonymizer_needed
|
|
57
57
|
|
|
58
58
|
@file_configs.each(&:run)
|
|
59
|
+
|
|
60
|
+
# By this point the charts have walked the issue histories, so every deleted status has already
|
|
61
|
+
# been fabricated lazily. Just report them. (The mcp path has no chart phase, so it resolves them
|
|
62
|
+
# explicitly before reporting.)
|
|
63
|
+
report_fabricated_statuses
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Walk every issue's status history so that any status that no longer exists in Jira gets fabricated
|
|
67
|
+
# now. Fabrication is otherwise lazy (it happens the first time a status is touched, which for some
|
|
68
|
+
# code paths is mid-render), so this gives us a single deterministic point where we know they all
|
|
69
|
+
# exist before we report them.
|
|
70
|
+
def resolve_all_status_changes
|
|
71
|
+
issues.each do |issue|
|
|
72
|
+
issue.status_changes.each do |change|
|
|
73
|
+
issue.find_or_create_status id: change.value_id, name: change.value
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Print one consolidated warning covering every status we had to fabricate (a status that an issue
|
|
79
|
+
# passed through but that no longer exists in Jira). It fires often -- any workflow rework leaves
|
|
80
|
+
# these behind -- so we say the explanation once and hand over a single ready-to-paste block of
|
|
81
|
+
# mappings rather than one repetitive paragraph per status.
|
|
82
|
+
def report_fabricated_statuses
|
|
83
|
+
statuses = possible_statuses.fabricated_statuses
|
|
84
|
+
return if statuses.empty? || @fabricated_statuses_reported
|
|
85
|
+
|
|
86
|
+
@fabricated_statuses_reported = true
|
|
87
|
+
|
|
88
|
+
width = statuses.map { |status| "'#{status.name}:#{status.id}'".length }.max
|
|
89
|
+
mappings = statuses.map do |status|
|
|
90
|
+
" #{"'#{status.name}:#{status.id}'".ljust(width)} => '#{status.category.name}',"
|
|
91
|
+
end.join("\n")
|
|
92
|
+
|
|
93
|
+
example_key = possible_statuses.example_issue_key
|
|
94
|
+
info_line =
|
|
95
|
+
if example_key
|
|
96
|
+
"\nRun `jirametrics info #{example_key}` on an affected issue to see how it moved through its workflow."
|
|
97
|
+
else
|
|
98
|
+
''
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
file_system.warning(
|
|
102
|
+
'Some statuses in your issue history no longer exist in Jira (they were probably deleted). ' \
|
|
103
|
+
"We've guessed each one's category from its name. If a guess is right the mapping just confirms " \
|
|
104
|
+
'it and silences this warning; if it is wrong, change the category. Add these to your ' \
|
|
105
|
+
"config's status category mappings; with standard_project that's:\n" \
|
|
106
|
+
" status_category_mappings: {\n#{mappings}\n }\n#{info_line}"
|
|
107
|
+
)
|
|
59
108
|
end
|
|
60
109
|
|
|
61
110
|
def load_settings
|
|
@@ -4,11 +4,13 @@ class StatusNotFoundError < StandardError
|
|
|
4
4
|
end
|
|
5
5
|
|
|
6
6
|
class StatusCollection
|
|
7
|
-
attr_reader :historical_status_mappings
|
|
7
|
+
attr_reader :historical_status_mappings, :fabricated_statuses, :example_issue_key
|
|
8
8
|
|
|
9
9
|
def initialize
|
|
10
10
|
@list = []
|
|
11
11
|
@historical_status_mappings = {} # 'name:id' => category
|
|
12
|
+
@fabricated_statuses = []
|
|
13
|
+
@example_issue_key = nil
|
|
12
14
|
end
|
|
13
15
|
|
|
14
16
|
# Return the status matching this id or nil if it can't be found.
|
|
@@ -88,9 +90,10 @@ class StatusCollection
|
|
|
88
90
|
"StatusCollection#{self}"
|
|
89
91
|
end
|
|
90
92
|
|
|
91
|
-
def fabricate_status_for id:, name:
|
|
93
|
+
def fabricate_status_for id:, name:, example_issue_key: nil
|
|
94
|
+
@example_issue_key ||= example_issue_key
|
|
92
95
|
category = @historical_status_mappings["#{name.inspect}:#{id.inspect}"]
|
|
93
|
-
category =
|
|
96
|
+
category = guess_category_by_name(name) if category.nil?
|
|
94
97
|
|
|
95
98
|
status = Status.new(
|
|
96
99
|
name: name,
|
|
@@ -101,11 +104,32 @@ class StatusCollection
|
|
|
101
104
|
artificial: true
|
|
102
105
|
)
|
|
103
106
|
@list << status
|
|
107
|
+
@fabricated_statuses << status
|
|
104
108
|
status
|
|
105
109
|
end
|
|
106
110
|
|
|
107
111
|
private
|
|
108
112
|
|
|
113
|
+
# Guess the category of a deleted status from its name rather than always assuming In Progress. In
|
|
114
|
+
# English, "To Do"/"New"/"Backlog" are almost certainly To Do and "Done"/"Closed"/"Cancelled" are
|
|
115
|
+
# almost certainly Done; everything else we treat as In Progress. It's only a guess -- the user
|
|
116
|
+
# should still map anything that matters -- but it beats defaulting every deleted status to In
|
|
117
|
+
# Progress. Falls back to In Progress if the board has no status of the guessed category to borrow.
|
|
118
|
+
def guess_category_by_name name
|
|
119
|
+
normalized = name.to_s.downcase
|
|
120
|
+
todo = category_matching(:new?)
|
|
121
|
+
return todo if todo && normalized.match?(/\b(to.?do|new|backlog)\b/)
|
|
122
|
+
|
|
123
|
+
done = category_matching(:done?)
|
|
124
|
+
return done if done && normalized.match?(/\b(done|closed|cancell?ed)\b/)
|
|
125
|
+
|
|
126
|
+
in_progress_category
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def category_matching predicate
|
|
130
|
+
find { |status| status.category.public_send(predicate) }&.category
|
|
131
|
+
end
|
|
132
|
+
|
|
109
133
|
# Return the in-progress category or raise an error if we can't find one.
|
|
110
134
|
def in_progress_category
|
|
111
135
|
first_in_progress_status = find { |s| s.category.indeterminate? }
|
|
@@ -18,7 +18,7 @@ class WipByColumnChart < ChartBase
|
|
|
18
18
|
<p>
|
|
19
19
|
Each row on the Y axis is a WIP level (the number of items in that column at the same time).
|
|
20
20
|
Each column on the X axis is a board column.
|
|
21
|
-
The horizontal bars show what percentage of the total time that column spent at that WIP level
|
|
21
|
+
The horizontal bars show what percentage of the total time that column spent at that WIP level -
|
|
22
22
|
a wider bar means more time was spent there.
|
|
23
23
|
</p>
|
|
24
24
|
<p>
|
|
@@ -136,7 +136,7 @@ class WipByColumnChart < ChartBase
|
|
|
136
136
|
|
|
137
137
|
max = @wip_limits[i]['max']
|
|
138
138
|
if max.nil?
|
|
139
|
-
"Add a WIP limit to column '#{name}'
|
|
139
|
+
"Add a WIP limit to column '#{name}' - suggested maximum: #{rec}"
|
|
140
140
|
elsif rec < max
|
|
141
141
|
"Lower the WIP limit for '#{name}' from #{max} to #{rec}"
|
|
142
142
|
elsif rec > max
|
|
@@ -267,7 +267,7 @@ class WipByColumnChart < ChartBase
|
|
|
267
267
|
end
|
|
268
268
|
|
|
269
269
|
# Move the issue out of its old column and into new_col, keeping wip_counts and current_column in
|
|
270
|
-
# sync. A nil column means the issue isn't on the board
|
|
270
|
+
# sync. A nil column means the issue isn't on the board - ie entering or leaving WIP.
|
|
271
271
|
def apply_event wip_counts, current_column, issue, new_col
|
|
272
272
|
old_col = current_column[issue]
|
|
273
273
|
wip_counts[old_col] -= 1 unless old_col.nil?
|
data/lib/jirametrics.rb
CHANGED
|
@@ -46,6 +46,23 @@ class JiraMetrics < Thor
|
|
|
46
46
|
Exporter.instance.export(name_filter: options[:name] || '*')
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
+
option :config
|
|
50
|
+
option :name
|
|
51
|
+
desc 'verify', 'Check that JiraMetrics can authenticate to Jira, without downloading anything'
|
|
52
|
+
def verify
|
|
53
|
+
load_config options[:config]
|
|
54
|
+
results = Exporter.instance.verify_jira_connections(name_filter: options[:name] || '*')
|
|
55
|
+
exit 1 if results.empty? || results.any? { |result| !result.ok }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
option :config
|
|
59
|
+
option :name
|
|
60
|
+
desc 'boards', "Show a board's columns and statuses, to help choose cycletime start/stop points"
|
|
61
|
+
def boards board_id = nil
|
|
62
|
+
load_config options[:config]
|
|
63
|
+
exit 1 unless Exporter.instance.boards(board_id: board_id, name_filter: options[:name])
|
|
64
|
+
end
|
|
65
|
+
|
|
49
66
|
option :config
|
|
50
67
|
desc 'info', 'Dump information about one issue'
|
|
51
68
|
def info key
|
|
@@ -63,7 +80,9 @@ class JiraMetrics < Thor
|
|
|
63
80
|
original_stdout = $stdout.dup
|
|
64
81
|
$stdout.reopen($stderr)
|
|
65
82
|
|
|
66
|
-
|
|
83
|
+
# Log to a separate file so that starting the MCP server doesn't truncate jirametrics.log, which
|
|
84
|
+
# someone may be relying on to debug a preceding export run.
|
|
85
|
+
load_config options[:config], logfile_name: 'jirametrics-mcp.log'
|
|
67
86
|
require 'jirametrics/mcp_server'
|
|
68
87
|
|
|
69
88
|
Exporter.instance.file_system.log_only = true
|
|
@@ -78,6 +97,10 @@ class JiraMetrics < Thor
|
|
|
78
97
|
today: project.time_range.end.to_date,
|
|
79
98
|
end_time: project.time_range.end
|
|
80
99
|
}
|
|
100
|
+
# Fabrication is lazy, so force every historical status to resolve now and then emit the single
|
|
101
|
+
# consolidated warning into jirametrics-mcp.log (file_system is log_only here).
|
|
102
|
+
project.resolve_all_status_changes
|
|
103
|
+
project.report_fabricated_statuses
|
|
81
104
|
rescue StandardError => e
|
|
82
105
|
if e.message.start_with? 'This is an aggregated project'
|
|
83
106
|
names = project.aggregate_project_names
|
|
@@ -122,7 +145,7 @@ class JiraMetrics < Thor
|
|
|
122
145
|
at_exit { JiraMetrics.log_uncaught_exception $ERROR_INFO }
|
|
123
146
|
|
|
124
147
|
no_commands do
|
|
125
|
-
def load_config config_file, file_system: FileSystem.new
|
|
148
|
+
def load_config config_file, logfile_name: nil, file_system: FileSystem.new
|
|
126
149
|
config_file = './config.rb' if config_file.nil?
|
|
127
150
|
|
|
128
151
|
if file_system.file_exist? config_file
|
|
@@ -135,6 +158,10 @@ class JiraMetrics < Thor
|
|
|
135
158
|
end
|
|
136
159
|
|
|
137
160
|
require_rel 'jirametrics'
|
|
161
|
+
# Set only after the require above, so Exporter is defined. The config file we load below calls
|
|
162
|
+
# Exporter.configure, which opens this log; the MCP server passes its own name so it doesn't
|
|
163
|
+
# truncate jirametrics.log.
|
|
164
|
+
Exporter.logfile_name = logfile_name if logfile_name
|
|
138
165
|
load config_file
|
|
139
166
|
end
|
|
140
167
|
end
|