jirametrics 1.4 → 2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/bin/jirametrics +1 -0
  3. data/lib/jirametrics/aggregate_config.rb +2 -1
  4. data/lib/jirametrics/aging_work_bar_chart.rb +3 -3
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +6 -6
  6. data/lib/jirametrics/anonymizer.rb +3 -3
  7. data/lib/jirametrics/blocked_stalled_change.rb +5 -10
  8. data/lib/jirametrics/board.rb +11 -13
  9. data/lib/jirametrics/chart_base.rb +5 -5
  10. data/lib/jirametrics/cycletime_histogram.rb +2 -2
  11. data/lib/jirametrics/cycletime_scatterplot.rb +5 -2
  12. data/lib/jirametrics/daily_wip_chart.rb +1 -1
  13. data/lib/jirametrics/data_quality_report.rb +4 -4
  14. data/lib/jirametrics/dependency_chart.rb +5 -4
  15. data/lib/jirametrics/download_config.rb +0 -19
  16. data/lib/jirametrics/downloader.rb +32 -74
  17. data/lib/jirametrics/examples/aggregated_project.rb +61 -3
  18. data/lib/jirametrics/examples/standard_project.rb +3 -3
  19. data/lib/jirametrics/expedited_chart.rb +4 -4
  20. data/lib/jirametrics/experimental/generator.rb +5 -4
  21. data/lib/jirametrics/experimental/info.rb +2 -2
  22. data/lib/jirametrics/exporter.rb +16 -30
  23. data/lib/jirametrics/file_system.rb +36 -0
  24. data/lib/jirametrics/groupable_issue_chart.rb +0 -9
  25. data/lib/jirametrics/hierarchy_table.rb +1 -1
  26. data/lib/jirametrics/html_report_config.rb +5 -30
  27. data/lib/jirametrics/issue.rb +1 -7
  28. data/lib/jirametrics/issue_link.rb +0 -7
  29. data/lib/jirametrics/jira_gateway.rb +59 -0
  30. data/lib/jirametrics/project_config.rb +78 -88
  31. data/lib/jirametrics/rules.rb +1 -20
  32. data/lib/jirametrics/sprint_burndown.rb +7 -6
  33. data/lib/jirametrics/sprint_issue_change_data.rb +4 -9
  34. data/lib/jirametrics/status.rb +24 -20
  35. data/lib/jirametrics/status_collection.rb +2 -2
  36. data/lib/jirametrics/story_point_accuracy_chart.rb +2 -7
  37. data/lib/jirametrics/throughput_chart.rb +2 -2
  38. data/lib/jirametrics/trend_line_calculator.rb +4 -4
  39. data/lib/jirametrics/value_equality.rb +23 -0
  40. data/lib/jirametrics.rb +3 -1
  41. metadata +5 -17
  42. data/lib/jirametrics/json_file_loader.rb +0 -9
@@ -1,9 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # This file is really intended to give you ideas about how you might configure your own reports, not
4
- # as a complete setup that will work in every case.
4
+ # as a complete setup that will work in every case.
5
5
  #
6
- # See https://github.com/mikebowler/jirametrics/wiki/Examples-folder for moreclass Exporter
6
+ # See https://github.com/mikebowler/jirametrics/wiki/Examples-folder for more details
7
+ #
8
+ # The point of an AGGREGATED report is that we're now looking at a higher level. We might use this in a
9
+ # S2 meeting (Scrum of Scrums) to talk about the things that are happening across teams, not within a
10
+ # single team. For that reason, we look at slightly different things that we would on a single team board.
11
+
7
12
  class Exporter
8
13
  def aggregated_project name:, project_names:
9
14
  project name: name do
@@ -29,10 +34,63 @@ class Exporter
29
34
  rules.label = issue.board.name
30
35
  end
31
36
  end
32
- aging_work_in_progress_chart
37
+ # aging_work_in_progress_chart
38
+ daily_wip_chart do
39
+ header_text 'Daily WIP by Parent'
40
+ description_text <<-TEXT
41
+ <p>How much work is in progress, grouped by the parent of the issue. This will give us an
42
+ indication of how focused we are on higher level objectives. If there are many parent
43
+ tickets in progress at the same time, either this team has their focus scattered or we
44
+ aren't doing a good job of
45
+ <a href="https://improvingflow.com/2024/02/21/slicing-epics.html">splitting those parent
46
+ tickets</a>. Neither of those is desirable.</p>
47
+ <p>If you're expecting all work items to have parents and there are a lot that don't,
48
+ that's also something to look at. Consider whether there is even value in aggregating
49
+ these projects if they don't share parent dependencies. Aggregation helps us when we're
50
+ looking at related work and if there aren't parent dependencies then the work may not
51
+ be related.</p>
52
+ TEXT
53
+ grouping_rules do |issue, rules|
54
+ rules.label = issue.parent&.key || 'No parent'
55
+ rules.color = 'white' if rules.label == 'No parent'
56
+ end
57
+ end
33
58
  aging_work_table do
59
+ # In an aggregated report, we likely only care about items that are old so exclude anything
60
+ # under 21 days.
34
61
  age_cutoff 21
35
62
  end
63
+
64
+ dependency_chart do
65
+ header_text 'Dependencies across boards'
66
+ description_text 'We are only showing dependencies across boards.'
67
+
68
+ # By default, the issue doesn't show what board it's on and this is important for an
69
+ # aggregated view
70
+ issue_rules do |issue, rules|
71
+ key = issue.key
72
+ key = "<S>#{key} </S> " if issue.status.category_name == 'Done'
73
+ rules.label = "<#{key} [#{issue.type}]<BR/>#{issue.board.name}<BR/>#{word_wrap issue.summary}>"
74
+ end
75
+
76
+ link_rules do |link, rules|
77
+ # By default, the dependency chart shows everything. Clean it up a bit.
78
+ case link.name
79
+ when 'Cloners'
80
+ # We don't want to see any clone links at all.
81
+ rules.ignore
82
+ when 'Blocks'
83
+ # For blocks, by default Jira will have links going both
84
+ # ways and we want them only going one way. Also make the
85
+ # link red.
86
+ rules.merge_bidirectional keep: 'outward'
87
+ rules.line_color = 'red'
88
+ end
89
+
90
+ # Because this is the aggregated view, let's hide any link that doesn't cross boards.
91
+ rules.ignore if link.origin.board == link.other_issue.board
92
+ end
93
+ end
36
94
  end
37
95
  end
38
96
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # This file is really intended to give you ideas about how you might configure your own reports, not
4
- # as a complete setup that will work in every case.
4
+ # as a complete setup that will work in every case.
5
5
  #
6
6
  # See https://github.com/mikebowler/jirametrics/wiki/Examples-folder for more
7
7
  class Exporter
@@ -83,13 +83,13 @@ class Exporter
83
83
  How much work is in progress, grouped by the parent of the issue. This will give us an
84
84
  indication of how focused we are on higher level objectives. If there are many parent
85
85
  tickets in progress at the same time, either this team has their focus scattered or we
86
- aren't doing a good job of
86
+ aren't doing a good job of
87
87
  <a href="https://improvingflow.com/2024/02/21/slicing-epics.html">splitting those parent
88
88
  tickets</a>. Neither of those is desirable.
89
89
  TEXT
90
90
  grouping_rules do |issue, rules|
91
91
  rules.label = issue.parent&.key || 'No parent'
92
- rules.color = 'white' if rules.label == 'No parent'
92
+ rules.color = 'white' if rules.label == 'No parent'
93
93
  end
94
94
  end
95
95
  expedited_chart
@@ -39,9 +39,9 @@ class ExpeditedChart < ChartBase
39
39
  end
40
40
 
41
41
  def run
42
- data_sets = find_expedited_issues.collect do |issue|
42
+ data_sets = find_expedited_issues.filter_map do |issue|
43
43
  make_expedite_lines_data_set(issue: issue, expedite_data: prepare_expedite_data(issue))
44
- end.compact
44
+ end
45
45
 
46
46
  if data_sets.empty?
47
47
  '<h1>Expedited work</h1>There is no expedited work in this time period.'
@@ -84,7 +84,7 @@ class ExpeditedChart < ChartBase
84
84
  prepare_expedite_data(issue).empty?
85
85
  end
86
86
 
87
- expedited_issues.sort { |a, b| a.key_as_i <=> b.key_as_i }
87
+ expedited_issues.sort_by(&:key_as_i)
88
88
  end
89
89
 
90
90
  def later_date date1, date2
@@ -110,7 +110,7 @@ class ExpeditedChart < ChartBase
110
110
 
111
111
  expedite_data << [started_time, :issue_started] if started_time
112
112
  expedite_data << [stopped_time, :issue_stopped] if stopped_time
113
- expedite_data.sort! { |a, b| a[0] <=> b[0] }
113
+ expedite_data.sort_by! { |a| a[0] }
114
114
 
115
115
  # If none of the data would be visible on the chart then skip it.
116
116
  return nil unless expedite_data.any? { |time, _action| time.to_date >= date_range.begin }
@@ -38,7 +38,7 @@ class FakeIssue
38
38
  priority: {
39
39
  name: ''
40
40
  },
41
- summary: RandomWord.phrases.next.gsub(/_/, ' '),
41
+ summary: RandomWord.phrases.next.gsub(_, ' '),
42
42
  issuelinks: [],
43
43
  fixVersions: []
44
44
  }
@@ -137,7 +137,7 @@ class Generator
137
137
  remove_old_files
138
138
  @date_range.each_with_index do |date, day|
139
139
  yield date, day if block_given?
140
- process_date(date, day) if (1..5).include? date.wday # Weekday
140
+ process_date(date, day) if (1..5).cover? date.wday # Weekday
141
141
  end
142
142
 
143
143
  @issues.each do |issue|
@@ -158,7 +158,7 @@ class Generator
158
158
  def remove_old_files
159
159
  path = "#{@target_path}#{@file_prefix}_issues"
160
160
  Dir.foreach path do |file|
161
- next unless file =~ /-\d+\.json$/
161
+ next unless file.match?(/-\d+\.json$/)
162
162
 
163
163
  filename = "#{path}/#{file}"
164
164
  File.unlink filename
@@ -191,8 +191,9 @@ class Generator
191
191
  end
192
192
  end
193
193
 
194
+ possible_capacities = [0, 1, 1, 1, 2]
194
195
  @workers.each do |worker|
195
- worker_capacity = [0, 1, 1, 1, 2].sample
196
+ worker_capacity = possible_capacities.sample
196
197
  if worker.issue.nil? || worker.issue.done?
197
198
  type = lucky?(89) ? 'Story' : 'Bug'
198
199
  worker.issue = next_issue_for worker: worker, date: date, type: type
@@ -13,7 +13,7 @@ class InfoDumper
13
13
  path = "#{@target_dir}#{prefix}_issues/#{key}.json"
14
14
  path = "#{@target_dir}#{prefix}_issues"
15
15
  Dir.foreach path do |file|
16
- if file =~ /^#{key}.+\.json$/
16
+ if file.match?(/^#{key}.+\.json$/)
17
17
  issue = Issue.new raw: JSON.parse(File.read(File.join(path, file))), board: nil
18
18
  dump issue
19
19
  end
@@ -74,4 +74,4 @@ if __FILE__ == $PROGRAM_NAME
74
74
  ARGV.each do |key|
75
75
  InfoDumper.new.run key
76
76
  end
77
- end
77
+ end
@@ -3,37 +3,17 @@
3
3
  require 'fileutils'
4
4
 
5
5
  class Object
6
- def deprecated message:, date: nil
7
- text = String.new
8
- text << 'Deprecated'
9
- text << "(#{date})"
10
- text << ': '
6
+ def deprecated message:
7
+ text = +''
8
+ text << 'Deprecated:'
11
9
  text << message
12
- text << "\n-> Called from #{caller[0]}"
13
- warn text
14
- end
15
-
16
- def assert_jira_behaviour_true condition, &block
17
- block.call if ENV['RACK_ENV'] == 'test' # Always expand the block if we're running in a test
18
- failed_jira_behaviour(block) unless condition
19
- end
20
-
21
- def assert_jira_behaviour_false condition, &block
22
- block.call if ENV['RACK_ENV'] == 'test' # Always expand the block if we're running in a test
23
- failed_jira_behaviour(block) if condition
24
- end
25
-
26
- def failed_jira_behaviour block
27
- text = String.new
28
- text << 'Jira not doing what we expected. Results may be incorrect: '
29
- text << block.call
30
- text << "\n-> Called from #{caller[1]}"
10
+ text << "\n-> Called from #{caller(1..1).first}"
31
11
  warn text
32
12
  end
33
13
  end
34
14
 
35
15
  class Exporter
36
- attr_reader :project_configs
16
+ attr_reader :project_configs, :file_system
37
17
 
38
18
  def self.configure &block
39
19
  exporter = Exporter.new
@@ -43,12 +23,13 @@ class Exporter
43
23
 
44
24
  def self.instance = @@instance
45
25
 
46
- def initialize
26
+ def initialize file_system: FileSystem.new
47
27
  @project_configs = []
48
28
  @timezone_offset = '+00:00'
49
29
  @target_path = '.'
50
30
  @holiday_dates = []
51
31
  @downloading = false
32
+ @file_system = file_system
52
33
  end
53
34
 
54
35
  def export name_filter:
@@ -62,14 +43,19 @@ class Exporter
62
43
  @downloading = true
63
44
  logfile_name = 'downloader.log'
64
45
  File.open logfile_name, 'w' do |logfile|
46
+ file_system.logfile = logfile
47
+ file_system.logfile_name = logfile_name
48
+
65
49
  each_project_config(name_filter: name_filter) do |project|
66
50
  project.evaluate_next_level
67
51
  next if project.aggregated_project?
68
52
 
69
53
  project.download_config.run
70
- downloader = Downloader.new(download_config: project.download_config)
71
- downloader.logfile = logfile
72
- downloader.logfile_name = logfile_name
54
+ downloader = Downloader.new(
55
+ download_config: project.download_config,
56
+ file_system: file_system,
57
+ jira_gateway: JiraGateway.new(file_system: file_system)
58
+ )
73
59
  downloader.run
74
60
  end
75
61
  end
@@ -107,7 +93,7 @@ class Exporter
107
93
  end
108
94
 
109
95
  def jira_config filename = nil
110
- @jira_config = JsonFileLoader.new.load(filename) unless filename.nil?
96
+ @jira_config = file_system.load_json(filename) unless filename.nil?
111
97
  @jira_config
112
98
  end
113
99
 
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ class FileSystem
6
+ attr_accessor :logfile, :logfile_name
7
+
8
+ def load_json filename, fail_on_error: true
9
+ return nil if fail_on_error == false && File.exist?(filename) == false
10
+
11
+ JSON.parse File.read(filename)
12
+ end
13
+
14
+ def save_json json:, filename:
15
+ file_path = File.dirname(filename)
16
+ FileUtils.mkdir_p file_path unless File.exist?(file_path)
17
+
18
+ File.write(filename, JSON.pretty_generate(compress json))
19
+ end
20
+
21
+ def log message
22
+ logfile.puts message
23
+ end
24
+
25
+ # In some Jira instances, a sizeable portion of the JSON is made up of empty fields. I've seen
26
+ # cases where this simple compression will drop the filesize by half.
27
+ def compress node
28
+ if node.is_a? Hash
29
+ node.reject! { |_key, value| value.nil? || (value.is_a?(Array) && value.empty?) }
30
+ node.each_value { |value| compress value }
31
+ elsif node.is_a? Array
32
+ node.each { |a| compress a }
33
+ end
34
+ node
35
+ end
36
+ end
@@ -5,15 +5,6 @@ require 'jirametrics/grouping_rules'
5
5
 
6
6
  module GroupableIssueChart
7
7
  def init_configuration_block user_provided_block, &default_block
8
- # The user provided a block but it's using the old deprecated style
9
- if user_provided_block && user_provided_block.arity == 1
10
- deprecated date: '2022-10-02', message: "#{self.class}: Use the new grouping_rules syntax"
11
- grouping_rules do |issue, rules|
12
- rules.label, rules.color = user_provided_block.call(issue)
13
- end
14
- return
15
- end
16
-
17
8
  if user_provided_block
18
9
  instance_eval(&user_provided_block)
19
10
  return if @group_by_block
@@ -17,7 +17,7 @@ class HierarchyTable < ChartBase
17
17
  def run
18
18
  tree_organizer = TreeOrganizer.new issues: @issues
19
19
  unless tree_organizer.cyclical_links.empty?
20
- message = String.new
20
+ message = +''
21
21
  message << '<p>Found cyclical links in the parent hierarchy. This is an error and should be '
22
22
  message << 'fixed.</p><ul>'
23
23
  tree_organizer.cyclical_links.each do |link|
@@ -19,7 +19,7 @@ class HtmlReportConfig
19
19
  def cycletime label = nil, &block
20
20
  # TODO: This is about to become deprecated
21
21
 
22
- @file_config.project_config.all_boards.each do |_id, board|
22
+ @file_config.project_config.all_boards.each_value do |board|
23
23
  raise 'Multiple cycletimes not supported yet' if board.cycletime
24
24
 
25
25
  board.cycletime = CycleTimeConfig.new(parent_config: self, label: label, block: block)
@@ -68,12 +68,7 @@ class HtmlReportConfig
68
68
  execute_chart AgingWorkBarChart.new(block)
69
69
  end
70
70
 
71
- def aging_work_table priority_name = nil, &block
72
- if priority_name
73
- deprecated message: 'priority name should no longer be passed into the chart. Specify it in the ' \
74
- 'board declaration. See https://github.com/mikebowler/jira-export/wiki/Deprecated',
75
- date: '2022-12-26'
76
- end
71
+ def aging_work_table &block
77
72
  execute_chart AgingWorkTable.new(block)
78
73
  end
79
74
 
@@ -81,11 +76,6 @@ class HtmlReportConfig
81
76
  execute_chart CycletimeScatterplot.new block
82
77
  end
83
78
 
84
- def total_wip_over_time_chart &block
85
- puts 'Deprecated(total_wip_over_time_chart). Use daily_wip_by_age_chart instead.'
86
- execute_chart DailyWipByAgeChart.new block
87
- end
88
-
89
79
  def daily_wip_chart &block
90
80
  execute_chart DailyWipChart.new(block)
91
81
  end
@@ -106,17 +96,7 @@ class HtmlReportConfig
106
96
  execute_chart ThroughputChart.new(block)
107
97
  end
108
98
 
109
- def blocked_stalled_chart
110
- puts 'Deprecated(blocked_stalled_chart). Use daily_wip_by_blocked_stalled_chart instead.'
111
- execute_chart DailyWipByBlockedStalledChart.new
112
- end
113
-
114
- def expedited_chart priority_name = nil
115
- if priority_name
116
- deprecated message: 'priority name should no longer be passed into the chart. Specify it in the ' \
117
- 'board declaration. See https://github.com/mikebowler/jira-export/wiki/Deprecated',
118
- date: '2022-12-26'
119
- end
99
+ def expedited_chart
120
100
  execute_chart ExpeditedChart.new
121
101
  end
122
102
 
@@ -125,11 +105,11 @@ class HtmlReportConfig
125
105
  end
126
106
 
127
107
  def random_color
128
- "\##{Random.bytes(3).unpack1('H*')}"
108
+ "##{Random.bytes(3).unpack1('H*')}"
129
109
  end
130
110
 
131
111
  def html string, type: :body
132
- raise "Unexpected type: #{type}" unless [:body, :header].include? type
112
+ raise "Unexpected type: #{type}" unless %i[body header].include? type
133
113
 
134
114
  @sections << [string, type]
135
115
  end
@@ -161,11 +141,6 @@ class HtmlReportConfig
161
141
  end
162
142
  end
163
143
 
164
- def discarded_changes_report
165
- puts 'Deprecated(discarded_changes_report) No need to specify this anymore as this information is ' \
166
- 'now included in the data quality checks.'
167
- end
168
-
169
144
  def dependency_chart &block
170
145
  execute_chart DependencyChart.new block
171
146
  end
@@ -54,11 +54,6 @@ class Issue
54
54
  Status.new raw: @raw['fields']['status']
55
55
  end
56
56
 
57
- def status_id
58
- puts 'DEPRECATED(Issue.status_id) Call Issue.status.id instead'
59
- status.id
60
- end
61
-
62
57
  def labels = @raw['fields']['labels'] || []
63
58
 
64
59
  def author = @raw['fields']['creator']&.[]('displayName') || ''
@@ -270,7 +265,6 @@ class Issue
270
265
  # By doing this, we're able to eliminate a lot of duplicated code in charts.
271
266
  mock_change = ChangeItem.new time: end_time, author: '', artificial: true, raw: { 'field' => '' }
272
267
  (changes + [mock_change]).each do |change|
273
-
274
268
  previous_was_active = false if check_for_stalled(
275
269
  change_time: change.time,
276
270
  previous_change_time: previous_change_time,
@@ -388,7 +382,7 @@ class Issue
388
382
  if expedited_names.include? change.value
389
383
  expedited_start = change.time.to_date if expedited_start.nil?
390
384
  else
391
- return true if expedited_start && (expedited_start..change.time.to_date).include?(date)
385
+ return true if expedited_start && (expedited_start..change.time.to_date).cover?(date)
392
386
 
393
387
  expedited_start = nil
394
388
  end
@@ -19,13 +19,6 @@ class IssueLink
19
19
  end
20
20
 
21
21
  def direction
22
- assert_jira_behaviour_false(raw['inwardIssue'].nil? && raw['outwardIssue'].nil?) do
23
- "Found an issue link with neither inward nor outward references: #{raw}"
24
- end
25
- assert_jira_behaviour_false(raw['inwardIssue'] && raw['outwardIssue']) do
26
- "Found an issue link that has both inward and outward references in the same link: #{raw}"
27
- end
28
-
29
22
  if raw['inwardIssue']
30
23
  :inward
31
24
  else
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+ require 'json'
5
+ require 'English'
6
+
7
+ class JiraGateway
8
+ attr_accessor :ignore_ssl_errors, :jira_url
9
+
10
+ def initialize file_system:
11
+ @file_system = file_system
12
+ end
13
+
14
+ def call_url relative_url:
15
+ command = make_curl_command url: "#{@jira_url}#{relative_url}"
16
+ JSON.parse call_command command
17
+ end
18
+
19
+ def call_command command
20
+ @file_system.log " #{command.gsub(/\s+/, ' ')}"
21
+ result = `#{command}`
22
+ @file_system.log result unless $CHILD_STATUS.success?
23
+ return result if $CHILD_STATUS.success?
24
+
25
+ @file_system.log "Failed call with exit status #{$CHILD_STATUS.exitstatus}."
26
+ raise "Failed call with exit status #{$CHILD_STATUS.exitstatus}. " \
27
+ "See #{@file_system.logfile_name} for details"
28
+ end
29
+
30
+ def load_jira_config jira_config
31
+ @jira_url = jira_config['url']
32
+ raise "Must specify URL in config" if @jira_url.nil?
33
+
34
+ @jira_email = jira_config['email']
35
+ @jira_api_token = jira_config['api_token']
36
+ @jira_personal_access_token = jira_config['personal_access_token']
37
+
38
+ raise 'When specifying an api-token, you must also specify email' if @jira_api_token && !@jira_email
39
+
40
+ if @jira_api_token && @jira_personal_access_token
41
+ raise "You can't specify both an api-token and a personal-access-token. They don't work together."
42
+ end
43
+
44
+ @cookies = (jira_config['cookies'] || []).collect { |key, value| "#{key}=#{value}" }.join(';')
45
+ end
46
+
47
+ def make_curl_command url:
48
+ command = 'curl'
49
+ command += ' -s'
50
+ command += ' -k' if @ignore_ssl_errors
51
+ command += " --cookie #{@cookies.inspect}" unless @cookies.empty?
52
+ command += " --user #{@jira_email}:#{@jira_api_token}" if @jira_api_token
53
+ command += " -H \"Authorization: Bearer #{@jira_personal_access_token}\"" if @jira_personal_access_token
54
+ command += ' --request GET'
55
+ command += ' --header "Accept: application/json"'
56
+ command += " --url \"#{url}\""
57
+ command
58
+ end
59
+ end