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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +42 -29
  3. data/lib/jirametrics/aging_work_bar_chart.rb +52 -40
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +65 -60
  5. data/lib/jirametrics/aging_work_table.rb +36 -33
  6. data/lib/jirametrics/anonymizer.rb +31 -86
  7. data/lib/jirametrics/atlassian_document_format.rb +11 -4
  8. data/lib/jirametrics/blocked_stalled_by_date_builder.rb +64 -0
  9. data/lib/jirametrics/blocked_stalled_change.rb +5 -1
  10. data/lib/jirametrics/blocked_stalled_change_stream_builder.rb +194 -0
  11. data/lib/jirametrics/board.rb +23 -9
  12. data/lib/jirametrics/board_config.rb +0 -7
  13. data/lib/jirametrics/board_movement_calculator.rb +24 -21
  14. data/lib/jirametrics/cfd_data_builder.rb +46 -44
  15. data/lib/jirametrics/change_item.rb +19 -7
  16. data/lib/jirametrics/chart_base.rb +51 -65
  17. data/lib/jirametrics/cumulative_flow_diagram.rb +60 -47
  18. data/lib/jirametrics/cycle_time_config.rb +35 -46
  19. data/lib/jirametrics/cycletime_histogram.rb +7 -0
  20. data/lib/jirametrics/cycletime_scatterplot.rb +7 -0
  21. data/lib/jirametrics/daily_view.rb +83 -61
  22. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +24 -9
  23. data/lib/jirametrics/daily_wip_chart.rb +72 -45
  24. data/lib/jirametrics/data_quality_report.rb +70 -66
  25. data/lib/jirametrics/dependency_chart.rb +40 -31
  26. data/lib/jirametrics/download_config.rb +0 -3
  27. data/lib/jirametrics/downloader.rb +39 -14
  28. data/lib/jirametrics/downloader_for_cloud.rb +110 -74
  29. data/lib/jirametrics/estimate_accuracy_chart.rb +13 -13
  30. data/lib/jirametrics/examples/standard_project.rb +2 -2
  31. data/lib/jirametrics/expedited_chart.rb +47 -37
  32. data/lib/jirametrics/exporter.rb +203 -12
  33. data/lib/jirametrics/file_config.rb +19 -12
  34. data/lib/jirametrics/file_system.rb +16 -10
  35. data/lib/jirametrics/flow_efficiency_calculator.rb +62 -0
  36. data/lib/jirametrics/flow_efficiency_scatterplot.rb +4 -2
  37. data/lib/jirametrics/github_gateway.rb +52 -12
  38. data/lib/jirametrics/groupable_issue_chart.rb +3 -0
  39. data/lib/jirametrics/html/cumulative_flow_diagram.erb +3 -3
  40. data/lib/jirametrics/html/index.css +10 -4
  41. data/lib/jirametrics/html_report_config.rb +9 -29
  42. data/lib/jirametrics/issue.rb +293 -411
  43. data/lib/jirametrics/issue_collection.rb +1 -0
  44. data/lib/jirametrics/issue_printer.rb +60 -23
  45. data/lib/jirametrics/jira_gateway.rb +56 -18
  46. data/lib/jirametrics/mcp_server.rb +226 -210
  47. data/lib/jirametrics/project_config.rb +206 -133
  48. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +1 -20
  49. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +11 -33
  50. data/lib/jirametrics/rules.rb +1 -0
  51. data/lib/jirametrics/self_or_issue_dispatcher.rb +1 -3
  52. data/lib/jirametrics/sprint_burndown.rb +142 -242
  53. data/lib/jirametrics/sprint_count_measure.rb +42 -0
  54. data/lib/jirametrics/sprint_issue_change_data.rb +1 -0
  55. data/lib/jirametrics/sprint_points_measure.rb +62 -0
  56. data/lib/jirametrics/sprint_summary_stats.rb +16 -0
  57. data/lib/jirametrics/status_collection.rb +29 -5
  58. data/lib/jirametrics/stitcher.rb +21 -16
  59. data/lib/jirametrics/throughput_chart.rb +38 -24
  60. data/lib/jirametrics/time_based_chart.rb +65 -0
  61. data/lib/jirametrics/time_based_histogram.rb +30 -23
  62. data/lib/jirametrics/time_based_scatterplot.rb +11 -2
  63. data/lib/jirametrics/trend_line_calculator.rb +2 -2
  64. data/lib/jirametrics/wip_by_column_chart.rb +75 -34
  65. data/lib/jirametrics.rb +35 -4
  66. metadata +13 -20
@@ -27,6 +27,7 @@ class IssueCollection < Array
27
27
  issue = hidden.find(&block) if issue.nil? && include_hidden
28
28
  issue
29
29
  end
30
+
30
31
  def clone
31
32
  raise 'baboom'
32
33
  end
@@ -6,45 +6,82 @@ class IssuePrinter
6
6
  end
7
7
 
8
8
  def to_s
9
- issue = @issue
10
9
  result = +''
11
- result << "#{issue.key} (#{issue.type}): #{issue.compact_text issue.summary, max: 200}\n"
10
+ result << header_line
11
+ result << assignee_line
12
+ result << links_section
13
+ result << history_section
14
+ result
15
+ end
16
+
17
+ def header_line
18
+ "#{@issue.key} (#{@issue.type}): #{@issue.compact_text @issue.summary, max: 200}\n"
19
+ end
12
20
 
13
- assignee = issue.raw['fields']['assignee']
14
- result << " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n" unless assignee.nil?
21
+ def assignee_line
22
+ assignee = @issue.raw['fields']['assignee']
23
+ return '' if assignee.nil?
15
24
 
16
- issue.raw['fields']['issuelinks']&.each do |link|
25
+ " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n"
26
+ end
27
+
28
+ def links_section
29
+ result = +''
30
+ @issue.raw['fields']['issuelinks']&.each do |link|
17
31
  result << " [link] #{link['type']['outward']} #{link['outwardIssue']['key']}\n" if link['outwardIssue']
18
32
  result << " [link] #{link['type']['inward']} #{link['inwardIssue']['key']}\n" if link['inwardIssue']
19
33
  end
34
+ result
35
+ end
20
36
 
21
- history = [] # time, type, detail
37
+ def history_section
38
+ result = +''
39
+ result << cycletime_warning
40
+ result << " History:\n"
41
+ result << render_history(build_history)
42
+ result
43
+ end
22
44
 
23
- if issue.board.cycletime
24
- started_at, stopped_at = issue.started_stopped_times
25
- history << [started_at, nil, 'vvvv Started here vvvv', true] if started_at
26
- history << [stopped_at, nil, '^^^^ Finished here ^^^^', true] if stopped_at
27
- else
28
- result << " Unable to determine start/end times as board #{issue.board.id} has no cycletime specified\n"
29
- end
45
+ def cycletime_warning
46
+ return '' if @issue.board.cycletime
47
+
48
+ " Unable to determine start/end times as board #{@issue.board.id} has no cycletime specified\n"
49
+ end
50
+
51
+ # Each history entry is [time, type, detail, artificial?].
52
+ def build_history
53
+ start_stop_entries + discarded_change_entries + change_entries
54
+ end
55
+
56
+ def start_stop_entries
57
+ return [] unless @issue.board.cycletime
58
+
59
+ started_at, stopped_at = @issue.started_stopped_times
60
+ entries = []
61
+ entries << [started_at, nil, 'vvvv Started here vvvv', true] if started_at
62
+ entries << [stopped_at, nil, '^^^^ Finished here ^^^^', true] if stopped_at
63
+ entries
64
+ end
30
65
 
31
- issue.discarded_change_times&.each do |time|
32
- history << [time, nil, '^^^^ Changes discarded ^^^^', true]
66
+ def discarded_change_entries
67
+ (@issue.discarded_change_times || []).map do |time|
68
+ [time, nil, '^^^^ Changes discarded ^^^^', true]
33
69
  end
70
+ end
34
71
 
35
- (issue.changes + (issue.discarded_changes || [])).each do |change|
36
- history << [change.time, change.field, create_change_message(change: change, issue: issue), change.artificial?]
72
+ def change_entries
73
+ (@issue.changes + (@issue.discarded_changes || [])).map do |change|
74
+ [change.time, change.field, create_change_message(change: change, issue: @issue), change.artificial?]
37
75
  end
76
+ end
38
77
 
39
- result << " History:\n"
78
+ def render_history history
40
79
  type_width = history.collect { |_time, type, _detail, _artificial| type&.length || 0 }.max
41
80
  sort_history!(history)
42
- history.each do |time, type, detail, _artificial|
81
+ history.map do |time, type, detail, _artificial|
43
82
  type = type.nil? ? '-' * type_width : type.rjust(type_width)
44
- result << " #{time.strftime '%Y-%m-%d %H:%M:%S %z'} [#{type}] #{detail}\n"
45
- end
46
-
47
- result
83
+ " #{time.strftime '%Y-%m-%d %H:%M:%S %z'} [#{type}] #{detail}\n"
84
+ end.join
48
85
  end
49
86
 
50
87
  def create_change_message change:, issue:
@@ -43,7 +43,7 @@ class JiraGateway
43
43
  return parse_response(command: command, result: stdout)
44
44
  end
45
45
 
46
- if RETRYABLE_EXIT_CODES.include?(status.exitstatus) && retries < MAX_RETRIES && !stderr.include?('503')
46
+ if retryable?(status: status, stderr: stderr, retries: retries)
47
47
  retries += 1
48
48
  @file_system.log "Transient network error (exit #{status.exitstatus}), retrying in " \
49
49
  "#{RETRY_DELAY_SECONDS}s (attempt #{retries}/#{MAX_RETRIES})..."
@@ -51,20 +51,40 @@ class JiraGateway
51
51
  next
52
52
  end
53
53
 
54
- @file_system.error "Failed call with exit status #{status.exitstatus}!"
55
- @file_system.error "Returned (stdout): #{stdout.inspect}"
56
- @file_system.error "Returned (stderr): #{stderr.inspect}"
57
- if stderr.include?('401')
58
- raise 'The request was not authorized. Verify that your authentication token hasn\'t expired'
59
- end
60
- if stderr.include?('503')
61
- raise 'Jira returned 503 (Service Unavailable). This may be a temporary outage, or your ' \
62
- 'Jira account may have been deactivated due to inactivity. Check your Jira subscription ' \
63
- 'and try again later.'
64
- end
65
- raise "Failed call with exit status #{status.exitstatus}. " \
66
- "See #{@file_system.logfile_name} for details"
54
+ raise_for_failure status: status, stdout: stdout, stderr: stderr
55
+ end
56
+ end
57
+
58
+ def retryable? status:, stderr:, retries:
59
+ RETRYABLE_EXIT_CODES.include?(status.exitstatus) && retries < MAX_RETRIES && !stderr.include?('503')
60
+ end
61
+
62
+ def raise_for_failure status:, stdout:, stderr:
63
+ @file_system.error "Failed call with exit status #{status.exitstatus}!"
64
+ @file_system.error "Returned (stdout): #{stdout.inspect}"
65
+ @file_system.error "Returned (stderr): #{stderr.inspect}"
66
+ if stderr.include?('401')
67
+ raise 'The request was not authorized. Verify that your authentication token hasn\'t expired'
68
+ end
69
+ if stderr.include?('503')
70
+ raise 'Jira returned 503 (Service Unavailable). This may be a temporary outage, or your ' \
71
+ 'Jira account may have been deactivated due to inactivity. Check your Jira subscription ' \
72
+ 'and try again later.'
67
73
  end
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}"
68
88
  end
69
89
 
70
90
  def capture3 command, stdin_data:
@@ -87,6 +107,21 @@ class JiraGateway
87
107
  exec_and_parse_response command: command, stdin_data: nil
88
108
  end
89
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
+
90
125
  def parse_response command:, result:
91
126
  begin
92
127
  json = JSON.parse(result)
@@ -115,14 +150,17 @@ class JiraGateway
115
150
  @jira_email = jira_config['email']
116
151
  @jira_api_token = jira_config['api_token']
117
152
  @jira_personal_access_token = jira_config['personal_access_token']
153
+ validate_jira_auth
118
154
 
155
+ @cookies = (jira_config['cookies'] || []).collect { |key, value| "#{key}=#{value}" }.join(';')
156
+ end
157
+
158
+ def validate_jira_auth
119
159
  raise 'When specifying an api-token, you must also specify email' if @jira_api_token && !@jira_email
120
160
 
121
- if @jira_api_token && @jira_personal_access_token
122
- raise "You can't specify both an api-token and a personal-access-token. They don't work together."
123
- end
161
+ return unless @jira_api_token && @jira_personal_access_token
124
162
 
125
- @cookies = (jira_config['cookies'] || []).collect { |key, value| "#{key}=#{value}" }.join(';')
163
+ raise "You can't specify both an api-token and a personal-access-token. They don't work together."
126
164
  end
127
165
 
128
166
  def make_curl_command url:, method: 'GET'