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.

Class Attribute Summary collapse

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.



41
42
43
44
45
46
47
48
49
# File 'lib/jirametrics/exporter.rb', line 41

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

  timezone_offset '+00:00'
end

Class Attribute Details

.logfile_nameObject



9
10
11
# File 'lib/jirametrics/exporter.rb', line 9

def self.logfile_name
  @logfile_name ||= 'jirametrics.log'
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



19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# File 'lib/jirametrics/exporter.rb', line 19

def self.configure &block
  # No block form: FileSystem holds this descriptor open for the whole run and writes to it as we
  # go, so it must outlive this method.
  logfile = File.open(logfile_name, 'w') # rubocop:disable Style/FileOpen
rescue Errno::EACCES
  # FileSystem can't be used here - it hasn't been created yet (it depends on this logfile).
  warn "Error: Cannot write to #{File.expand_path(logfile_name)}. " \
    'Please ensure the current directory is writable.'
  exit 1
else
  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



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

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

#board_features(gateway, board_id, raw) ⇒ Object

Only a team-managed ("simple") board needs the features lookup to tell sprints from kanban; for classic scrum/kanban the board type alone settles it, so we skip the extra request.



207
208
209
210
211
# File 'lib/jirametrics/exporter.rb', line 207

def board_features gateway, board_id, raw
  return [] unless raw['type'] == 'simple'

  BoardFeature.from_raw gateway.call_url(relative_url: "/rest/agile/1.0/board/#{board_id}/features")
end

#board_kind_label(board) ⇒ Object

A team-managed ("simple") board can be scrum- or kanban-flavoured depending on its sprints feature; the bare "simple" from Jira doesn't say which, so spell it out.



248
249
250
251
252
# File 'lib/jirametrics/exporter.rb', line 248

def board_kind_label board
  return board.board_type unless board.board_type == 'simple'

  board.scrum? ? 'team-managed, uses sprints' : 'team-managed, no sprints'
end

#boards(board_id:, name_filter: nil) ⇒ Object



128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/jirametrics/exporter.rb', line 128

def boards board_id:, name_filter: nil
  gateway = JiraGateway.new(file_system: file_system, jira_config: jira_config, settings: {})
  # Keep the gateway's raw curl chatter in the logfile; list_boards/describe_board switch this off
  # right before they print their own clean output, and the rescue turns a failed Jira call into a
  # one-line message with next steps instead of a stack trace.
  file_system.log_only = true
  if board_id.nil?
    list_boards gateway, name_filter
  else
    describe_board gateway, board_id
  end
  true
rescue StandardError
  file_system.log_only = false
  file_system.error boards_error_message(board_id)
  false
ensure
  file_system.log_only = false
end

#boards_error_message(board_id) ⇒ Object



148
149
150
151
152
153
154
155
156
# File 'lib/jirametrics/exporter.rb', line 148

def boards_error_message board_id
  if board_id
    "Couldn't read board #{board_id} from Jira. Check the id (run `jirametrics boards` to list them) " \
      "and your credentials (`jirametrics verify`). Details in #{file_system.logfile_name}."
  else
    "Couldn't list boards from Jira. Check your credentials with `jirametrics verify`. " \
      "Details in #{file_system.logfile_name}."
  end
end

#describe_board(gateway, board_id) ⇒ Object



193
194
195
196
197
198
199
200
201
202
203
# File 'lib/jirametrics/exporter.rb', line 193

def describe_board gateway, board_id
  statuses = StatusCollection.new
  gateway.call_url(relative_url: '/rest/api/2/status').each do |snippet|
    statuses << Status.from_raw(snippet)
  end
  raw = gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/configuration"
  features = board_features(gateway, board_id, raw)
  file_system.log_only = false # gateway calls done; turn logging back on to print results
  board = Board.new raw: raw, possible_statuses: statuses, features: features
  file_system.log format_board(board, board_id), also_write_to_stderr: true
end

#download(name_filter:) ⇒ Object



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
# File 'lib/jirametrics/exporter.rb', line 58

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)


304
305
306
# File 'lib/jirametrics/exporter.rb', line 304

def downloading?
  @downloading
end

#each_project_config(name_filter:) ⇒ Object



298
299
300
301
302
# File 'lib/jirametrics/exporter.rb', line 298

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



51
52
53
54
55
56
# File 'lib/jirametrics/exporter.rb', line 51

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

#fetch_all_boards(gateway) ⇒ Object



179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'lib/jirametrics/exporter.rb', line 179

def fetch_all_boards gateway
  boards = []
  start_at = 0
  loop do
    json = gateway.call_url relative_url: "/rest/agile/1.0/board?startAt=#{start_at}&maxResults=50"
    values = json['values'] || []
    boards.concat values
    break if json['isLast'] || values.empty?

    start_at += values.length
  end
  boards
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

#format_board(board, board_id) ⇒ Object



213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
# File 'lib/jirametrics/exporter.rb', line 213

def format_board board, board_id
  lines = ["Board #{board_id}: #{board.name.inspect} (#{board_kind_label board})", '']

  backlog = board.backlog_statuses
  unless backlog.empty?
    lines << 'Not shown on the board (treated as not started):'
    backlog.each { |status| lines << "  - #{format_status status}" }
    lines << ''
  end

  lines << 'Columns, left to right, with the statuses in each (shown as "name":id with its category):'
  lines << ''
  board.visible_columns.each do |column|
    lines << "  #{column.name}"
    column.status_ids.each do |id|
      status = board.possible_statuses.find_by_id id
      lines << "    - #{status ? format_status(status) : "unknown status id #{id}"}"
    end
  end

  lines << ''
  lines << 'To set cycle time, choose the column where work is "started" and where it is "finished":'
  lines << "  start_at first_time_in_or_right_of_column '<started column>'"
  lines << "  stop_at  first_time_in_or_right_of_column '<finished column>'"
  if board.scrum?
    lines << ''
    lines << 'This board uses sprints. If no column cleanly marks when work starts, you can start the'
    lines << 'clock when an item is first added to a sprint instead:'
    lines << '  start_at first_time_added_to_active_sprint'
  end
  lines.join "\n"
end

#format_status(status) ⇒ Object



254
255
256
# File 'lib/jirametrics/exporter.rb', line 254

def format_status status
  "#{status.name.inspect}:#{status.id} (#{status.category.name})"
end

#holiday_dates(*args) ⇒ Object



343
344
345
346
347
348
349
350
351
352
353
354
355
356
# File 'lib/jirametrics/exporter.rb', line 343

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

#info(key, name_filter:) ⇒ Object



258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
# File 'lib/jirametrics/exporter.rb', line 258

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

    project.run load_only: true
    selected.concat matching_issues_in(project, key)
  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
  file_system.log_only = false

  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



327
328
329
330
331
332
333
334
335
336
# File 'lib/jirametrics/exporter.rb', line 327

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?

    match = %r{^(?<base_url>.+)/+$}.match(@jira_config['url'])
    @jira_config['url'] = match[:base_url] if match
  end
  @jira_config
end

#jira_connections_to_verify(name_filter:) ⇒ Object

The [jira_config, settings] pairs to verify, deduplicated by URL. We do NOT evaluate_next_level: running a project block (e.g. standard_project) forces an issue-data load that doesn't exist before the first download, and verify must work pre-download. When no project supplied a connection we fall back to the top-level jira_config, so verify can run as a first setup step on just the credentials file. (The fallback has no per-project settings, so a config-block-only setting like ignore_ssl_errors isn't applied. That only affects self-signed Data Center instances.)



114
115
116
117
118
119
120
121
122
123
124
125
126
# File 'lib/jirametrics/exporter.rb', line 114

def jira_connections_to_verify name_filter:
  connections = []
  seen_urls = []
  each_project_config(name_filter: name_filter) do |project|
    url = project.jira_config && project.jira_config['url']
    next if url.nil? || seen_urls.include?(url)

    seen_urls << url
    connections << [project.jira_config, project.settings]
  end
  connections << [jira_config, {}] if connections.empty? && jira_config
  connections
end

#list_boards(gateway, name_filter) ⇒ Object



158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/jirametrics/exporter.rb', line 158

def list_boards gateway, name_filter
  boards = fetch_all_boards gateway
  boards.select! { |board| File.fnmatch(name_filter, board['name'].to_s, File::FNM_CASEFOLD) } if name_filter
  boards.sort_by! { |board| board['name'].to_s.strip.downcase }
  file_system.log_only = false # gateway calls done; turn logging back on to print results

  if boards.empty?
    file_system.log(
      name_filter ? "No boards match #{name_filter.inspect}." : 'No boards found for this Jira connection.',
      also_write_to_stderr: true
    )
    return
  end

  lines = ['Boards you can access:', '']
  boards.each { |board| lines << "  #{board['id']}: #{board['name'].inspect} (#{board['type']})" }
  lines << ''
  lines << "Run `jirametrics boards <id>` to see a board's columns and choose cycletime points."
  file_system.log lines.join("\n"), also_write_to_stderr: true
end

#matching_issues_in(project, key) ⇒ Object



283
284
285
286
287
288
289
290
291
292
# File 'lib/jirametrics/exporter.rb', line 283

def matching_issues_in project, key
  matches = []
  project.issues.each do |issue|
    matches << [project, issue] if key == issue.key
    issue.subtasks.each do |subtask|
      matches << [project, subtask] if key == subtask.key
    end
  end
  matches
end

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



308
309
310
311
312
313
314
# File 'lib/jirametrics/exporter.rb', line 308

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)

    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



294
295
296
# File 'lib/jirametrics/exporter.rb', line 294

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

#target_path(path = nil) ⇒ Object



318
319
320
321
322
323
324
325
# File 'lib/jirametrics/exporter.rb', line 318

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



338
339
340
341
# File 'lib/jirametrics/exporter.rb', line 338

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

#verify_jira_connections(name_filter:) ⇒ Object



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/jirametrics/exporter.rb', line 85

def verify_jira_connections name_filter:
  results = []
  begin
    # Probe under log_only so the gateway's raw curl chatter (and its error dumps) stay in the
    # logfile rather than cluttering the console; we surface a clean summary line below.
    file_system.log_only = true
    jira_connections_to_verify(name_filter: name_filter).each do |config, settings|
      gateway = JiraGateway.new(file_system: file_system, jira_config: config, settings: settings)
      results << gateway.verify_connection
    end
  ensure
    file_system.log_only = false
  end

  if results.empty?
    file_system.error 'No Jira connection found in the configuration to verify'
    return results
  end
  results.each { |result| file_system.log result.message, also_write_to_stderr: true }
  results
end

#xproject(*args) ⇒ Object



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

def xproject *args; end