jirametrics 2.10 → 2.30
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/bin/jirametrics-mcp +5 -0
- data/lib/jirametrics/aggregate_config.rb +10 -2
- data/lib/jirametrics/aging_work_bar_chart.rb +191 -133
- data/lib/jirametrics/aging_work_in_progress_chart.rb +138 -42
- data/lib/jirametrics/aging_work_table.rb +62 -17
- data/lib/jirametrics/anonymizer.rb +81 -6
- data/lib/jirametrics/atlassian_document_format.rb +160 -0
- data/lib/jirametrics/bar_chart_range.rb +17 -0
- data/lib/jirametrics/blocked_stalled_change.rb +5 -3
- data/lib/jirametrics/board.rb +63 -11
- data/lib/jirametrics/board_config.rb +5 -1
- data/lib/jirametrics/board_feature.rb +14 -0
- data/lib/jirametrics/board_movement_calculator.rb +155 -0
- data/lib/jirametrics/cfd_data_builder.rb +108 -0
- data/lib/jirametrics/change_item.rb +49 -19
- data/lib/jirametrics/chart_base.rb +147 -7
- data/lib/jirametrics/css_variable.rb +2 -2
- data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
- data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +22 -5
- data/lib/jirametrics/cycletime_histogram.rb +15 -101
- data/lib/jirametrics/cycletime_scatterplot.rb +17 -83
- data/lib/jirametrics/daily_view.rb +306 -0
- data/lib/jirametrics/daily_wip_by_age_chart.rb +4 -5
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +14 -4
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
- data/lib/jirametrics/daily_wip_chart.rb +30 -8
- data/lib/jirametrics/data_quality_report.rb +43 -12
- data/lib/jirametrics/dependency_chart.rb +6 -3
- data/lib/jirametrics/download_config.rb +15 -0
- data/lib/jirametrics/downloader.rb +128 -71
- data/lib/jirametrics/downloader_for_cloud.rb +287 -0
- data/lib/jirametrics/downloader_for_data_center.rb +95 -0
- data/lib/jirametrics/estimate_accuracy_chart.rb +74 -12
- data/lib/jirametrics/estimation_configuration.rb +25 -0
- data/lib/jirametrics/examples/aggregated_project.rb +2 -2
- data/lib/jirametrics/examples/standard_project.rb +42 -27
- data/lib/jirametrics/expedited_chart.rb +3 -1
- data/lib/jirametrics/exporter.rb +28 -8
- data/lib/jirametrics/file_config.rb +10 -12
- data/lib/jirametrics/file_system.rb +59 -3
- data/lib/jirametrics/fix_version.rb +13 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +6 -2
- data/lib/jirametrics/github_gateway.rb +115 -0
- data/lib/jirametrics/groupable_issue_chart.rb +11 -1
- data/lib/jirametrics/grouping_rules.rb +26 -4
- data/lib/jirametrics/html/aging_work_bar_chart.erb +5 -5
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +24 -5
- data/lib/jirametrics/html/aging_work_table.erb +12 -3
- data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +40 -5
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
- data/lib/jirametrics/html/expedited_chart.erb +6 -14
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
- data/lib/jirametrics/html/index.css +323 -63
- data/lib/jirametrics/html/index.erb +17 -19
- data/lib/jirametrics/html/index.js +164 -0
- data/lib/jirametrics/html/legacy_colors.css +174 -0
- data/lib/jirametrics/html/sprint_burndown.erb +17 -15
- data/lib/jirametrics/html/throughput_chart.erb +42 -11
- data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +15 -11
- data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
- data/lib/jirametrics/html_generator.rb +32 -0
- data/lib/jirametrics/html_report_config.rb +52 -55
- data/lib/jirametrics/issue.rb +347 -103
- data/lib/jirametrics/issue_collection.rb +33 -0
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +81 -14
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +151 -18
- data/lib/jirametrics/pull_request.rb +30 -0
- data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
- data/lib/jirametrics/pull_request_review.rb +13 -0
- data/lib/jirametrics/raw_javascript.rb +17 -0
- data/lib/jirametrics/settings.json +6 -1
- data/lib/jirametrics/sprint.rb +13 -0
- data/lib/jirametrics/sprint_burndown.rb +45 -37
- data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
- data/lib/jirametrics/status.rb +3 -0
- data/lib/jirametrics/status_collection.rb +7 -0
- data/lib/jirametrics/stitcher.rb +81 -0
- data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
- data/lib/jirametrics/throughput_chart.rb +73 -23
- data/lib/jirametrics/time_based_histogram.rb +139 -0
- data/lib/jirametrics/time_based_scatterplot.rb +107 -0
- data/lib/jirametrics/user.rb +12 -0
- data/lib/jirametrics/wip_by_column_chart.rb +236 -0
- data/lib/jirametrics.rb +83 -64
- metadata +66 -6
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class IssuePrinter
|
|
4
|
+
def initialize issue
|
|
5
|
+
@issue = issue
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def to_s
|
|
9
|
+
issue = @issue
|
|
10
|
+
result = +''
|
|
11
|
+
result << "#{issue.key} (#{issue.type}): #{issue.compact_text issue.summary, max: 200}\n"
|
|
12
|
+
|
|
13
|
+
assignee = issue.raw['fields']['assignee']
|
|
14
|
+
result << " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n" unless assignee.nil?
|
|
15
|
+
|
|
16
|
+
issue.raw['fields']['issuelinks']&.each do |link|
|
|
17
|
+
result << " [link] #{link['type']['outward']} #{link['outwardIssue']['key']}\n" if link['outwardIssue']
|
|
18
|
+
result << " [link] #{link['type']['inward']} #{link['inwardIssue']['key']}\n" if link['inwardIssue']
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
history = [] # time, type, detail
|
|
22
|
+
|
|
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
|
|
30
|
+
|
|
31
|
+
issue.discarded_change_times&.each do |time|
|
|
32
|
+
history << [time, nil, '^^^^ Changes discarded ^^^^', true]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
(issue.changes + (issue.discarded_changes || [])).each do |change|
|
|
36
|
+
history << [change.time, change.field, create_change_message(change: change, issue: issue), change.artificial?]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
result << " History:\n"
|
|
40
|
+
type_width = history.collect { |_time, type, _detail, _artificial| type&.length || 0 }.max
|
|
41
|
+
sort_history!(history)
|
|
42
|
+
history.each do |time, type, detail, _artificial|
|
|
43
|
+
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
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def create_change_message change:, issue:
|
|
51
|
+
value, old_value = format_change_values(change: change, issue: issue)
|
|
52
|
+
|
|
53
|
+
message = +''
|
|
54
|
+
message << "#{old_value} -> " unless old_value.nil? || old_value.empty?
|
|
55
|
+
message << value
|
|
56
|
+
if change.artificial?
|
|
57
|
+
message << ' (Artificial entry)'
|
|
58
|
+
else
|
|
59
|
+
message << " (Author: #{change.author})"
|
|
60
|
+
end
|
|
61
|
+
message
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def format_change_values change:, issue:
|
|
65
|
+
if change.status?
|
|
66
|
+
value = "#{change.value.inspect}:#{change.value_id.inspect}"
|
|
67
|
+
old_value = change.old_value ? "#{change.old_value.inspect}:#{change.old_value_id.inspect}" : nil
|
|
68
|
+
elsif change.sprint?
|
|
69
|
+
added = change.value_id - change.old_value_id
|
|
70
|
+
removed = change.old_value_id - change.value_id
|
|
71
|
+
value = "#{change.value.inspect} #{change.value_id}"
|
|
72
|
+
value << " (added: #{added})" unless added.empty?
|
|
73
|
+
value << " (removed: #{removed})" unless removed.empty?
|
|
74
|
+
old_value = nil
|
|
75
|
+
else
|
|
76
|
+
value = issue.compact_text(change.value).inspect
|
|
77
|
+
old_value = change.old_value ? issue.compact_text(change.old_value).inspect : nil
|
|
78
|
+
end
|
|
79
|
+
[value, old_value]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def sort_history! history
|
|
83
|
+
history.sort! do |a, b|
|
|
84
|
+
if a[0] == b[0]
|
|
85
|
+
if a[1].nil?
|
|
86
|
+
1
|
|
87
|
+
elsif b[1].nil?
|
|
88
|
+
-1
|
|
89
|
+
else
|
|
90
|
+
a[1] <=> b[1]
|
|
91
|
+
end
|
|
92
|
+
else
|
|
93
|
+
a[0] <=> b[0]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -3,21 +3,83 @@
|
|
|
3
3
|
require 'cgi'
|
|
4
4
|
require 'json'
|
|
5
5
|
require 'English'
|
|
6
|
+
require 'open3'
|
|
6
7
|
|
|
7
8
|
class JiraGateway
|
|
8
|
-
attr_accessor :ignore_ssl_errors
|
|
9
|
+
attr_accessor :ignore_ssl_errors
|
|
10
|
+
attr_reader :jira_url, :settings, :file_system
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
RETRYABLE_EXIT_CODES = [7, 28, 35, 56].freeze
|
|
13
|
+
MAX_RETRIES = 3
|
|
14
|
+
RETRY_DELAY_SECONDS = 5
|
|
15
|
+
|
|
16
|
+
def initialize file_system:, jira_config:, settings:
|
|
11
17
|
@file_system = file_system
|
|
18
|
+
load_jira_config(jira_config)
|
|
19
|
+
@settings = settings
|
|
20
|
+
@ignore_ssl_errors = settings['ignore_ssl_errors']
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def post_request relative_url:, payload:
|
|
24
|
+
command = make_curl_command url: "#{@jira_url}#{relative_url}", method: 'POST'
|
|
25
|
+
exec_and_parse_response command: command, stdin_data: payload
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def exec_and_parse_response command:, stdin_data:
|
|
29
|
+
log_entry = " #{command.gsub(/\s+/, ' ')}"
|
|
30
|
+
log_entry = sanitize_message log_entry
|
|
31
|
+
@file_system.log log_entry
|
|
32
|
+
|
|
33
|
+
retries = 0
|
|
34
|
+
loop do
|
|
35
|
+
stdout, stderr, status = capture3(command, stdin_data: stdin_data)
|
|
36
|
+
|
|
37
|
+
if status.success?
|
|
38
|
+
@file_system.log "Returned (stderr): #{stderr.inspect}" unless stderr == ''
|
|
39
|
+
raise 'no response from curl on stdout' if stdout == ''
|
|
40
|
+
return parse_response(command: command, result: stdout)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if RETRYABLE_EXIT_CODES.include?(status.exitstatus) && retries < MAX_RETRIES
|
|
44
|
+
retries += 1
|
|
45
|
+
@file_system.log "Transient network error (exit #{status.exitstatus}), retrying in #{RETRY_DELAY_SECONDS}s (attempt #{retries}/#{MAX_RETRIES})..."
|
|
46
|
+
sleep_between_retries
|
|
47
|
+
next
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
@file_system.error "Failed call with exit status #{status.exitstatus}!"
|
|
51
|
+
@file_system.error "Returned (stdout): #{stdout.inspect}"
|
|
52
|
+
@file_system.error "Returned (stderr): #{stderr.inspect}"
|
|
53
|
+
if stderr.include?('401')
|
|
54
|
+
raise 'The request was not authorized. Verify that your authentication token hasn\'t expired'
|
|
55
|
+
end
|
|
56
|
+
raise "Failed call with exit status #{status.exitstatus}. " \
|
|
57
|
+
"See #{@file_system.logfile_name} for details"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def capture3 command, stdin_data:
|
|
62
|
+
# In it's own method so we can mock it out in tests
|
|
63
|
+
Open3.capture3(command, stdin_data: stdin_data)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def sleep_between_retries
|
|
67
|
+
# In its own method so we can mock it out in tests
|
|
68
|
+
sleep RETRY_DELAY_SECONDS
|
|
12
69
|
end
|
|
13
70
|
|
|
14
71
|
def call_url relative_url:
|
|
15
72
|
command = make_curl_command url: "#{@jira_url}#{relative_url}"
|
|
16
|
-
|
|
73
|
+
exec_and_parse_response command: command, stdin_data: nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def parse_response command:, result:
|
|
17
77
|
begin
|
|
18
78
|
json = JSON.parse(result)
|
|
19
79
|
rescue # rubocop:disable Style/RescueStandardError
|
|
20
|
-
|
|
80
|
+
message = "Unable to parse results from #{sanitize_message(command)}"
|
|
81
|
+
@file_system.error message, more: result
|
|
82
|
+
raise message
|
|
21
83
|
end
|
|
22
84
|
|
|
23
85
|
raise "Download failed with: #{JSON.pretty_generate(json)}" unless json_successful?(json)
|
|
@@ -25,15 +87,11 @@ class JiraGateway
|
|
|
25
87
|
json
|
|
26
88
|
end
|
|
27
89
|
|
|
28
|
-
def
|
|
29
|
-
@
|
|
30
|
-
|
|
31
|
-
@file_system.log result unless $CHILD_STATUS.success?
|
|
32
|
-
return result if $CHILD_STATUS.success?
|
|
90
|
+
def sanitize_message message
|
|
91
|
+
token = @jira_api_token || @jira_personal_access_token
|
|
92
|
+
return message unless token # cookie based authentication
|
|
33
93
|
|
|
34
|
-
|
|
35
|
-
raise "Failed call with exit status #{$CHILD_STATUS.exitstatus}. " \
|
|
36
|
-
"See #{@file_system.logfile_name} for details"
|
|
94
|
+
message.gsub(token, '[API_TOKEN]')
|
|
37
95
|
end
|
|
38
96
|
|
|
39
97
|
def load_jira_config jira_config
|
|
@@ -53,7 +111,7 @@ class JiraGateway
|
|
|
53
111
|
@cookies = (jira_config['cookies'] || []).collect { |key, value| "#{key}=#{value}" }.join(';')
|
|
54
112
|
end
|
|
55
113
|
|
|
56
|
-
def make_curl_command url:
|
|
114
|
+
def make_curl_command url:, method: 'GET'
|
|
57
115
|
command = +''
|
|
58
116
|
command << 'curl'
|
|
59
117
|
command << ' -L' # follow redirects
|
|
@@ -62,8 +120,13 @@ class JiraGateway
|
|
|
62
120
|
command << " --cookie #{@cookies.inspect}" unless @cookies.empty?
|
|
63
121
|
command << " --user #{@jira_email}:#{@jira_api_token}" if @jira_api_token
|
|
64
122
|
command << " -H \"Authorization: Bearer #{@jira_personal_access_token}\"" if @jira_personal_access_token
|
|
65
|
-
command <<
|
|
123
|
+
command << " --request #{method}"
|
|
124
|
+
if method == 'POST'
|
|
125
|
+
command << ' --data @-'
|
|
126
|
+
command << ' --header "Content-Type: application/json"'
|
|
127
|
+
end
|
|
66
128
|
command << ' --header "Accept: application/json"'
|
|
129
|
+
command << ' --show-error --fail' # Better diagnostics when the server returns an error
|
|
67
130
|
command << " --url \"#{url}\""
|
|
68
131
|
command
|
|
69
132
|
end
|
|
@@ -74,4 +137,8 @@ class JiraGateway
|
|
|
74
137
|
|
|
75
138
|
true
|
|
76
139
|
end
|
|
140
|
+
|
|
141
|
+
def cloud?
|
|
142
|
+
@jira_url.downcase.end_with? '.atlassian.net'
|
|
143
|
+
end
|
|
77
144
|
end
|