jirametrics 2.31 → 3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/jirametrics/aggregate_config.rb +42 -29
- data/lib/jirametrics/aging_work_bar_chart.rb +52 -40
- data/lib/jirametrics/aging_work_in_progress_chart.rb +65 -60
- data/lib/jirametrics/aging_work_table.rb +36 -33
- data/lib/jirametrics/anonymizer.rb +31 -86
- data/lib/jirametrics/atlassian_document_format.rb +11 -4
- data/lib/jirametrics/blocked_stalled_by_date_builder.rb +64 -0
- data/lib/jirametrics/blocked_stalled_change.rb +5 -1
- data/lib/jirametrics/blocked_stalled_change_stream_builder.rb +194 -0
- data/lib/jirametrics/board.rb +23 -9
- data/lib/jirametrics/board_config.rb +0 -7
- data/lib/jirametrics/board_movement_calculator.rb +24 -21
- data/lib/jirametrics/cfd_data_builder.rb +46 -44
- data/lib/jirametrics/change_item.rb +19 -7
- data/lib/jirametrics/chart_base.rb +51 -65
- data/lib/jirametrics/cumulative_flow_diagram.rb +60 -47
- data/lib/jirametrics/cycle_time_config.rb +35 -46
- data/lib/jirametrics/cycletime_histogram.rb +7 -0
- data/lib/jirametrics/cycletime_scatterplot.rb +7 -0
- data/lib/jirametrics/daily_view.rb +83 -61
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +24 -9
- data/lib/jirametrics/daily_wip_chart.rb +72 -45
- data/lib/jirametrics/data_quality_report.rb +70 -66
- data/lib/jirametrics/dependency_chart.rb +40 -31
- data/lib/jirametrics/download_config.rb +0 -3
- data/lib/jirametrics/downloader.rb +39 -14
- data/lib/jirametrics/downloader_for_cloud.rb +110 -74
- data/lib/jirametrics/estimate_accuracy_chart.rb +13 -13
- data/lib/jirametrics/examples/standard_project.rb +2 -2
- data/lib/jirametrics/expedited_chart.rb +47 -37
- data/lib/jirametrics/exporter.rb +203 -12
- data/lib/jirametrics/file_config.rb +19 -12
- data/lib/jirametrics/file_system.rb +16 -10
- data/lib/jirametrics/flow_efficiency_calculator.rb +62 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +4 -2
- data/lib/jirametrics/github_gateway.rb +52 -12
- data/lib/jirametrics/groupable_issue_chart.rb +3 -0
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +3 -3
- data/lib/jirametrics/html/index.css +10 -4
- data/lib/jirametrics/html_report_config.rb +9 -29
- data/lib/jirametrics/issue.rb +293 -411
- data/lib/jirametrics/issue_collection.rb +1 -0
- data/lib/jirametrics/issue_printer.rb +60 -23
- data/lib/jirametrics/jira_gateway.rb +56 -18
- data/lib/jirametrics/mcp_server.rb +226 -210
- data/lib/jirametrics/project_config.rb +206 -133
- data/lib/jirametrics/pull_request_cycle_time_histogram.rb +1 -20
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +11 -33
- data/lib/jirametrics/rules.rb +1 -0
- data/lib/jirametrics/self_or_issue_dispatcher.rb +1 -3
- data/lib/jirametrics/sprint_burndown.rb +142 -242
- data/lib/jirametrics/sprint_count_measure.rb +42 -0
- data/lib/jirametrics/sprint_issue_change_data.rb +1 -0
- data/lib/jirametrics/sprint_points_measure.rb +62 -0
- data/lib/jirametrics/sprint_summary_stats.rb +16 -0
- data/lib/jirametrics/status_collection.rb +29 -5
- data/lib/jirametrics/stitcher.rb +21 -16
- data/lib/jirametrics/throughput_chart.rb +38 -24
- data/lib/jirametrics/time_based_chart.rb +65 -0
- data/lib/jirametrics/time_based_histogram.rb +30 -23
- data/lib/jirametrics/time_based_scatterplot.rb +11 -2
- data/lib/jirametrics/trend_line_calculator.rb +2 -2
- data/lib/jirametrics/wip_by_column_chart.rb +75 -34
- data/lib/jirametrics.rb +35 -4
- metadata +13 -20
|
@@ -6,45 +6,82 @@ class IssuePrinter
|
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def to_s
|
|
9
|
-
issue = @issue
|
|
10
9
|
result = +''
|
|
11
|
-
result <<
|
|
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
|
-
|
|
14
|
-
|
|
21
|
+
def assignee_line
|
|
22
|
+
assignee = @issue.raw['fields']['assignee']
|
|
23
|
+
return '' if assignee.nil?
|
|
15
24
|
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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.
|
|
81
|
+
history.map do |time, type, detail, _artificial|
|
|
43
82
|
type = type.nil? ? '-' * type_width : type.rjust(type_width)
|
|
44
|
-
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'
|