jirametrics 2.22 → 2.27

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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +10 -2
  3. data/lib/jirametrics/aging_work_bar_chart.rb +20 -6
  4. data/lib/jirametrics/aging_work_table.rb +4 -5
  5. data/lib/jirametrics/anonymizer.rb +74 -1
  6. data/lib/jirametrics/atlassian_document_format.rb +93 -93
  7. data/lib/jirametrics/blocked_stalled_change.rb +5 -3
  8. data/lib/jirametrics/board.rb +20 -8
  9. data/lib/jirametrics/board_feature.rb +14 -0
  10. data/lib/jirametrics/board_movement_calculator.rb +2 -2
  11. data/lib/jirametrics/cfd_data_builder.rb +108 -0
  12. data/lib/jirametrics/change_item.rb +4 -3
  13. data/lib/jirametrics/chart_base.rb +94 -2
  14. data/lib/jirametrics/css_variable.rb +1 -1
  15. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  16. data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +1 -2
  17. data/lib/jirametrics/cycletime_histogram.rb +15 -103
  18. data/lib/jirametrics/cycletime_scatterplot.rb +13 -98
  19. data/lib/jirametrics/daily_view.rb +36 -12
  20. data/lib/jirametrics/daily_wip_by_age_chart.rb +1 -1
  21. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +1 -1
  22. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
  23. data/lib/jirametrics/daily_wip_chart.rb +29 -7
  24. data/lib/jirametrics/data_quality_report.rb +38 -12
  25. data/lib/jirametrics/dependency_chart.rb +2 -2
  26. data/lib/jirametrics/download_config.rb +15 -0
  27. data/lib/jirametrics/downloader.rb +87 -5
  28. data/lib/jirametrics/downloader_for_cloud.rb +52 -10
  29. data/lib/jirametrics/downloader_for_data_center.rb +2 -1
  30. data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
  31. data/lib/jirametrics/examples/aggregated_project.rb +2 -2
  32. data/lib/jirametrics/examples/standard_project.rb +29 -19
  33. data/lib/jirametrics/expedited_chart.rb +3 -1
  34. data/lib/jirametrics/exporter.rb +3 -1
  35. data/lib/jirametrics/file_system.rb +35 -2
  36. data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
  37. data/lib/jirametrics/github_gateway.rb +115 -0
  38. data/lib/jirametrics/groupable_issue_chart.rb +4 -0
  39. data/lib/jirametrics/grouping_rules.rb +26 -4
  40. data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -4
  41. data/lib/jirametrics/html/aging_work_table.erb +3 -0
  42. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  43. data/lib/jirametrics/html/daily_wip_chart.erb +38 -5
  44. data/lib/jirametrics/html/estimate_accuracy_chart.erb +2 -12
  45. data/lib/jirametrics/html/expedited_chart.erb +3 -13
  46. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +2 -8
  47. data/lib/jirametrics/html/index.css +117 -0
  48. data/lib/jirametrics/html/index.erb +6 -0
  49. data/lib/jirametrics/html/index.js +52 -2
  50. data/lib/jirametrics/html/sprint_burndown.erb +7 -13
  51. data/lib/jirametrics/html/throughput_chart.erb +40 -9
  52. data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +59 -59
  53. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +11 -7
  54. data/lib/jirametrics/html_generator.rb +2 -1
  55. data/lib/jirametrics/html_report_config.rb +23 -16
  56. data/lib/jirametrics/issue.rb +101 -96
  57. data/lib/jirametrics/issue_printer.rb +97 -0
  58. data/lib/jirametrics/jira_gateway.rb +6 -3
  59. data/lib/jirametrics/mcp_server.rb +305 -0
  60. data/lib/jirametrics/project_config.rb +80 -7
  61. data/lib/jirametrics/pull_request.rb +30 -0
  62. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  63. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  64. data/lib/jirametrics/pull_request_review.rb +13 -0
  65. data/lib/jirametrics/raw_javascript.rb +4 -0
  66. data/lib/jirametrics/settings.json +3 -1
  67. data/lib/jirametrics/sprint_burndown.rb +3 -1
  68. data/lib/jirametrics/status.rb +1 -1
  69. data/lib/jirametrics/stitcher.rb +7 -1
  70. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  71. data/lib/jirametrics/throughput_chart.rb +73 -23
  72. data/lib/jirametrics/time_based_histogram.rb +139 -0
  73. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  74. data/lib/jirametrics.rb +28 -0
  75. metadata +47 -5
@@ -0,0 +1,305 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mcp'
4
+ require 'mcp/server/transports/stdio_transport'
5
+
6
+ class McpServer
7
+ def initialize projects:, timezone_offset: '+00:00'
8
+ @projects = projects
9
+ @timezone_offset = timezone_offset
10
+ end
11
+
12
+ def run
13
+ server = MCP::Server.new(
14
+ name: 'jirametrics',
15
+ version: Gem.loaded_specs['jirametrics']&.version&.to_s || '0.0.0',
16
+ tools: [AgingWorkTool, CompletedWorkTool, NotYetStartedTool],
17
+ server_context: { projects: @projects, timezone_offset: @timezone_offset }
18
+ )
19
+
20
+ transport = MCP::Server::Transports::StdioTransport.new(server)
21
+ transport.open
22
+ end
23
+
24
+ HISTORY_FILTER_SCHEMA = {
25
+ history_field: {
26
+ type: 'string',
27
+ description: 'When combined with history_value, only return issues where this field ever had that value ' \
28
+ '(e.g. "priority", "status"). Both history_field and history_value must be provided together.'
29
+ },
30
+ history_value: {
31
+ type: 'string',
32
+ description: 'The value to look for in the change history of history_field (e.g. "Highest", "Done").'
33
+ },
34
+ ever_blocked: {
35
+ type: 'boolean',
36
+ description: 'When true, only return issues that were ever blocked. Blocked includes flagged items, ' \
37
+ 'issues in blocked statuses, and blocking issue links.'
38
+ },
39
+ ever_stalled: {
40
+ type: 'boolean',
41
+ description: 'When true, only return issues that were ever stalled. Stalled means the issue sat ' \
42
+ 'inactive for longer than the stalled threshold, or entered a stalled status.'
43
+ },
44
+ currently_blocked: {
45
+ type: 'boolean',
46
+ description: 'When true, only return issues that are currently blocked (as of the data end date).'
47
+ },
48
+ currently_stalled: {
49
+ type: 'boolean',
50
+ description: 'When true, only return issues that are currently stalled (as of the data end date).'
51
+ }
52
+ }.freeze
53
+
54
+ def self.flow_efficiency_percent issue, end_time
55
+ active_time, total_time = issue.flow_efficiency_numbers(end_time: end_time)
56
+ total_time.positive? ? (active_time / total_time * 100).round(1) : nil
57
+ end
58
+
59
+ def self.matches_blocked_stalled?(bsc, ever_blocked, ever_stalled, currently_blocked, currently_stalled)
60
+ return false if ever_blocked && bsc.none?(&:blocked?)
61
+ return false if ever_stalled && bsc.none?(&:stalled?)
62
+ return false if currently_blocked && !bsc.last&.blocked?
63
+ return false if currently_stalled && !bsc.last&.stalled?
64
+
65
+ true
66
+ end
67
+
68
+ def self.matches_history?(issue, end_time, history_field, history_value,
69
+ ever_blocked, ever_stalled, currently_blocked, currently_stalled)
70
+ return false if history_field && history_value &&
71
+ issue.changes.none? { |c| c.field == history_field && c.value == history_value }
72
+
73
+ if ever_blocked || ever_stalled || currently_blocked || currently_stalled
74
+ bsc = issue.blocked_stalled_changes(end_time: end_time)
75
+ return false unless matches_blocked_stalled?(bsc, ever_blocked, ever_stalled,
76
+ currently_blocked, currently_stalled)
77
+ end
78
+
79
+ true
80
+ end
81
+
82
+ class AgingWorkTool < MCP::Tool
83
+ tool_name 'aging_work'
84
+ description 'Returns all issues that have been started but not yet completed (work in progress), ' \
85
+ 'sorted from oldest to newest. Age is the number of days since the issue was started.'
86
+
87
+ input_schema(
88
+ type: 'object',
89
+ properties: {
90
+ min_age_days: {
91
+ type: 'integer',
92
+ description: 'Only return issues at least this many days old. Omit to return all ages.'
93
+ },
94
+ project: {
95
+ type: 'string',
96
+ description: 'Only return issues from this project name. Omit to return all projects.'
97
+ },
98
+ **HISTORY_FILTER_SCHEMA
99
+ }
100
+ )
101
+
102
+ def self.call(server_context:, min_age_days: nil, project: nil,
103
+ history_field: nil, history_value: nil, ever_blocked: nil, ever_stalled: nil,
104
+ currently_blocked: nil, currently_stalled: nil)
105
+ rows = []
106
+
107
+ server_context[:projects].each do |project_name, project_data|
108
+ next if project && project_name != project
109
+
110
+ today = project_data[:today]
111
+ project_data[:issues].each do |issue|
112
+ started, stopped = issue.started_stopped_times
113
+ next unless started && !stopped
114
+
115
+ age = (today - started.to_date).to_i + 1
116
+ next if min_age_days && age < min_age_days
117
+ unless McpServer.matches_history?(issue, project_data[:end_time],
118
+ history_field, history_value, ever_blocked, ever_stalled,
119
+ currently_blocked, currently_stalled)
120
+ next
121
+ end
122
+
123
+ rows << {
124
+ key: issue.key,
125
+ summary: issue.summary,
126
+ status: issue.status.name,
127
+ type: issue.type,
128
+ age_days: age,
129
+ flow_efficiency: McpServer.flow_efficiency_percent(issue, project_data[:end_time]),
130
+ project: project_name
131
+ }
132
+ end
133
+ end
134
+
135
+ rows.sort_by! { |r| -r[:age_days] }
136
+
137
+ if rows.empty?
138
+ text = 'No aging work found.'
139
+ else
140
+ lines = rows.map do |r|
141
+ fe = r[:flow_efficiency] ? " | FE: #{r[:flow_efficiency]}%" : ''
142
+ "#{r[:key]} | #{r[:project]} | #{r[:type]} | #{r[:status]} | Age: #{r[:age_days]}d#{fe} | #{r[:summary]}"
143
+ end
144
+ text = lines.join("\n")
145
+ end
146
+
147
+ MCP::Tool::Response.new([{ type: 'text', text: text }])
148
+ end
149
+ end
150
+
151
+ class CompletedWorkTool < MCP::Tool
152
+ tool_name 'completed_work'
153
+ description 'Returns issues that have been completed, sorted most recently completed first. ' \
154
+ 'Includes cycle time (days from start to completion).'
155
+
156
+ input_schema(
157
+ type: 'object',
158
+ properties: {
159
+ days_back: {
160
+ type: 'integer',
161
+ description: 'Only return issues completed within this many days of the data end date. Omit to return all.'
162
+ },
163
+ project: {
164
+ type: 'string',
165
+ description: 'Only return issues from this project name. Omit to return all projects.'
166
+ },
167
+ completed_status: {
168
+ type: 'string',
169
+ description: 'Only return issues whose status at completion matches this value (e.g. "Cancelled", "Done").'
170
+ },
171
+ completed_resolution: {
172
+ type: 'string',
173
+ description: 'Only return issues whose resolution at completion matches this value (e.g. "Won\'t Do").'
174
+ },
175
+ **HISTORY_FILTER_SCHEMA
176
+ }
177
+ )
178
+
179
+ def self.build_row issue, project_name, started, stopped, cutoff, completed_status, completed_resolution,
180
+ end_time, history_field, history_value, ever_blocked, ever_stalled,
181
+ currently_blocked, currently_stalled
182
+ completed_date = stopped.to_date
183
+ return nil if cutoff && completed_date < cutoff
184
+
185
+ status_at_done, resolution_at_done = issue.status_resolution_at_done
186
+ return nil if completed_status && status_at_done&.name != completed_status
187
+ return nil if completed_resolution && completed_resolution != resolution_at_done
188
+ return nil unless McpServer.matches_history?(issue, end_time,
189
+ history_field, history_value, ever_blocked, ever_stalled,
190
+ currently_blocked, currently_stalled)
191
+
192
+ cycle_time = started ? (completed_date - started.to_date).to_i + 1 : nil
193
+ {
194
+ key: issue.key,
195
+ summary: issue.summary,
196
+ type: issue.type,
197
+ completed_date: completed_date,
198
+ cycle_time_days: cycle_time,
199
+ flow_efficiency: McpServer.flow_efficiency_percent(issue, stopped),
200
+ status_at_done: status_at_done&.name,
201
+ resolution_at_done: resolution_at_done,
202
+ project: project_name
203
+ }
204
+ end
205
+
206
+ def self.call(server_context:, days_back: nil, project: nil,
207
+ completed_status: nil, completed_resolution: nil,
208
+ history_field: nil, history_value: nil, ever_blocked: nil, ever_stalled: nil,
209
+ currently_blocked: nil, currently_stalled: nil)
210
+ rows = []
211
+
212
+ server_context[:projects].each do |project_name, project_data|
213
+ next if project && project_name != project
214
+
215
+ today = project_data[:today]
216
+ cutoff = today - days_back if days_back
217
+
218
+ project_data[:issues].each do |issue|
219
+ started, stopped = issue.started_stopped_times
220
+ next unless stopped
221
+
222
+ row = build_row(issue, project_name, started, stopped, cutoff, completed_status, completed_resolution,
223
+ project_data[:end_time], history_field, history_value, ever_blocked, ever_stalled,
224
+ currently_blocked, currently_stalled)
225
+ rows << row if row
226
+ end
227
+ end
228
+
229
+ rows.sort_by! { |r| -r[:completed_date].to_time.to_i }
230
+
231
+ if rows.empty?
232
+ text = 'No completed work found.'
233
+ else
234
+ lines = rows.map do |r|
235
+ ct = r[:cycle_time_days] ? "#{r[:cycle_time_days]}d" : 'unknown'
236
+ fe = r[:flow_efficiency] ? " | FE: #{r[:flow_efficiency]}%" : ''
237
+ completion = [r[:status_at_done], r[:resolution_at_done]].compact.join(' / ')
238
+ "#{r[:key]} | #{r[:project]} | #{r[:type]} | #{r[:completed_date]} | " \
239
+ "Cycle time: #{ct}#{fe} | #{completion} | #{r[:summary]}"
240
+ end
241
+ text = lines.join("\n")
242
+ end
243
+
244
+ MCP::Tool::Response.new([{ type: 'text', text: text }])
245
+ end
246
+ end
247
+
248
+ class NotYetStartedTool < MCP::Tool
249
+ tool_name 'not_yet_started'
250
+ description 'Returns issues that have not yet been started (backlog items), sorted by creation date oldest first.'
251
+
252
+ input_schema(
253
+ type: 'object',
254
+ properties: {
255
+ project: {
256
+ type: 'string',
257
+ description: 'Only return issues from this project name. Omit to return all projects.'
258
+ },
259
+ **HISTORY_FILTER_SCHEMA
260
+ }
261
+ )
262
+
263
+ def self.call(server_context:, project: nil,
264
+ history_field: nil, history_value: nil, ever_blocked: nil, ever_stalled: nil,
265
+ currently_blocked: nil, currently_stalled: nil)
266
+ rows = []
267
+
268
+ server_context[:projects].each do |project_name, project_data|
269
+ next if project && project_name != project
270
+
271
+ project_data[:issues].each do |issue|
272
+ started, stopped = issue.started_stopped_times
273
+ next if started || stopped
274
+ unless McpServer.matches_history?(issue, project_data[:end_time],
275
+ history_field, history_value, ever_blocked, ever_stalled,
276
+ currently_blocked, currently_stalled)
277
+ next
278
+ end
279
+
280
+ rows << {
281
+ key: issue.key,
282
+ summary: issue.summary,
283
+ status: issue.status.name,
284
+ type: issue.type,
285
+ created: issue.created.to_date,
286
+ project: project_name
287
+ }
288
+ end
289
+ end
290
+
291
+ rows.sort_by! { |r| r[:created] }
292
+
293
+ if rows.empty?
294
+ text = 'No unstarted work found.'
295
+ else
296
+ lines = rows.map do |r|
297
+ "#{r[:key]} | #{r[:project]} | #{r[:type]} | #{r[:status]} | Created: #{r[:created]} | #{r[:summary]}"
298
+ end
299
+ text = lines.join("\n")
300
+ end
301
+
302
+ MCP::Tool::Response.new([{ type: 'text', text: text }])
303
+ end
304
+ end
305
+ end
@@ -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, :users
9
+ :settings, :aggregate_config, :discarded_changes_data, :users, :fix_versions
10
10
  attr_accessor :time_range, :jira_url, :id
11
11
 
12
12
  def initialize exporter:, jira_config:, block:, target_path: '.', name: '', id: nil
@@ -23,6 +23,7 @@ class ProjectConfig
23
23
  @settings = load_settings
24
24
  @id = id
25
25
  @has_loaded_data = false
26
+ @fix_versions = []
26
27
  end
27
28
 
28
29
  def evaluate_next_level
@@ -40,17 +41,20 @@ class ProjectConfig
40
41
  @id = guess_project_id
41
42
  load_project_metadata
42
43
  load_sprints
44
+ load_fix_versions
43
45
  load_users
46
+ resolve_blocked_stalled_status_settings
44
47
  end
45
48
 
46
49
  def run load_only: false
47
50
  return if @exporter.downloading?
48
51
 
49
52
  load_data unless aggregated_project?
50
- anonymize_data if @anonymizer_needed
51
53
 
52
54
  return if load_only
53
55
 
56
+ anonymize_data if @anonymizer_needed
57
+
54
58
  @file_configs.each do |file_config|
55
59
  file_config.run
56
60
  end
@@ -67,7 +71,10 @@ class ProjectConfig
67
71
  file_system.deprecated message: 'stalled color should be set via css now', date: '2024-05-03'
68
72
  end
69
73
 
70
- settings
74
+ settings['blocked_statuses'] = StatusCollection.new
75
+ settings['stalled_statuses'] = StatusCollection.new
76
+
77
+ stringify_keys(settings)
71
78
  end
72
79
 
73
80
  def guess_project_id
@@ -143,6 +150,17 @@ class ProjectConfig
143
150
  @file_prefix
144
151
  end
145
152
 
153
+ def validate_discard_status status_name
154
+ return if status_name == :backlog
155
+ return if possible_statuses.empty? # not yet downloaded; skip validation
156
+
157
+ found = possible_statuses.find_all_by_name status_name
158
+ return unless found.empty?
159
+
160
+ raise "discard_changes_before: Status #{status_name.inspect} not found. " \
161
+ "Possible statuses are: #{possible_statuses}"
162
+ end
163
+
146
164
  def raise_if_prefix_already_used prefix
147
165
  @exporter.project_configs.each do |project|
148
166
  next unless project.get_file_prefix(raise_if_not_set: false) == prefix && project.target_path == target_path
@@ -267,9 +285,16 @@ class ProjectConfig
267
285
  end
268
286
 
269
287
  def load_board board_id:, filename:
270
- board = Board.new(
271
- raw: file_system.load_json(filename), possible_statuses: @possible_statuses
272
- )
288
+ raw = file_system.load_json(filename)
289
+
290
+ features_filename = File.join(@target_path, "#{get_file_prefix}_board_#{board_id}_features.json")
291
+ features = if file_system.file_exist?(features_filename)
292
+ BoardFeature.from_raw(file_system.load_json(features_filename))
293
+ else
294
+ []
295
+ end
296
+
297
+ board = Board.new(raw: raw, possible_statuses: @possible_statuses, features: features)
273
298
  board.project_config = self
274
299
  @all_boards[board_id] = board
275
300
  end
@@ -326,6 +351,13 @@ class ProjectConfig
326
351
  end
327
352
  end
328
353
 
354
+ def load_fix_versions
355
+ filename = File.join(@target_path, "#{get_file_prefix}_fix_versions.json")
356
+ return unless file_system.file_exist?(filename)
357
+
358
+ @fix_versions = file_system.load_json(filename).map { |raw| FixVersion.new(raw) }
359
+ end
360
+
329
361
  def load_project_metadata
330
362
  filename = File.join @target_path, "#{get_file_prefix}_meta.json"
331
363
  json = file_system.load_json(filename)
@@ -362,6 +394,19 @@ class ProjectConfig
362
394
  json.each { |user_data| @users << User.new(raw: user_data) }
363
395
  end
364
396
 
397
+ def attach_github_prs
398
+ filename = File.join(@target_path, "#{get_file_prefix}_github_prs.json")
399
+ return unless File.exist?(filename)
400
+
401
+ prs_by_issue_key = Hash.new { |h, k| h[k] = [] }
402
+ file_system.load_json(filename).each do |raw|
403
+ pr = PullRequest.new(raw: raw)
404
+ pr.issue_keys.each { |key| prs_by_issue_key[key] << pr }
405
+ end
406
+
407
+ @issues.each { |issue| issue.github_prs = prs_by_issue_key[issue.key] }
408
+ end
409
+
365
410
  def atlassian_document_format
366
411
  @atlassian_document_format ||= AtlassianDocumentFormat.new(
367
412
  users: @users, timezone_offset: exporter.timezone_offset
@@ -444,6 +489,7 @@ class ProjectConfig
444
489
  # attached them in the appropriate places, remove any that aren't part of that initial set.
445
490
  issues.reject! { |i| !i.in_initial_query? } # rubocop:disable Style/InverseMethods
446
491
  @issues = issues
492
+ attach_github_prs
447
493
  end
448
494
 
449
495
  @issues
@@ -563,6 +609,8 @@ class ProjectConfig
563
609
  if status_becomes
564
610
  status_becomes = [status_becomes] unless status_becomes.is_a? Array
565
611
 
612
+ status_becomes.each { |status_name| validate_discard_status status_name }
613
+
566
614
  block = lambda do |issue|
567
615
  trigger_statuses = status_becomes.collect do |status_name|
568
616
  if status_name == :backlog
@@ -588,7 +636,7 @@ class ProjectConfig
588
636
  cutoff_time = block.call(issue)
589
637
  next if cutoff_time.nil?
590
638
 
591
- original_start_time = issue.board.cycletime.started_stopped_times(issue).first
639
+ original_start_time = issue.started_stopped_times.first
592
640
  next if original_start_time.nil?
593
641
 
594
642
  issue.discard_changes_before cutoff_time
@@ -606,4 +654,29 @@ class ProjectConfig
606
654
 
607
655
  cycletimes_touched.each { |c| c.flush_cache }
608
656
  end
657
+
658
+ def stringify_keys value
659
+ case value
660
+ when Hash then value.transform_keys(&:to_s).transform_values { |v| stringify_keys(v) }
661
+ when Array then value.map { |v| stringify_keys(v) }
662
+ else value
663
+ end
664
+ end
665
+
666
+ def resolve_blocked_stalled_status_settings
667
+ %w[blocked_statuses stalled_statuses].each do |key|
668
+ next if @settings[key].is_a?(StatusCollection)
669
+
670
+ collection = StatusCollection.new
671
+ @settings[key].each do |identifier|
672
+ statuses = @possible_statuses.find_all_by_name(identifier)
673
+ if statuses.empty?
674
+ file_system.warning "Status #{identifier.inspect} in #{key} not found. Ignoring."
675
+ else
676
+ statuses.each { |status| collection << status }
677
+ end
678
+ end
679
+ @settings[key] = collection
680
+ end
681
+ end
609
682
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'jirametrics/pull_request_review'
5
+
6
+ class PullRequest
7
+ attr_reader :raw
8
+
9
+ def initialize raw:
10
+ @raw = raw
11
+ end
12
+
13
+ def number = @raw['number']
14
+ def repo = @raw['repo']
15
+ def url = @raw['url']
16
+ def title = @raw['title']
17
+ def branch = @raw['branch']
18
+ def state = @raw['state']
19
+ def issue_keys = @raw['issue_keys']
20
+
21
+ def opened_at = Time.parse(@raw['opened_at'])
22
+ def closed_at = @raw['closed_at'] ? Time.parse(@raw['closed_at']) : nil
23
+ def merged_at = @raw['merged_at'] ? Time.parse(@raw['merged_at']) : nil
24
+
25
+ def reviews = (@raw['reviews'] || []).map { |r| PullRequestReview.new(raw: r) }
26
+ def additions = @raw['additions']
27
+ def deletions = @raw['deletions']
28
+ def changed_files = @raw['changed_files']
29
+ def lines_changed = (additions || 0) + (deletions || 0)
30
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/groupable_issue_chart'
4
+
5
+ class PullRequestCycleTimeHistogram < TimeBasedHistogram
6
+ def initialize block
7
+ super()
8
+
9
+ @cycletime_unit = :days
10
+ @x_axis_title = 'Cycle time in days'
11
+
12
+ header_text 'PR Histogram'
13
+ description_text <<-HTML
14
+ <div class="p">
15
+ This cycletime Histogram shows how many pull requests completed in a certain timeframe. This can be
16
+ useful for determining how many different types of work are flowing through, based on the
17
+ lengths of time they take.
18
+ </div>
19
+ HTML
20
+
21
+ init_configuration_block(block) do
22
+ grouping_rules do |pull_request, rule|
23
+ rule.label = pull_request.repo
24
+ end
25
+ end
26
+ end
27
+
28
+ def cycletime_unit unit
29
+ unless %i[minutes hours days].include?(unit)
30
+ raise ArgumentError, "cycletime_unit must be :minutes, :hours, or :days, got #{unit.inspect}"
31
+ end
32
+
33
+ @cycletime_unit = unit
34
+ @x_axis_title = "Cycle time in #{unit}"
35
+ end
36
+
37
+ def all_items
38
+ result = []
39
+ issues.each do |issue|
40
+ next unless issue.github_prs
41
+
42
+ issue.github_prs.each do |pr|
43
+ next unless pr.closed_at
44
+
45
+ result << pr
46
+ end
47
+ end
48
+ result.uniq
49
+ end
50
+
51
+ def value_for_item item
52
+ divisor = { minutes: 60.0, hours: 3600.0, days: 86_400.0 }[@cycletime_unit]
53
+ ((item.closed_at - item.opened_at) / divisor).ceil
54
+ end
55
+
56
+ def label_cycletime value
57
+ case @cycletime_unit
58
+ when :minutes then label_minutes(value)
59
+ when :hours then label_hours(value)
60
+ when :days then label_days(value)
61
+ end
62
+ end
63
+
64
+ def title_for_item count:, value:
65
+ "#{count} PR#{'s' unless count == 1} closed in #{label_cycletime value}"
66
+ end
67
+
68
+ def sort_items items
69
+ items.sort_by(&:opened_at)
70
+ end
71
+
72
+ def label_for_item item, hint:
73
+ label = "#{item.number} #{item.title}"
74
+ label << hint if hint
75
+ label
76
+ end
77
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/groupable_issue_chart'
4
+
5
+ class PullRequestCycleTimeScatterplot < TimeBasedScatterplot
6
+ def initialize block
7
+ super()
8
+
9
+ @cycletime_unit = :days
10
+ @y_axis_title = 'Cycle time in days'
11
+
12
+ header_text 'Pull Request (PR) Scatterplot'
13
+ description_text <<-HTML
14
+ <div class="p">
15
+ This graph shows the cycle time for all closed pull requests (time from opened to closed).
16
+ </div>
17
+ #{describe_non_working_days}
18
+ HTML
19
+
20
+ init_configuration_block(block) do
21
+ grouping_rules do |pull_request, rule|
22
+ rule.label = pull_request.repo
23
+ end
24
+ end
25
+ end
26
+
27
+ def cycletime_unit unit
28
+ unless %i[minutes hours days].include?(unit)
29
+ raise ArgumentError, "cycletime_unit must be :minutes, :hours, or :days, got #{unit.inspect}"
30
+ end
31
+
32
+ @cycletime_unit = unit
33
+ @y_axis_title = "Cycle time in #{unit}"
34
+ end
35
+
36
+ def all_items
37
+ result = []
38
+ issues.each do |issue|
39
+ issue.github_prs&.each do |pr|
40
+ result << pr if pr.closed_at
41
+ end
42
+ end
43
+ result
44
+ end
45
+
46
+ def x_value pull_request
47
+ pull_request.closed_at
48
+ end
49
+
50
+ def y_value pull_request
51
+ if @cycletime_unit == :days
52
+ tz = timezone_offset || '+00:00'
53
+ opened = pull_request.opened_at.getlocal(tz).to_date
54
+ closed = pull_request.closed_at.getlocal(tz).to_date
55
+ (closed - opened).to_i + 1
56
+ else
57
+ divisor = { minutes: 60, hours: 3600 }[@cycletime_unit]
58
+ ((pull_request.closed_at - pull_request.opened_at) / divisor).round
59
+ end
60
+ end
61
+
62
+ def label_cycletime value
63
+ case @cycletime_unit
64
+ when :minutes then label_minutes(value)
65
+ when :hours then label_hours(value)
66
+ when :days then label_days(value)
67
+ end
68
+ end
69
+
70
+ def title_value pull_request, rules: nil
71
+ age_label = label_cycletime y_value(pull_request)
72
+ keys = pull_request.issue_keys.join(', ')
73
+ "#{keys} | #{pull_request.title} | #{rules.label} | Age:#{age_label}#{lines_changed_text(pull_request)}"
74
+ end
75
+
76
+ def lines_changed_text pull_request
77
+ return '' unless pull_request.changed_files
78
+
79
+ additions = pull_request.additions || 0
80
+ deletions = pull_request.deletions || 0
81
+ text = +' | Lines changed: ['
82
+ text << "+#{to_human_readable additions}" unless additions.zero?
83
+ text << ' ' if additions != 0 && deletions != 0
84
+ text << "-#{to_human_readable deletions}" unless deletions.zero?
85
+ text << "], Files changed: #{to_human_readable pull_request.changed_files}"
86
+ text
87
+ end
88
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ class PullRequestReview
6
+ def initialize raw:
7
+ @raw = raw
8
+ end
9
+
10
+ def author = @raw['author']
11
+ def state = @raw['state']
12
+ def submitted_at = Time.parse(@raw['submitted_at'])
13
+ end
@@ -10,4 +10,8 @@ class RawJavascript
10
10
  def to_json(*_args)
11
11
  @content
12
12
  end
13
+
14
+ def == other
15
+ other.is_a?(RawJavascript) && to_json == other.to_json
16
+ end
13
17
  end