jirametrics 2.11 → 2.14

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.
@@ -6,7 +6,7 @@ require 'jirametrics/status_collection'
6
6
  class ProjectConfig
7
7
  attr_reader :target_path, :jira_config, :all_boards, :possible_statuses,
8
8
  :download_config, :file_configs, :exporter, :data_version, :name, :board_configs,
9
- :settings, :aggregate_config, :discarded_changes_data
9
+ :settings, :aggregate_config, :discarded_changes_data, :users
10
10
  attr_accessor :time_range, :jira_url, :id
11
11
 
12
12
  def initialize exporter:, jira_config:, block:, target_path: '.', name: '', id: nil
@@ -40,6 +40,7 @@ class ProjectConfig
40
40
  @id = guess_project_id
41
41
  load_project_metadata
42
42
  load_sprints
43
+ load_users
43
44
  end
44
45
 
45
46
  def run load_only: false
@@ -113,10 +114,14 @@ class ProjectConfig
113
114
  def file_prefix prefix
114
115
  # The file_prefix has to be set before almost everything else. It really should have been an attribute
115
116
  # on the project declaration itself. Hindsight is 20/20.
117
+
118
+ # There can only be one of these
116
119
  if @file_prefix
117
- 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}."
118
121
  end
119
122
 
123
+ raise_if_prefix_already_used(prefix)
124
+
120
125
  @file_prefix = prefix
121
126
 
122
127
  # Yes, this is a wierd place to be initializing this. Unfortunately, it has to happen after the file_prefix
@@ -129,8 +134,21 @@ class ProjectConfig
129
134
  @file_prefix
130
135
  end
131
136
 
132
- def get_file_prefix # rubocop:disable Naming/AccessorMethodName
133
- 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
134
152
 
135
153
  @file_prefix
136
154
  end
@@ -151,6 +169,8 @@ class ProjectConfig
151
169
  end
152
170
 
153
171
  def status_category_mapping status:, category:
172
+ return if @exporter.downloading?
173
+
154
174
  status, status_id = possible_statuses.parse_name_id status
155
175
  category, category_id = possible_statuses.parse_name_id category
156
176
 
@@ -323,6 +343,15 @@ class ProjectConfig
323
343
  raise
324
344
  end
325
345
 
346
+ def load_users
347
+ @users = []
348
+ filename = File.join @target_path, "#{get_file_prefix}_users.json"
349
+ return unless File.exist? filename
350
+
351
+ json = file_system.load_json(filename)
352
+ json.each { |user_data| @users << User.new(raw: user_data) }
353
+ end
354
+
326
355
  def to_time string, end_of_day: false
327
356
  time = end_of_day ? '23:59:59' : '00:00:00'
328
357
  string = "#{string}T#{time}#{exporter.timezone_offset}" if string.match?(/^\d{4}-\d{2}-\d{2}$/)
@@ -356,7 +385,7 @@ class ProjectConfig
356
385
 
357
386
  # To be used by the aggregate_config only. Not intended to be part of the public API
358
387
  def add_issues issues_list
359
- @issues = [] if @issues.nil?
388
+ @issues = IssueCollection.new if @issues.nil?
360
389
  @all_boards = {}
361
390
 
362
391
  issues_list.each do |issue|
@@ -373,7 +402,7 @@ class ProjectConfig
373
402
  'declaration but none are here. Check your config.'
374
403
  end
375
404
 
376
- return @issues = [] if @exporter.downloading?
405
+ return @issues = IssueCollection.new if @exporter.downloading?
377
406
  raise 'No data found. Must do a download before an export' unless data_downloaded?
378
407
 
379
408
  load_data if all_boards.empty?
@@ -385,7 +414,7 @@ class ProjectConfig
385
414
  issues = load_issues_from_issues_directory path: issues_path, timezone_offset: timezone_offset
386
415
  else
387
416
  file_system.log "Can't find directory #{issues_path}. Has a download been done?", also_write_to_stderr: true
388
- return []
417
+ return IssueCollection.new
389
418
  end
390
419
 
391
420
  # Attach related issues
@@ -397,7 +426,8 @@ class ProjectConfig
397
426
 
398
427
  # We'll have some issues that are in the list that weren't part of the initial query. Once we've
399
428
  # attached them in the appropriate places, remove any that aren't part of that initial set.
400
- @issues = issues.select { |i| i.in_initial_query? }
429
+ issues.reject! { |i| !i.in_initial_query? } # rubocop:disable Style/InverseMethods
430
+ @issues = issues
401
431
  end
402
432
 
403
433
  @issues
@@ -438,7 +468,7 @@ class ProjectConfig
438
468
  end
439
469
 
440
470
  def load_issues_from_issues_directory path:, timezone_offset:
441
- issues = []
471
+ issues = IssueCollection.new
442
472
  default_board = nil
443
473
 
444
474
  group_filenames_and_board_ids(path: path).each do |filename, board_ids|
@@ -450,6 +480,10 @@ class ProjectConfig
450
480
  end
451
481
 
452
482
  boards.each do |board|
483
+ if board.cycletime.nil?
484
+ raise "The board declaration for board #{board.id} must come before the " \
485
+ "first usage of 'issues' in the configuration"
486
+ end
453
487
  issues << Issue.new(raw: content, timezone_offset: timezone_offset, board: board)
454
488
  end
455
489
  end
@@ -462,7 +496,7 @@ class ProjectConfig
462
496
  # board ids appropriately.
463
497
  def group_filenames_and_board_ids path:
464
498
  hash = {}
465
- Dir.foreach(path) do |filename|
499
+ file_system.foreach(path) do |filename|
466
500
  # Matches either FAKE-123.json or FAKE-123-456.json
467
501
  if /^(?<key>[^-]+-\d+)(?<_>-(?<board_id>\d+))?\.json$/ =~ filename
468
502
  (hash[key] ||= []) << [filename, board_id&.to_i || :unknown]
@@ -6,5 +6,6 @@
6
6
  "blocked_statuses": [],
7
7
  "flagged_means_blocked": true,
8
8
 
9
- "expedited_priority_names": ["Critical", "Highest"]
9
+ "expedited_priority_names": ["Critical", "Highest"],
10
+ "priority_order": ["Lowest", "Low", "Medium", "High", "Highest"]
10
11
  }
@@ -12,6 +12,7 @@ class Sprint
12
12
 
13
13
  def id = @raw['id']
14
14
  def active? = (@raw['state'] == 'active')
15
+ def closed? = (@raw['state'] == 'closed')
15
16
 
16
17
  def completed_at? time
17
18
  completed_at = completed_time
@@ -121,11 +121,13 @@ class SprintBurndown < ChartBase
121
121
 
122
122
  # select all the changes that are relevant for the sprint. If this issue never appears in this sprint then return [].
123
123
  def changes_for_one_issue issue:, sprint:
124
- story_points = 0.0
124
+ estimate = 0.0
125
125
  ever_in_sprint = false
126
126
  currently_in_sprint = false
127
127
  change_data = []
128
128
 
129
+ estimate_display_name = current_board.estimation_configuration.display_name
130
+
129
131
  issue_completed_time = issue.board.cycletime.started_stopped_times(issue).last
130
132
  completed_has_been_tracked = false
131
133
 
@@ -140,26 +142,26 @@ class SprintBurndown < ChartBase
140
142
  if currently_in_sprint == false && in_change_item
141
143
  action = :enter_sprint
142
144
  ever_in_sprint = true
143
- value = story_points
145
+ value = estimate
144
146
  elsif currently_in_sprint && in_change_item == false
145
147
  action = :leave_sprint
146
- value = -story_points
148
+ value = -estimate
147
149
  end
148
150
  currently_in_sprint = in_change_item
149
- elsif change.story_points? && (issue_completed_time.nil? || change.time < issue_completed_time)
151
+ elsif change.field == estimate_display_name && (issue_completed_time.nil? || change.time < issue_completed_time)
150
152
  action = :story_points
151
- story_points = change.value.to_f
152
- value = story_points - change.old_value.to_f
153
+ estimate = change.value.to_f
154
+ value = estimate - change.old_value.to_f
153
155
  elsif completed_has_been_tracked == false && change.time == issue_completed_time
154
156
  completed_has_been_tracked = true
155
157
  action = :issue_stopped
156
- value = -story_points
158
+ value = -estimate
157
159
  end
158
160
 
159
161
  next unless action
160
162
 
161
163
  change_data << SprintIssueChangeData.new(
162
- time: change.time, issue: issue, action: action, value: value, story_points: story_points
164
+ time: change.time, issue: issue, action: action, value: value, estimate: estimate
163
165
  )
164
166
  end
165
167
 
@@ -176,7 +178,7 @@ class SprintBurndown < ChartBase
176
178
  summary_stats = SprintSummaryStats.new
177
179
  summary_stats.completed = 0.0
178
180
 
179
- story_points = 0.0
181
+ estimate = 0.0
180
182
  start_data_written = false
181
183
  data_set = []
182
184
 
@@ -185,11 +187,11 @@ class SprintBurndown < ChartBase
185
187
  change_data_for_sprint.each do |change_data|
186
188
  if start_data_written == false && change_data.time >= sprint.start_time
187
189
  data_set << {
188
- y: story_points,
190
+ y: estimate,
189
191
  x: chart_format(sprint.start_time),
190
- title: "Sprint started with #{story_points} points"
192
+ title: "Sprint started with #{estimate} points"
191
193
  }
192
- summary_stats.started = story_points
194
+ summary_stats.started = estimate
193
195
  start_data_written = true
194
196
  end
195
197
 
@@ -198,12 +200,12 @@ class SprintBurndown < ChartBase
198
200
  case change_data.action
199
201
  when :enter_sprint
200
202
  issues_currently_in_sprint << change_data.issue.key
201
- story_points += change_data.story_points
203
+ estimate += change_data.estimate
202
204
  when :leave_sprint
203
205
  issues_currently_in_sprint.delete change_data.issue.key
204
- story_points -= change_data.story_points
206
+ estimate -= change_data.estimate
205
207
  when :story_points
206
- story_points += change_data.value if issues_currently_in_sprint.include? change_data.issue.key
208
+ estimate += change_data.value if issues_currently_in_sprint.include? change_data.issue.key
207
209
  end
208
210
 
209
211
  next unless change_data.time >= sprint.start_time
@@ -213,26 +215,26 @@ class SprintBurndown < ChartBase
213
215
  when :story_points
214
216
  next unless issues_currently_in_sprint.include? change_data.issue.key
215
217
 
216
- old_story_points = change_data.story_points - change_data.value
217
- message = "Story points changed from #{old_story_points} points to #{change_data.story_points} points"
218
+ old_estimate = change_data.estimate - change_data.value
219
+ message = "Story points changed from #{old_estimate} points to #{change_data.estimate} points"
218
220
  summary_stats.points_values_changed = true
219
221
  when :enter_sprint
220
- message = "Added to sprint with #{change_data.story_points || 'no'} points"
221
- summary_stats.added += change_data.story_points
222
+ message = "Added to sprint with #{change_data.estimate || 'no'} points"
223
+ summary_stats.added += change_data.estimate
222
224
  when :issue_stopped
223
- story_points -= change_data.story_points
224
- message = "Completed with #{change_data.story_points || 'no'} points"
225
+ estimate -= change_data.estimate
226
+ message = "Completed with #{change_data.estimate || 'no'} points"
225
227
  issues_currently_in_sprint.delete change_data.issue.key
226
- summary_stats.completed += change_data.story_points
228
+ summary_stats.completed += change_data.estimate
227
229
  when :leave_sprint
228
- message = "Removed from sprint with #{change_data.story_points || 'no'} points"
229
- summary_stats.removed += change_data.story_points
230
+ message = "Removed from sprint with #{change_data.estimate || 'no'} points"
231
+ summary_stats.removed += change_data.estimate
230
232
  else
231
233
  raise "Unexpected action: #{change_data.action}"
232
234
  end
233
235
 
234
236
  data_set << {
235
- y: story_points,
237
+ y: estimate,
236
238
  x: chart_format(change_data.time),
237
239
  title: "#{change_data.issue.key} #{message}"
238
240
  }
@@ -241,27 +243,27 @@ class SprintBurndown < ChartBase
241
243
  unless start_data_written
242
244
  # There was nothing that triggered us to write the sprint started block so do it now.
243
245
  data_set << {
244
- y: story_points,
246
+ y: estimate,
245
247
  x: chart_format(sprint.start_time),
246
- title: "Sprint started with #{story_points} points"
248
+ title: "Sprint started with #{estimate} points"
247
249
  }
248
- summary_stats.started = story_points
250
+ summary_stats.started = estimate
249
251
  end
250
252
 
251
253
  if sprint.completed_time
252
254
  data_set << {
253
- y: story_points,
255
+ y: estimate,
254
256
  x: chart_format(sprint.completed_time),
255
- title: "Sprint ended with #{story_points} points unfinished"
257
+ title: "Sprint ended with #{estimate} points unfinished"
256
258
  }
257
- summary_stats.remaining = story_points
259
+ summary_stats.remaining = estimate
258
260
  end
259
261
 
260
262
  unless sprint.completed_at?(time_range.end)
261
263
  data_set << {
262
- y: story_points,
264
+ y: estimate,
263
265
  x: chart_format(time_range.end),
264
- title: "Sprint still active. #{story_points} points still in progress."
266
+ title: "Sprint still active. #{estimate} points still in progress."
265
267
  }
266
268
  end
267
269
 
@@ -4,14 +4,14 @@ require 'jirametrics/value_equality'
4
4
 
5
5
  class SprintIssueChangeData
6
6
  include ValueEquality
7
- attr_reader :time, :action, :value, :issue, :story_points
7
+ attr_reader :time, :action, :value, :issue, :estimate
8
8
 
9
- def initialize time:, action:, value:, issue:, story_points:
9
+ def initialize time:, action:, value:, issue:, estimate:
10
10
  @time = time
11
11
  @action = action
12
12
  @value = value
13
13
  @issue = issue
14
- @story_points = story_points
14
+ @estimate = estimate
15
15
  end
16
16
 
17
17
  def inspect
@@ -16,6 +16,13 @@ class StatusCollection
16
16
  @list.find { |status| status.id == id }
17
17
  end
18
18
 
19
+ def find_by_id! id
20
+ status = @list.find { |status| status.id == id }
21
+ raise "Can't find any status for id #{id} in #{self}" unless status
22
+
23
+ status
24
+ end
25
+
19
26
  def find_all_by_name identifier
20
27
  name, id = parse_name_id identifier
21
28
 
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ class User
4
+ def initialize raw:
5
+ @raw = raw
6
+ end
7
+
8
+ def account_id = @raw['accountId']
9
+ def avatar_url = @raw['avatarUrls']['16x16']
10
+ def active? = @raw['active']
11
+ def display_name = @raw['displayName']
12
+ end
data/lib/jirametrics.rb CHANGED
@@ -69,6 +69,7 @@ class JiraMetrics < Thor
69
69
  require 'jirametrics/daily_wip_chart'
70
70
  require 'jirametrics/groupable_issue_chart'
71
71
  require 'jirametrics/css_variable'
72
+ require 'jirametrics/issue_collection'
72
73
 
73
74
  require 'jirametrics/aggregate_config'
74
75
  require 'jirametrics/expedited_chart'
@@ -112,7 +113,11 @@ class JiraMetrics < Thor
112
113
  require 'jirametrics/download_config'
113
114
  require 'jirametrics/columns_config'
114
115
  require 'jirametrics/hierarchy_table'
116
+ require 'jirametrics/estimation_configuration'
115
117
  require 'jirametrics/board'
118
+ require 'jirametrics/daily_view'
119
+ require 'jirametrics/user'
120
+ require 'jirametrics/atlassian_document_format'
116
121
  load config_file
117
122
  end
118
123
  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.11'
4
+ version: '2.14'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Bowler
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-03-11 00:00:00.000000000 Z
10
+ date: 2025-08-18 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: random-word
@@ -65,6 +65,7 @@ files:
65
65
  - lib/jirametrics/aging_work_in_progress_chart.rb
66
66
  - lib/jirametrics/aging_work_table.rb
67
67
  - lib/jirametrics/anonymizer.rb
68
+ - lib/jirametrics/atlassian_document_format.rb
68
69
  - lib/jirametrics/blocked_stalled_change.rb
69
70
  - lib/jirametrics/board.rb
70
71
  - lib/jirametrics/board_column.rb
@@ -77,6 +78,7 @@ files:
77
78
  - lib/jirametrics/cycletime_config.rb
78
79
  - lib/jirametrics/cycletime_histogram.rb
79
80
  - lib/jirametrics/cycletime_scatterplot.rb
81
+ - lib/jirametrics/daily_view.rb
80
82
  - lib/jirametrics/daily_wip_by_age_chart.rb
81
83
  - lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb
82
84
  - lib/jirametrics/daily_wip_by_parent_chart.rb
@@ -86,6 +88,7 @@ files:
86
88
  - lib/jirametrics/download_config.rb
87
89
  - lib/jirametrics/downloader.rb
88
90
  - lib/jirametrics/estimate_accuracy_chart.rb
91
+ - lib/jirametrics/estimation_configuration.rb
89
92
  - lib/jirametrics/examples/aggregated_project.rb
90
93
  - lib/jirametrics/examples/standard_project.rb
91
94
  - lib/jirametrics/expedited_chart.rb
@@ -114,6 +117,7 @@ files:
114
117
  - lib/jirametrics/html/throughput_chart.erb
115
118
  - lib/jirametrics/html_report_config.rb
116
119
  - lib/jirametrics/issue.rb
120
+ - lib/jirametrics/issue_collection.rb
117
121
  - lib/jirametrics/issue_link.rb
118
122
  - lib/jirametrics/jira_gateway.rb
119
123
  - lib/jirametrics/project_config.rb
@@ -128,6 +132,7 @@ files:
128
132
  - lib/jirametrics/throughput_chart.rb
129
133
  - lib/jirametrics/tree_organizer.rb
130
134
  - lib/jirametrics/trend_line_calculator.rb
135
+ - lib/jirametrics/user.rb
131
136
  - lib/jirametrics/value_equality.rb
132
137
  homepage: https://jirametrics.org
133
138
  licenses: