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.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/bin/jirametrics-mcp +5 -0
  3. data/lib/jirametrics/aggregate_config.rb +10 -2
  4. data/lib/jirametrics/aging_work_bar_chart.rb +191 -133
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +138 -42
  6. data/lib/jirametrics/aging_work_table.rb +62 -17
  7. data/lib/jirametrics/anonymizer.rb +81 -6
  8. data/lib/jirametrics/atlassian_document_format.rb +160 -0
  9. data/lib/jirametrics/bar_chart_range.rb +17 -0
  10. data/lib/jirametrics/blocked_stalled_change.rb +5 -3
  11. data/lib/jirametrics/board.rb +63 -11
  12. data/lib/jirametrics/board_config.rb +5 -1
  13. data/lib/jirametrics/board_feature.rb +14 -0
  14. data/lib/jirametrics/board_movement_calculator.rb +155 -0
  15. data/lib/jirametrics/cfd_data_builder.rb +108 -0
  16. data/lib/jirametrics/change_item.rb +49 -19
  17. data/lib/jirametrics/chart_base.rb +147 -7
  18. data/lib/jirametrics/css_variable.rb +2 -2
  19. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  20. data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +22 -5
  21. data/lib/jirametrics/cycletime_histogram.rb +15 -101
  22. data/lib/jirametrics/cycletime_scatterplot.rb +17 -83
  23. data/lib/jirametrics/daily_view.rb +306 -0
  24. data/lib/jirametrics/daily_wip_by_age_chart.rb +4 -5
  25. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +14 -4
  26. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
  27. data/lib/jirametrics/daily_wip_chart.rb +30 -8
  28. data/lib/jirametrics/data_quality_report.rb +43 -12
  29. data/lib/jirametrics/dependency_chart.rb +6 -3
  30. data/lib/jirametrics/download_config.rb +15 -0
  31. data/lib/jirametrics/downloader.rb +128 -71
  32. data/lib/jirametrics/downloader_for_cloud.rb +287 -0
  33. data/lib/jirametrics/downloader_for_data_center.rb +95 -0
  34. data/lib/jirametrics/estimate_accuracy_chart.rb +74 -12
  35. data/lib/jirametrics/estimation_configuration.rb +25 -0
  36. data/lib/jirametrics/examples/aggregated_project.rb +2 -2
  37. data/lib/jirametrics/examples/standard_project.rb +42 -27
  38. data/lib/jirametrics/expedited_chart.rb +3 -1
  39. data/lib/jirametrics/exporter.rb +28 -8
  40. data/lib/jirametrics/file_config.rb +10 -12
  41. data/lib/jirametrics/file_system.rb +59 -3
  42. data/lib/jirametrics/fix_version.rb +13 -0
  43. data/lib/jirametrics/flow_efficiency_scatterplot.rb +6 -2
  44. data/lib/jirametrics/github_gateway.rb +115 -0
  45. data/lib/jirametrics/groupable_issue_chart.rb +11 -1
  46. data/lib/jirametrics/grouping_rules.rb +26 -4
  47. data/lib/jirametrics/html/aging_work_bar_chart.erb +5 -5
  48. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +24 -5
  49. data/lib/jirametrics/html/aging_work_table.erb +12 -3
  50. data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
  51. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  52. data/lib/jirametrics/html/daily_wip_chart.erb +40 -5
  53. data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
  54. data/lib/jirametrics/html/expedited_chart.erb +6 -14
  55. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
  56. data/lib/jirametrics/html/index.css +323 -63
  57. data/lib/jirametrics/html/index.erb +17 -19
  58. data/lib/jirametrics/html/index.js +164 -0
  59. data/lib/jirametrics/html/legacy_colors.css +174 -0
  60. data/lib/jirametrics/html/sprint_burndown.erb +17 -15
  61. data/lib/jirametrics/html/throughput_chart.erb +42 -11
  62. data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
  63. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +15 -11
  64. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  65. data/lib/jirametrics/html_generator.rb +32 -0
  66. data/lib/jirametrics/html_report_config.rb +52 -55
  67. data/lib/jirametrics/issue.rb +347 -103
  68. data/lib/jirametrics/issue_collection.rb +33 -0
  69. data/lib/jirametrics/issue_printer.rb +97 -0
  70. data/lib/jirametrics/jira_gateway.rb +81 -14
  71. data/lib/jirametrics/mcp_server.rb +531 -0
  72. data/lib/jirametrics/project_config.rb +151 -18
  73. data/lib/jirametrics/pull_request.rb +30 -0
  74. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  75. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  76. data/lib/jirametrics/pull_request_review.rb +13 -0
  77. data/lib/jirametrics/raw_javascript.rb +17 -0
  78. data/lib/jirametrics/settings.json +6 -1
  79. data/lib/jirametrics/sprint.rb +13 -0
  80. data/lib/jirametrics/sprint_burndown.rb +45 -37
  81. data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
  82. data/lib/jirametrics/status.rb +3 -0
  83. data/lib/jirametrics/status_collection.rb +7 -0
  84. data/lib/jirametrics/stitcher.rb +81 -0
  85. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  86. data/lib/jirametrics/throughput_chart.rb +73 -23
  87. data/lib/jirametrics/time_based_histogram.rb +139 -0
  88. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  89. data/lib/jirametrics/user.rb +12 -0
  90. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  91. data/lib/jirametrics.rb +83 -64
  92. 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, :jira_url
9
+ attr_accessor :ignore_ssl_errors
10
+ attr_reader :jira_url, :settings, :file_system
9
11
 
10
- def initialize file_system:
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
- result = call_command command
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
- raise "Error when parsing result: #{result.inspect}"
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 call_command command
29
- @file_system.log " #{command.gsub(/\s+/, ' ')}"
30
- result = `#{command}`
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
- @file_system.log "Failed call with exit status #{$CHILD_STATUS.exitstatus}."
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 << ' --request GET'
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