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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8411199c48209ed18008a509523bcd28c7d22c1145b492137ac5219972f35245
4
- data.tar.gz: 3074f95dfc4b401408e615d71b957f5e5626514a6172de5855ec80a15a9bc82a
3
+ metadata.gz: 39b2d5045ef29e8ea0b33e8b9134ba66a564a6c92f0c607f4bcc706512659393
4
+ data.tar.gz: 1b40c073695046589fe0d8ca996d0337eb81fda0ffb44ac7875f238369a88d11
5
5
  SHA512:
6
- metadata.gz: 1b7dc5f5abea1401da94464b80cb8b346f503055d5df92bac596af7c48b3021d4deb641af804710186a964f4d750b6d03a542cb9236c852b12e174887ba2def9
7
- data.tar.gz: d9b6de864972518ae2a3ab91c92fc4eba3d411232b252afb31c041ea9575cfa1cebee4085211d0d1eb091335240da5a13038622765dc0ef3fd8c73f874ee4222
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 a lookup
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 perceptually distinct under the most common forms of colour blindness.
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 a bottleneck. Parallel top edges
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. To understand the accuracy of the chart, we have to understand how accurate the
32
- initial data was and also how much of the original data set was used in the chart. This section
33
- will hopefully give you enough information to make that decision.
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 = @download_config.github_repos.flat_map do |repo|
344
- GithubGateway.new(
345
- repo: repo,
346
- project_keys: project_keys,
347
- file_system: @file_system,
348
- raw_pr_cache: @github_pr_cache
349
- ).fetch_pull_requests(since: @download_date_range&.begin)
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 seen when
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 they may reference related
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 but log it so we can diagnose later if an export fails because a
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] }
@@ -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 it hasn't been created yet (it depends on this logfile).
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 including it triggers GitHub's GraphQL node
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? a substring
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 parse_command_result(stdout)
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 derive the band bottom from the
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 this accounts for the label/checkbox above the
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 no drawing needed here.
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 shared by the media query and the forced dark theme */
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 distinct from blocked */
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 overrides the dark media query when user explicitly picks light */
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 distinct from blocked */
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
 
@@ -177,27 +177,8 @@ class Issue
177
177
  end
178
178
 
179
179
  def find_or_create_status id:, name:
180
- status = board.possible_statuses.find_by_id(id)
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 a status change must have a 'from' id but Jira has
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 "Failed call with exit status #{status.exitstatus}. " \
75
- "See #{@file_system.logfile_name} for details"
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 the answer changes the result significantly.'
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 = in_progress_category if category.nil?
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}' suggested maximum: #{rec}"
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 ie entering or leaving WIP.
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
- load_config options[:config]
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jirametrics
3
3
  version: !ruby/object:Gem::Version
4
- version: '3.0'
4
+ version: '3.1'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Bowler