Class: Exporter
- Inherits:
-
Object
- Object
- Exporter
- 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
-
#file_system ⇒ Object
Returns the value of attribute file_system.
-
#project_configs ⇒ Object
readonly
Returns the value of attribute project_configs.
Class Method Summary collapse
Instance Method Summary collapse
- #aggregated_project(name:, project_names:, settings: {}) ⇒ Object
-
#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.
-
#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.
- #boards(board_id:, name_filter: nil) ⇒ Object
- #boards_error_message(board_id) ⇒ Object
- #describe_board(gateway, board_id) ⇒ Object
- #download(name_filter:) ⇒ Object
- #downloading? ⇒ Boolean
- #each_project_config(name_filter:) ⇒ Object
- #export(name_filter:) ⇒ Object
- #fetch_all_boards(gateway) ⇒ Object
-
#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.
- #format_board(board, board_id) ⇒ Object
- #format_status(status) ⇒ Object
- #holiday_dates(*args) ⇒ Object
- #info(key, name_filter:) ⇒ Object
-
#initialize(file_system: FileSystem.new) ⇒ Exporter
constructor
A new instance of Exporter.
- #jira_config(filename = nil) ⇒ Object
-
#jira_connections_to_verify(name_filter:) ⇒ Object
The [jira_config, settings] pairs to verify, deduplicated by URL.
- #list_boards(gateway, name_filter) ⇒ Object
- #matching_issues_in(project, key) ⇒ Object
- #project(name: nil, &block) ⇒ Object
- #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
- #stitch(stitch_file) ⇒ Object
- #target_path(path = nil) ⇒ Object
- #timezone_offset(offset = nil) ⇒ Object
- #verify_jira_connections(name_filter:) ⇒ Object
- #xproject(*args) ⇒ Object
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_name ⇒ Object
9 10 11 |
# File 'lib/jirametrics/exporter.rb', line 9 def self.logfile_name @logfile_name ||= 'jirametrics.log' end |
Instance Attribute Details
#file_system ⇒ Object
Returns the value of attribute file_system.
7 8 9 |
# File 'lib/jirametrics/exporter.rb', line 7 def file_system @file_system end |
#project_configs ⇒ Object (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.(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 |
.instance ⇒ Object
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 (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 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
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..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_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., also_write_to_stderr: true } results end |
#xproject(*args) ⇒ Object
316 |
# File 'lib/jirametrics/exporter.rb', line 316 def xproject *args; end |