Class: Exporter

Inherits:
Object
  • Object
show all
Defined in:
lib/jirametrics/examples/standard_project.rb,
lib/jirametrics/exporter.rb,
lib/jirametrics/examples/aggregated_project.rb

Overview

This file is really intended to give you ideas about how you might configure your own reports, not as a complete setup that will work in every case.

The point of an AGGREGATED report is that we’re now looking at a higher level. We might use this in a S2 meeting (Scrum of Scrums) to talk about the things that are happening across teams, not within a single team. For that reason, we look at slightly different things that we would on a single team board.

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(file_system: FileSystem.new) ⇒ Exporter

Returns a new instance of Exporter.



24
25
26
27
28
29
30
31
32
# File 'lib/jirametrics/exporter.rb', line 24

def initialize file_system: FileSystem.new
  @project_configs = []
  @target_path = '.'
  @holiday_dates = []
  @downloading = false
  @file_system = file_system

  timezone_offset '+00:00'
end

Instance Attribute Details

#file_systemObject

Returns the value of attribute file_system.



7
8
9
# File 'lib/jirametrics/exporter.rb', line 7

def file_system
  @file_system
end

#project_configsObject (readonly)

Returns the value of attribute project_configs.



6
7
8
# File 'lib/jirametrics/exporter.rb', line 6

def project_configs
  @project_configs
end

Class Method Details

.configure(&block) ⇒ Object



9
10
11
12
13
14
15
16
17
18
19
20
# File 'lib/jirametrics/exporter.rb', line 9

def self.configure &block
  logfile_name = 'jirametrics.log'
  logfile = File.open logfile_name, 'w'
  file_system = FileSystem.new
  file_system.logfile = logfile
  file_system.logfile_name = logfile_name

  exporter = Exporter.new file_system: file_system

  exporter.instance_eval(&block)
  @@instance = exporter
end

.instanceObject



22
# File 'lib/jirametrics/exporter.rb', line 22

def self.instance = @@instance

Instance Method Details

#aggregated_project(name:, project_names:, settings: {}) ⇒ Object



11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/jirametrics/examples/aggregated_project.rb', line 11

def aggregated_project name:, project_names:, settings: {}
  project name: name do
    file_system.log name
    file_prefix name
    self.settings.merge! stringify_keys(settings)

    aggregate do
      project_names.each do |project_name|
        include_issues_from project_name
      end
    end

    file do
      file_suffix '.html'
      issues.reject! do |issue|
        %w[Sub-task Epic].include? issue.type
      end

      html_report do
        html '<h1>Boards included in this report</h1><ul>', type: :header
        board_lines = []
        included_projects.each do |project|
          project.all_boards.each_value do |board|
            board_lines << "<a href='#{project.get_file_prefix}.html'>#{board.name}</a> from project #{project.name}"
          end
        end
        board_lines.sort.each { |line| html "<li>#{line}</li>", type: :header }
        html '</ul>', type: :header

        cycletime_scatterplot do
          show_trend_lines
          # For an aggregated report we group by board rather than by type
          grouping_rules do |issue, rules|
            rules.label = issue.board.name
          end
        end
        # aging_work_in_progress_chart
        daily_wip_by_parent_chart do
          # When aggregating, the chart tends to need more vertical space
          canvas height: 400, width: 800
        end
        aging_work_table do
          # In an aggregated report, we likely only care about items that are old so exclude anything
          # under 21 days.
          age_cutoff 21
        end

        dependency_chart do
          header_text 'Dependencies across boards'
          description_text 'We are only showing dependencies across boards.'

          # By default, the issue doesn't show what board it's on and this is important for an
          # aggregated view
          chart = self
          issue_rules do |issue, rules|
            chart.default_issue_rules.call(issue, rules)
            rules.label = rules.label.split('<BR/>').insert(1, "Board: #{issue.board.name}").join('<BR/>')
          end

          link_rules do |link, rules|
            chart.default_link_rules.call(link, rules)

            # Because this is the aggregated view, let's hide any link that doesn't cross boards.
            rules.ignore if link.origin.board == link.other_issue.board
          end
        end
      end
    end
  end
end

#download(name_filter:) ⇒ Object



41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/jirametrics/exporter.rb', line 41

def download name_filter:
  @downloading = true
  github_pr_cache = {}
  each_project_config(name_filter: name_filter) do |project|
    project.evaluate_next_level
    next if project.aggregated_project?

    unless project.download_config
      raise "Project #{project.name.inspect} is missing a download section in the config. " \
        'That is required in order to download'
    end

    project.download_config.run
    gateway = JiraGateway.new(
      file_system: file_system, jira_config: project.jira_config, settings: project.settings
    )
    downloader = Downloader.create(
      download_config: project.download_config,
      file_system: file_system,
      jira_gateway: gateway,
      github_pr_cache: github_pr_cache
    )
    downloader.run
  end
  puts "Full output from downloader in #{file_system.logfile_name}"
end

#downloading?Boolean

Returns:

  • (Boolean)


103
104
105
# File 'lib/jirametrics/exporter.rb', line 103

def downloading?
  @downloading
end

#each_project_config(name_filter:) ⇒ Object



97
98
99
100
101
# File 'lib/jirametrics/exporter.rb', line 97

def each_project_config name_filter:
  @project_configs.each do |project|
    yield project if project.name.nil? || File.fnmatch(name_filter, project.name)
  end
end

#export(name_filter:) ⇒ Object



34
35
36
37
38
39
# File 'lib/jirametrics/exporter.rb', line 34

def export name_filter:
  each_project_config(name_filter: name_filter) do |project|
    project.evaluate_next_level
    project.run
  end
end

#filter_issues(issues, ignore_issues) ⇒ Object

Extracted as a separate method so it can be tested independently, without needing to invoke the full standard_project DSL setup.



104
105
106
107
108
109
110
# File 'lib/jirametrics/examples/standard_project.rb', line 104

def filter_issues issues, ignore_issues
  return unless ignore_issues

  issues.reject! do |issue|
    ignore_issues.is_a?(Proc) ? ignore_issues.call(issue) : ignore_issues.include?(issue.key)
  end
end

#holiday_dates(*args) ⇒ Object



141
142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/jirametrics/exporter.rb', line 141

def holiday_dates *args
  unless args.empty?
    dates = []
    args.each do |arg|
      if arg =~ /^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/
        Date.parse($1).upto(Date.parse($2)).each { |date| dates << date }
      else
        dates << Date.parse(arg)
      end
    end
    @holiday_dates = dates
  end
  @holiday_dates
end

#info(key, name_filter:) ⇒ Object



68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/jirametrics/exporter.rb', line 68

def info key, name_filter:
  selected = []
  each_project_config(name_filter: name_filter) do |project|
    project.evaluate_next_level

    project.run load_only: true
    project.issues.each do |issue|
      selected << [project, issue] if key == issue.key
    end
  rescue => e # rubocop:disable Style/RescueStandardError
    # This happens when we're attempting to load an aggregated project because it hasn't been
    # properly initialized. Since we don't care about aggregated projects, we just ignore it.
    raise unless e.message.start_with? 'This is an aggregated project and issues should have been included'
  end

  if selected.empty?
    file_system.log "No issues found to match #{key.inspect}"
  else
    selected.each do |project, issue|
      file_system.log "\nProject #{project.name}", also_write_to_stderr: true
      file_system.log issue.dump, also_write_to_stderr: true
    end
  end
end

#jira_config(filename = nil) ⇒ Object



126
127
128
129
130
131
132
133
134
# File 'lib/jirametrics/exporter.rb', line 126

def jira_config filename = nil
  if filename
    @jira_config = file_system.load_json(filename, fail_on_error: false)
    raise "Unable to load Jira configuration file and cannot continue: #{filename.inspect}" if @jira_config.nil?

    @jira_config['url'] = $1 if @jira_config['url'] =~ /^(.+)\/+$/
  end
  @jira_config
end

#project(name: nil, &block) ⇒ Object



107
108
109
110
111
112
113
# File 'lib/jirametrics/exporter.rb', line 107

def project name: nil, &block
  raise 'jira_config not set' if @jira_config.nil?

  @project_configs << ProjectConfig.new(
    exporter: self, target_path: @target_path, jira_config: @jira_config, block: block, name: name
  )
end

#standard_project(name:, file_prefix:, ignore_issues: nil, starting_status: nil, boards: {}, default_board: nil, anonymize: false, settings: {}, status_category_mappings: {}, rolling_date_count: 90, no_earlier_than: nil, ignore_types: %w[Sub-task Subtask Epic],, show_experimental_charts: false, github_repos: nil) ⇒ Object



6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/jirametrics/examples/standard_project.rb', line 6

def standard_project name:, file_prefix:, ignore_issues: nil, starting_status: nil, boards: {},
    default_board: nil, anonymize: false, settings: {}, status_category_mappings: {},
    rolling_date_count: 90, no_earlier_than: nil, ignore_types: %w[Sub-task Subtask Epic],
    show_experimental_charts: false, github_repos: nil
  exporter = self
  project name: name do
    file_system.log name, also_write_to_stderr: true
    file_prefix file_prefix

    self.anonymize if anonymize
    self.settings.merge! stringify_keys(settings)

    boards.each_key do |board_id|
      block = boards[board_id]
      if block == :default
        block = lambda do |_|
          start_at first_time_in_status_category(:indeterminate)
          stop_at still_in_status_category(:done)
        end
      end
      board id: board_id do
        cycletime(&block)
      end
    end

    status_category_mappings.each do |status, category|
      status_category_mapping status: status, category: category
    end

    download do
      self.rolling_date_count(rolling_date_count) if rolling_date_count
      self.no_earlier_than(no_earlier_than) if no_earlier_than
      github_repo *github_repos if github_repos
    end

    issues.reject! do |issue|
      ignore_types.include? issue.type
    end

    exporter.filter_issues issues, ignore_issues

    discard_changes_before status_becomes: (starting_status || :backlog) # rubocop:disable Style/RedundantParentheses

    file do
      file_suffix '.html'

      html_report do
        board_id default_board if default_board

        html "<H1>#{name}</H1>", type: :header
        boards.each_key do |id|
          board = find_board id
          html "<div><a href='#{board.url}'>#{id} #{board.name}</a> (#{board.board_type})</div>",
               type: :header
        end
        daily_view
        cumulative_flow_diagram
        cycletime_scatterplot do
          show_trend_lines
        end
        cycletime_histogram

        throughput_chart do
          description_text <<~TEXT
            <div>Throughput data is very useful for#{' '}
              <a href="https://blog.mikebowler.ca/2024/06/02/probabilistic-forecasting/">probabilistic forecasting</a>,
              to determine when we'll be done. Try it now with the
              <a href="<%= throughput_forecaster_url %>" target="_blank" rel="noopener noreferrer">
              Focused Objective throughput forecaster,</a> to see how long it would take to complete all of the
              <%= @not_started_count %> items you currently have in your backlog.
            </div>
            <h2>Number of items completed, grouped by issue type</h2>'
          TEXT
        end
        throughput_by_completed_resolution_chart do
          description_text '<h2>Number of items completed, grouped by completion status and resolution</h2>'
        end

        aging_work_in_progress_chart
        wip_by_column_chart do
          show_recommendations
        end
        aging_work_bar_chart
        aging_work_table
        daily_wip_by_age_chart
        daily_wip_by_blocked_stalled_chart
        daily_wip_by_parent_chart
        flow_efficiency_scatterplot if show_experimental_charts
        sprint_burndown
        estimate_accuracy_chart
        dependency_chart
      end
    end
  end
end

#stitch(stitch_file) ⇒ Object



93
94
95
# File 'lib/jirametrics/exporter.rb', line 93

def stitch stitch_file
  Stitcher.new(file_system: file_system).run(stitch_file: stitch_file)
end

#target_path(path = nil) ⇒ Object



117
118
119
120
121
122
123
124
# File 'lib/jirametrics/exporter.rb', line 117

def target_path path = nil
  unless path.nil?
    @target_path = path
    @target_path += File::SEPARATOR unless @target_path.end_with? File::SEPARATOR
    FileUtils.mkdir_p @target_path
  end
  @target_path
end

#timezone_offset(offset = nil) ⇒ Object



136
137
138
139
# File 'lib/jirametrics/exporter.rb', line 136

def timezone_offset offset = nil
  @timezone_offset = offset unless offset.nil?
  @timezone_offset
end

#xproject(*args) ⇒ Object



115
# File 'lib/jirametrics/exporter.rb', line 115

def xproject *args; end