jirametrics 2.13 → 2.20.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/anonymizer.rb +8 -6
  3. data/lib/jirametrics/atlassian_document_format.rb +8 -4
  4. data/lib/jirametrics/board_config.rb +3 -1
  5. data/lib/jirametrics/change_item.rb +2 -2
  6. data/lib/jirametrics/chart_base.rb +5 -2
  7. data/lib/jirametrics/cycletime_config.rb +22 -3
  8. data/lib/jirametrics/cycletime_histogram.rb +3 -1
  9. data/lib/jirametrics/daily_view.rb +49 -42
  10. data/lib/jirametrics/data_quality_report.rb +6 -3
  11. data/lib/jirametrics/dependency_chart.rb +4 -1
  12. data/lib/jirametrics/downloader.rb +34 -99
  13. data/lib/jirametrics/downloader_for_cloud.rb +202 -0
  14. data/lib/jirametrics/downloader_for_data_center.rb +94 -0
  15. data/lib/jirametrics/examples/standard_project.rb +9 -9
  16. data/lib/jirametrics/expedited_chart.rb +1 -1
  17. data/lib/jirametrics/exporter.rb +10 -5
  18. data/lib/jirametrics/file_system.rb +24 -1
  19. data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
  20. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +1 -1
  21. data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
  22. data/lib/jirametrics/html/cycletime_histogram.erb +2 -2
  23. data/lib/jirametrics/html/index.css +5 -10
  24. data/lib/jirametrics/html/index.erb +2 -34
  25. data/lib/jirametrics/html/index.js +90 -0
  26. data/lib/jirametrics/html/sprint_burndown.erb +5 -3
  27. data/lib/jirametrics/html_report_config.rb +5 -3
  28. data/lib/jirametrics/issue.rb +31 -19
  29. data/lib/jirametrics/jira_gateway.rb +55 -17
  30. data/lib/jirametrics/project_config.rb +30 -3
  31. data/lib/jirametrics/settings.json +3 -1
  32. data/lib/jirametrics.rb +19 -70
  33. metadata +6 -3
@@ -51,7 +51,9 @@ class HtmlReportConfig
51
51
  @file_config.project_config.all_boards.each_value do |board|
52
52
  raise 'Multiple cycletimes not supported' if board.cycletime
53
53
 
54
- board.cycletime = CycleTimeConfig.new(parent_config: self, label: label, block: block, file_system: file_system)
54
+ board.cycletime = CycleTimeConfig.new(
55
+ parent_config: self, label: label, block: block, file_system: file_system, settings: settings
56
+ )
55
57
  end
56
58
  end
57
59
 
@@ -72,6 +74,7 @@ class HtmlReportConfig
72
74
 
73
75
  html_directory = "#{Pathname.new(File.realpath(__FILE__)).dirname}/html"
74
76
  css = load_css html_directory: html_directory
77
+ javascript = file_system.load(File.join(html_directory, 'index.js'))
75
78
  erb = ERB.new file_system.load(File.join(html_directory, 'index.erb'))
76
79
  file_system.save_file content: erb.result(binding), filename: @file_config.output_filename
77
80
  end
@@ -87,7 +90,6 @@ class HtmlReportConfig
87
90
  def load_css html_directory:
88
91
  base_css_filename = File.join(html_directory, 'index.css')
89
92
  base_css = file_system.load(base_css_filename)
90
- log("Loaded CSS: #{base_css_filename}")
91
93
 
92
94
  extra_css_filename = settings['include_css']
93
95
  if extra_css_filename
@@ -160,7 +162,7 @@ class HtmlReportConfig
160
162
  chart.time_range = project_config.time_range
161
163
  chart.timezone_offset = timezone_offset
162
164
  chart.settings = settings
163
- chart.users = project_config.users
165
+ chart.atlassian_document_format = project_config.atlassian_document_format
164
166
 
165
167
  chart.all_boards = project_config.all_boards
166
168
  chart.board_id = find_board_id
@@ -19,9 +19,10 @@ class Issue
19
19
 
20
20
  # There are cases where we create an Issue of fragments like linked issues and those won't have
21
21
  # changelogs.
22
- return unless @raw['changelog']
22
+ load_history_into_changes if @raw['changelog']
23
23
 
24
- load_history_into_changes
24
+ # As above with fragments, there may not be a fields section
25
+ return unless @raw['fields']
25
26
 
26
27
  # If this is an older pull of data then comments may not be there.
27
28
  load_comments_into_changes if @raw['fields']['comment']
@@ -152,7 +153,7 @@ class Issue
152
153
  # Are we currently in this status? If yes, then return the most recent status change.
153
154
  def currently_in_status *status_names
154
155
  change = most_recent_status_change
155
- return false if change.nil?
156
+ return nil if change.nil?
156
157
 
157
158
  change if change.current_status_matches(*status_names)
158
159
  end
@@ -162,7 +163,7 @@ class Issue
162
163
  category_ids = find_status_category_ids_by_names category_names
163
164
 
164
165
  change = most_recent_status_change
165
- return false if change.nil?
166
+ return nil if change.nil?
166
167
 
167
168
  status = find_or_create_status id: change.value_id, name: change.value
168
169
  change if status && category_ids.include?(status.category.id)
@@ -212,7 +213,11 @@ class Issue
212
213
  end
213
214
 
214
215
  def parse_time text
215
- Time.parse(text).getlocal(@timezone_offset)
216
+ if text.is_a? String
217
+ Time.parse(text).getlocal(@timezone_offset)
218
+ else
219
+ Time.at(text / 1000).getlocal(@timezone_offset)
220
+ end
216
221
  end
217
222
 
218
223
  def created
@@ -233,11 +238,11 @@ class Issue
233
238
  end
234
239
 
235
240
  def assigned_to
236
- @raw['fields']&.[]('assignee')&.[]('displayName')
241
+ @raw['fields']['assignee']&.[]('displayName')
237
242
  end
238
243
 
239
244
  def assigned_to_icon_url
240
- @raw['fields']&.[]('assignee')&.[]('avatarUrls')&.[]('16x16')
245
+ @raw['fields']['assignee']&.[]('avatarUrls')&.[]('16x16')
241
246
  end
242
247
 
243
248
  # Many test failures are simply unreadable because the default inspect on this class goes
@@ -608,7 +613,7 @@ class Issue
608
613
 
609
614
  def dump
610
615
  result = +''
611
- result << "#{key} (#{type}): #{compact_text summary, 200}\n"
616
+ result << "#{key} (#{type}): #{compact_text summary, max: 200}\n"
612
617
 
613
618
  assignee = raw['fields']['assignee']
614
619
  result << " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n" unless assignee.nil?
@@ -681,9 +686,8 @@ class Issue
681
686
  def done?
682
687
  if artificial? || board.cycletime.nil?
683
688
  # This was probably loaded as a linked issue, which means we don't know what board it really
684
- # belonged to. The best we can do is look at the status category. This case should be rare but
685
- # it can happen.
686
- status.category.name == 'Done'
689
+ # belonged to. The best we can do is look at the status key
690
+ status.category.done?
687
691
  else
688
692
  board.cycletime.done? self
689
693
  end
@@ -706,6 +710,19 @@ class Issue
706
710
  board.sprints.select { |s| sprint_ids.include? s.id }
707
711
  end
708
712
 
713
+ def compact_text text, max: 60
714
+ return '' if text.nil?
715
+
716
+ if text.is_a? Hash
717
+ # We can't effectively compact it but we can convert it into a string.
718
+ text = @board.project_config.atlassian_document_format.to_html(text)
719
+ else
720
+ text = text.gsub(/\s+/, ' ').strip
721
+ text = "#{text[0...max]}..." if text.length > max
722
+ end
723
+ text
724
+ end
725
+
709
726
  private
710
727
 
711
728
  def load_history_into_changes
@@ -730,14 +747,6 @@ class Issue
730
747
  end
731
748
  end
732
749
 
733
- def compact_text text, max = 60
734
- return nil if text.nil?
735
-
736
- text = text.gsub(/\s+/, ' ').strip
737
- text = "#{text[0..max]}..." if text.length > max
738
- text
739
- end
740
-
741
750
  def sort_changes!
742
751
  @changes.sort! do |a, b|
743
752
  # It's common that a resolved will happen at the same time as a status change.
@@ -755,6 +764,9 @@ class Issue
755
764
  first_status = nil
756
765
  first_status_id = nil
757
766
 
767
+ # There won't be a created timestamp in cases where this was a linked issue
768
+ return unless @raw['fields']['created']
769
+
758
770
  created_time = parse_time @raw['fields']['created']
759
771
  first_change = @changes.find { |change| change.field == field_name }
760
772
  if first_change.nil?
@@ -3,21 +3,61 @@
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
+ def initialize file_system:, jira_config:, settings:
11
13
  @file_system = file_system
14
+ load_jira_config(jira_config)
15
+ @settings = settings
16
+ @ignore_ssl_errors = settings['ignore_ssl_errors']
17
+ end
18
+
19
+ def post_request relative_url:, payload:
20
+ command = make_curl_command url: "#{@jira_url}#{relative_url}", method: 'POST'
21
+ exec_and_parse_response command: command, stdin_data: payload
22
+ end
23
+
24
+ def exec_and_parse_response command:, stdin_data:
25
+ log_entry = " #{command.gsub(/\s+/, ' ')}"
26
+ log_entry = sanitize_message log_entry
27
+ @file_system.log log_entry
28
+
29
+ stdout, stderr, status = capture3(command, stdin_data: stdin_data)
30
+ unless status.success?
31
+ @file_system.log "Failed call with exit status #{status.exitstatus}!"
32
+ @file_system.log "Returned (stdout): #{stdout.inspect}"
33
+ @file_system.log "Returned (stderr): #{stderr.inspect}"
34
+ raise "Failed call with exit status #{status.exitstatus}. " \
35
+ "See #{@file_system.logfile_name} for details"
36
+ end
37
+
38
+ @file_system.log "Returned (stderr): #{stderr.inspect}" unless stderr == ''
39
+ raise 'no response from curl on stdout' if stdout == ''
40
+
41
+ parse_response(command: command, result: stdout)
42
+ end
43
+
44
+ def capture3 command, stdin_data:
45
+ # In it's own method so we can mock it out in tests
46
+ Open3.capture3(command, stdin_data: stdin_data)
12
47
  end
13
48
 
14
49
  def call_url relative_url:
15
50
  command = make_curl_command url: "#{@jira_url}#{relative_url}"
16
- result = call_command command
51
+ exec_and_parse_response command: command, stdin_data: nil
52
+ end
53
+
54
+ def parse_response command:, result:
17
55
  begin
18
56
  json = JSON.parse(result)
19
57
  rescue # rubocop:disable Style/RescueStandardError
20
- raise "Error when parsing result: #{result.inspect}"
58
+ message = "Unable to parse results from #{sanitize_message(command)}"
59
+ @file_system.error message, more: result
60
+ raise message
21
61
  end
22
62
 
23
63
  raise "Download failed with: #{JSON.pretty_generate(json)}" unless json_successful?(json)
@@ -25,18 +65,11 @@ class JiraGateway
25
65
  json
26
66
  end
27
67
 
28
- def call_command command
29
- log_entry = " #{command.gsub(/\s+/, ' ')}"
30
- log_entry = log_entry.gsub(@jira_api_token, '[API_TOKEN]') if @jira_api_token
31
- @file_system.log log_entry
68
+ def sanitize_message message
69
+ token = @jira_api_token || @jira_personal_access_token
70
+ return message unless token # cookie based authentication
32
71
 
33
- result = `#{command}`
34
- @file_system.log result unless $CHILD_STATUS.success?
35
- return result if $CHILD_STATUS.success?
36
-
37
- @file_system.log "Failed call with exit status #{$CHILD_STATUS.exitstatus}."
38
- raise "Failed call with exit status #{$CHILD_STATUS.exitstatus}. " \
39
- "See #{@file_system.logfile_name} for details"
72
+ message.gsub(token, '[API_TOKEN]')
40
73
  end
41
74
 
42
75
  def load_jira_config jira_config
@@ -56,7 +89,7 @@ class JiraGateway
56
89
  @cookies = (jira_config['cookies'] || []).collect { |key, value| "#{key}=#{value}" }.join(';')
57
90
  end
58
91
 
59
- def make_curl_command url:
92
+ def make_curl_command url:, method: 'GET'
60
93
  command = +''
61
94
  command << 'curl'
62
95
  command << ' -L' # follow redirects
@@ -65,8 +98,13 @@ class JiraGateway
65
98
  command << " --cookie #{@cookies.inspect}" unless @cookies.empty?
66
99
  command << " --user #{@jira_email}:#{@jira_api_token}" if @jira_api_token
67
100
  command << " -H \"Authorization: Bearer #{@jira_personal_access_token}\"" if @jira_personal_access_token
68
- command << ' --request GET'
101
+ command << " --request #{method}"
102
+ if method == 'POST'
103
+ command << ' --data @-'
104
+ command << ' --header "Content-Type: application/json"'
105
+ end
69
106
  command << ' --header "Accept: application/json"'
107
+ command << ' --show-error --fail' # Better diagnostics when the server returns an error
70
108
  command << " --url \"#{url}\""
71
109
  command
72
110
  end
@@ -114,10 +114,14 @@ class ProjectConfig
114
114
  def file_prefix prefix
115
115
  # The file_prefix has to be set before almost everything else. It really should have been an attribute
116
116
  # on the project declaration itself. Hindsight is 20/20.
117
+
118
+ # There can only be one of these
117
119
  if @file_prefix
118
- raise "file_prefix should only be set once. Was #{@file_prefix.inspect} and now changed to #{prefix.inspect}."
120
+ raise "file_prefix can only be set once. Was #{@file_prefix.inspect} and now changed to #{prefix.inspect}."
119
121
  end
120
122
 
123
+ raise_if_prefix_already_used(prefix)
124
+
121
125
  @file_prefix = prefix
122
126
 
123
127
  # Yes, this is a wierd place to be initializing this. Unfortunately, it has to happen after the file_prefix
@@ -130,8 +134,21 @@ class ProjectConfig
130
134
  @file_prefix
131
135
  end
132
136
 
133
- def get_file_prefix # rubocop:disable Naming/AccessorMethodName
134
- raise 'file_prefix has not been set yet. Move it to the top of the project declaration.' unless @file_prefix
137
+ def raise_if_prefix_already_used prefix
138
+ @exporter.project_configs.each do |project|
139
+ next unless project.get_file_prefix(raise_if_not_set: false) == prefix && project.target_path == target_path
140
+
141
+ raise "Project #{name.inspect} specifies file prefix #{prefix.inspect}, " \
142
+ "but that is already used by project #{project.name.inspect} in the same target path #{target_path.inspect}. " \
143
+ 'This is almost guaranteed to be too much copy and paste in your configuration. ' \
144
+ 'File prefixes must be unique within a directory.'
145
+ end
146
+ end
147
+
148
+ def get_file_prefix raise_if_not_set: true
149
+ if @file_prefix.nil? && raise_if_not_set
150
+ raise 'file_prefix has not been set yet. Move it to the top of the project declaration.'
151
+ end
135
152
 
136
153
  @file_prefix
137
154
  end
@@ -335,6 +352,12 @@ class ProjectConfig
335
352
  json.each { |user_data| @users << User.new(raw: user_data) }
336
353
  end
337
354
 
355
+ def atlassian_document_format
356
+ @atlassian_document_format ||= AtlassianDocumentFormat.new(
357
+ users: @users, timezone_offset: exporter.timezone_offset
358
+ )
359
+ end
360
+
338
361
  def to_time string, end_of_day: false
339
362
  time = end_of_day ? '23:59:59' : '00:00:00'
340
363
  string = "#{string}T#{time}#{exporter.timezone_offset}" if string.match?(/^\d{4}-\d{2}-\d{2}$/)
@@ -526,6 +549,7 @@ class ProjectConfig
526
549
  end
527
550
 
528
551
  def discard_changes_before status_becomes: nil, &block
552
+ cycletimes_touched = Set.new
529
553
  if status_becomes
530
554
  status_becomes = [status_becomes] unless status_becomes.is_a? Array
531
555
 
@@ -558,6 +582,7 @@ class ProjectConfig
558
582
  next if original_start_time.nil?
559
583
 
560
584
  issue.discard_changes_before cutoff_time
585
+ cycletimes_touched << issue.board.cycletime
561
586
 
562
587
  next unless cutoff_time
563
588
  next if original_start_time > cutoff_time # ie the cutoff would have made no difference.
@@ -568,5 +593,7 @@ class ProjectConfig
568
593
  issue: issue
569
594
  }
570
595
  end
596
+
597
+ cycletimes_touched.each { |c| c.flush_cache }
571
598
  end
572
599
  end
@@ -7,5 +7,7 @@
7
7
  "flagged_means_blocked": true,
8
8
 
9
9
  "expedited_priority_names": ["Critical", "Highest"],
10
- "priority_order": ["Lowest", "Low", "Medium", "High", "Highest"]
10
+ "priority_order": ["Lowest", "Low", "Medium", "High", "Highest"],
11
+
12
+ "cache_cycletime_calculations": true
11
13
  }
data/lib/jirametrics.rb CHANGED
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'thor'
4
+ require 'require_all'
5
+
6
+ # This one does need to be loaded early. The rest will be loaded later.
7
+ require 'jirametrics/file_system'
4
8
 
5
9
  class JiraMetrics < Thor
6
10
  def self.exit_on_failure?
@@ -43,81 +47,26 @@ class JiraMetrics < Thor
43
47
 
44
48
  option :config
45
49
  desc 'info', 'Dump information about one issue'
46
- def info keys
50
+ def info key
47
51
  load_config options[:config]
48
- Exporter.instance.info(keys, name_filter: options[:name] || '*')
52
+ Exporter.instance.info(key, name_filter: options[:name] || '*')
49
53
  end
50
54
 
51
- private
55
+ no_commands do
56
+ def load_config config_file, file_system: FileSystem.new
57
+ config_file = './config.rb' if config_file.nil?
52
58
 
53
- def load_config config_file
54
- config_file = './config.rb' if config_file.nil?
59
+ if File.exist? config_file
60
+ # The fact that File.exist can see the file does not mean that require will be
61
+ # able to load it. Convert this to an absolute pathname now for require.
62
+ config_file = File.absolute_path(config_file).to_s
63
+ else
64
+ file_system.error "Cannot find configuration file #{config_file.inspect}"
65
+ exit 1
66
+ end
55
67
 
56
- if File.exist? config_file
57
- # The fact that File.exist can see the file does not mean that require will be
58
- # able to load it. Convert this to an absolute pathname now for require.
59
- config_file = File.absolute_path(config_file).to_s
60
- else
61
- puts "Cannot find configuration file #{config_file.inspect}"
62
- exit 1
68
+ require_rel 'jirametrics'
69
+ load config_file
63
70
  end
64
-
65
- require 'jirametrics/value_equality'
66
- require 'jirametrics/chart_base'
67
- require 'jirametrics/rules'
68
- require 'jirametrics/grouping_rules'
69
- require 'jirametrics/daily_wip_chart'
70
- require 'jirametrics/groupable_issue_chart'
71
- require 'jirametrics/css_variable'
72
- require 'jirametrics/issue_collection'
73
-
74
- require 'jirametrics/aggregate_config'
75
- require 'jirametrics/expedited_chart'
76
- require 'jirametrics/board_config'
77
- require 'jirametrics/file_config'
78
- require 'jirametrics/jira_gateway'
79
- require 'jirametrics/trend_line_calculator'
80
- require 'jirametrics/status'
81
- require 'jirametrics/issue_link'
82
- require 'jirametrics/estimate_accuracy_chart'
83
- require 'jirametrics/status_collection'
84
- require 'jirametrics/sprint'
85
- require 'jirametrics/issue'
86
- require 'jirametrics/daily_wip_by_age_chart'
87
- require 'jirametrics/daily_wip_by_parent_chart'
88
- require 'jirametrics/aging_work_in_progress_chart'
89
- require 'jirametrics/cycletime_scatterplot'
90
- require 'jirametrics/flow_efficiency_scatterplot'
91
- require 'jirametrics/sprint_issue_change_data'
92
- require 'jirametrics/cycletime_histogram'
93
- require 'jirametrics/daily_wip_by_blocked_stalled_chart'
94
- require 'jirametrics/html_report_config'
95
- require 'jirametrics/data_quality_report'
96
- require 'jirametrics/aging_work_bar_chart'
97
- require 'jirametrics/change_item'
98
- require 'jirametrics/project_config'
99
- require 'jirametrics/dependency_chart'
100
- require 'jirametrics/cycletime_config'
101
- require 'jirametrics/tree_organizer'
102
- require 'jirametrics/aging_work_table'
103
- require 'jirametrics/sprint_burndown'
104
- require 'jirametrics/self_or_issue_dispatcher'
105
- require 'jirametrics/throughput_chart'
106
- require 'jirametrics/exporter'
107
- require 'jirametrics/file_system'
108
- require 'jirametrics/blocked_stalled_change'
109
- require 'jirametrics/board_column'
110
- require 'jirametrics/anonymizer'
111
- require 'jirametrics/downloader'
112
- require 'jirametrics/fix_version'
113
- require 'jirametrics/download_config'
114
- require 'jirametrics/columns_config'
115
- require 'jirametrics/hierarchy_table'
116
- require 'jirametrics/estimation_configuration'
117
- require 'jirametrics/board'
118
- require 'jirametrics/daily_view'
119
- require 'jirametrics/user'
120
- require 'jirametrics/atlassian_document_format'
121
- load config_file
122
71
  end
123
72
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jirametrics
3
3
  version: !ruby/object:Gem::Version
4
- version: '2.13'
4
+ version: 2.20.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Bowler
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-07-25 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: random-word
@@ -87,6 +87,8 @@ files:
87
87
  - lib/jirametrics/dependency_chart.rb
88
88
  - lib/jirametrics/download_config.rb
89
89
  - lib/jirametrics/downloader.rb
90
+ - lib/jirametrics/downloader_for_cloud.rb
91
+ - lib/jirametrics/downloader_for_data_center.rb
90
92
  - lib/jirametrics/estimate_accuracy_chart.rb
91
93
  - lib/jirametrics/estimation_configuration.rb
92
94
  - lib/jirametrics/examples/aggregated_project.rb
@@ -113,6 +115,7 @@ files:
113
115
  - lib/jirametrics/html/hierarchy_table.erb
114
116
  - lib/jirametrics/html/index.css
115
117
  - lib/jirametrics/html/index.erb
118
+ - lib/jirametrics/html/index.js
116
119
  - lib/jirametrics/html/sprint_burndown.erb
117
120
  - lib/jirametrics/html/throughput_chart.erb
118
121
  - lib/jirametrics/html_report_config.rb
@@ -156,7 +159,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
156
159
  - !ruby/object:Gem::Version
157
160
  version: '0'
158
161
  requirements: []
159
- rubygems_version: 3.6.2
162
+ rubygems_version: 3.6.9
160
163
  specification_version: 4
161
164
  summary: Extract Jira metrics
162
165
  test_files: []