jirametrics 2.13 → 2.30
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.
- checksums.yaml +4 -4
- data/bin/jirametrics-mcp +5 -0
- data/lib/jirametrics/aggregate_config.rb +10 -2
- data/lib/jirametrics/aging_work_bar_chart.rb +191 -133
- data/lib/jirametrics/aging_work_in_progress_chart.rb +43 -11
- data/lib/jirametrics/aging_work_table.rb +9 -7
- data/lib/jirametrics/anonymizer.rb +81 -6
- data/lib/jirametrics/atlassian_document_format.rb +101 -97
- data/lib/jirametrics/bar_chart_range.rb +17 -0
- data/lib/jirametrics/blocked_stalled_change.rb +5 -3
- data/lib/jirametrics/board.rb +32 -8
- data/lib/jirametrics/board_config.rb +4 -1
- data/lib/jirametrics/board_feature.rb +14 -0
- data/lib/jirametrics/board_movement_calculator.rb +2 -2
- data/lib/jirametrics/cfd_data_builder.rb +108 -0
- data/lib/jirametrics/change_item.rb +14 -6
- data/lib/jirametrics/chart_base.rb +141 -3
- data/lib/jirametrics/css_variable.rb +1 -1
- data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
- data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +21 -4
- data/lib/jirametrics/cycletime_histogram.rb +15 -101
- data/lib/jirametrics/cycletime_scatterplot.rb +17 -83
- data/lib/jirametrics/daily_view.rb +85 -53
- data/lib/jirametrics/daily_wip_by_age_chart.rb +4 -5
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +14 -4
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
- data/lib/jirametrics/daily_wip_chart.rb +30 -8
- data/lib/jirametrics/data_quality_report.rb +43 -12
- data/lib/jirametrics/dependency_chart.rb +6 -3
- data/lib/jirametrics/download_config.rb +15 -0
- data/lib/jirametrics/downloader.rb +117 -100
- data/lib/jirametrics/downloader_for_cloud.rb +287 -0
- data/lib/jirametrics/downloader_for_data_center.rb +95 -0
- data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
- data/lib/jirametrics/examples/aggregated_project.rb +2 -2
- data/lib/jirametrics/examples/standard_project.rb +41 -28
- data/lib/jirametrics/expedited_chart.rb +3 -1
- data/lib/jirametrics/exporter.rb +26 -6
- data/lib/jirametrics/file_config.rb +9 -11
- data/lib/jirametrics/file_system.rb +59 -3
- data/lib/jirametrics/fix_version.rb +13 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
- data/lib/jirametrics/github_gateway.rb +115 -0
- data/lib/jirametrics/groupable_issue_chart.rb +11 -1
- data/lib/jirametrics/grouping_rules.rb +26 -4
- data/lib/jirametrics/html/aging_work_bar_chart.erb +5 -5
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +3 -1
- data/lib/jirametrics/html/aging_work_table.erb +5 -0
- data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +40 -5
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
- data/lib/jirametrics/html/expedited_chart.erb +6 -14
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
- data/lib/jirametrics/html/index.css +249 -69
- data/lib/jirametrics/html/index.erb +9 -35
- data/lib/jirametrics/html/index.js +164 -0
- data/lib/jirametrics/html/legacy_colors.css +174 -0
- data/lib/jirametrics/html/sprint_burndown.erb +17 -15
- data/lib/jirametrics/html/throughput_chart.erb +42 -11
- data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +15 -11
- data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
- data/lib/jirametrics/html_generator.rb +32 -0
- data/lib/jirametrics/html_report_config.rb +52 -57
- data/lib/jirametrics/issue.rb +304 -101
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +77 -17
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +128 -12
- data/lib/jirametrics/pull_request.rb +30 -0
- data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
- data/lib/jirametrics/pull_request_review.rb +13 -0
- data/lib/jirametrics/raw_javascript.rb +17 -0
- data/lib/jirametrics/settings.json +5 -1
- data/lib/jirametrics/sprint.rb +12 -0
- data/lib/jirametrics/sprint_burndown.rb +10 -4
- data/lib/jirametrics/status.rb +1 -1
- data/lib/jirametrics/stitcher.rb +81 -0
- data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
- data/lib/jirametrics/throughput_chart.rb +73 -23
- data/lib/jirametrics/time_based_histogram.rb +139 -0
- data/lib/jirametrics/time_based_scatterplot.rb +107 -0
- data/lib/jirametrics/wip_by_column_chart.rb +236 -0
- data/lib/jirametrics.rb +83 -69
- metadata +60 -6
data/lib/jirametrics/exporter.rb
CHANGED
|
@@ -8,7 +8,13 @@ class Exporter
|
|
|
8
8
|
|
|
9
9
|
def self.configure &block
|
|
10
10
|
logfile_name = 'jirametrics.log'
|
|
11
|
-
logfile = File.open
|
|
11
|
+
logfile = File.open(logfile_name, 'w')
|
|
12
|
+
rescue Errno::EACCES
|
|
13
|
+
# FileSystem can't be used here — it hasn't been created yet (it depends on this logfile).
|
|
14
|
+
warn "Error: Cannot write to #{File.expand_path(logfile_name)}. " \
|
|
15
|
+
'Please ensure the current directory is writable.'
|
|
16
|
+
exit 1
|
|
17
|
+
else
|
|
12
18
|
file_system = FileSystem.new
|
|
13
19
|
file_system.logfile = logfile
|
|
14
20
|
file_system.logfile_name = logfile_name
|
|
@@ -40,6 +46,7 @@ class Exporter
|
|
|
40
46
|
|
|
41
47
|
def download name_filter:
|
|
42
48
|
@downloading = true
|
|
49
|
+
github_pr_cache = {}
|
|
43
50
|
each_project_config(name_filter: name_filter) do |project|
|
|
44
51
|
project.evaluate_next_level
|
|
45
52
|
next if project.aggregated_project?
|
|
@@ -50,33 +57,42 @@ class Exporter
|
|
|
50
57
|
end
|
|
51
58
|
|
|
52
59
|
project.download_config.run
|
|
53
|
-
|
|
60
|
+
gateway = JiraGateway.new(
|
|
61
|
+
file_system: file_system, jira_config: project.jira_config, settings: project.settings
|
|
62
|
+
)
|
|
63
|
+
downloader = Downloader.create(
|
|
54
64
|
download_config: project.download_config,
|
|
55
65
|
file_system: file_system,
|
|
56
|
-
jira_gateway:
|
|
66
|
+
jira_gateway: gateway,
|
|
67
|
+
github_pr_cache: github_pr_cache
|
|
57
68
|
)
|
|
58
69
|
downloader.run
|
|
59
70
|
end
|
|
60
71
|
puts "Full output from downloader in #{file_system.logfile_name}"
|
|
61
72
|
end
|
|
62
73
|
|
|
63
|
-
def info
|
|
74
|
+
def info key, name_filter:
|
|
64
75
|
selected = []
|
|
76
|
+
file_system.log_only = true
|
|
65
77
|
each_project_config(name_filter: name_filter) do |project|
|
|
66
78
|
project.evaluate_next_level
|
|
67
79
|
|
|
68
80
|
project.run load_only: true
|
|
69
81
|
project.issues.each do |issue|
|
|
70
|
-
selected << [project, issue] if
|
|
82
|
+
selected << [project, issue] if key == issue.key
|
|
83
|
+
issue.subtasks.each do |subtask|
|
|
84
|
+
selected << [project, subtask] if key == subtask.key
|
|
85
|
+
end
|
|
71
86
|
end
|
|
72
87
|
rescue => e # rubocop:disable Style/RescueStandardError
|
|
73
88
|
# This happens when we're attempting to load an aggregated project because it hasn't been
|
|
74
89
|
# properly initialized. Since we don't care about aggregated projects, we just ignore it.
|
|
75
90
|
raise unless e.message.start_with? 'This is an aggregated project and issues should have been included'
|
|
76
91
|
end
|
|
92
|
+
file_system.log_only = false
|
|
77
93
|
|
|
78
94
|
if selected.empty?
|
|
79
|
-
file_system.log "No issues found to match #{
|
|
95
|
+
file_system.log "No issues found to match #{key.inspect}"
|
|
80
96
|
else
|
|
81
97
|
selected.each do |project, issue|
|
|
82
98
|
file_system.log "\nProject #{project.name}", also_write_to_stderr: true
|
|
@@ -85,6 +101,10 @@ class Exporter
|
|
|
85
101
|
end
|
|
86
102
|
end
|
|
87
103
|
|
|
104
|
+
def stitch stitch_file
|
|
105
|
+
Stitcher.new(file_system: file_system).run(stitch_file: stitch_file)
|
|
106
|
+
end
|
|
107
|
+
|
|
88
108
|
def each_project_config name_filter:
|
|
89
109
|
@project_configs.each do |project|
|
|
90
110
|
yield project if project.name.nil? || File.fnmatch(name_filter, project.name)
|
|
@@ -65,22 +65,20 @@ class FileConfig
|
|
|
65
65
|
# most common usecase - the Team Dashboard from FocusedObjective.com. The rule for that one
|
|
66
66
|
# is that all empty values in the first column should be at the bottom.
|
|
67
67
|
def sort_output all_lines
|
|
68
|
-
all_lines.sort do |a, b|
|
|
69
|
-
result =
|
|
70
|
-
|
|
71
|
-
result = a[1..] <=> b[1..]
|
|
68
|
+
all_lines.each_with_index.sort do |(a, a_idx), (b, b_idx)|
|
|
69
|
+
result = if a[0] == b[0]
|
|
70
|
+
a[1..] <=> b[1..]
|
|
72
71
|
elsif a[0].nil?
|
|
73
|
-
|
|
72
|
+
1
|
|
74
73
|
elsif b[0].nil?
|
|
75
|
-
|
|
74
|
+
-1
|
|
76
75
|
else
|
|
77
|
-
|
|
76
|
+
a[0] <=> b[0]
|
|
78
77
|
end
|
|
79
78
|
|
|
80
|
-
#
|
|
81
|
-
result
|
|
82
|
-
|
|
83
|
-
end
|
|
79
|
+
# When objects aren't comparable, preserve original order for a stable sort.
|
|
80
|
+
result.nil? || result.zero? ? a_idx <=> b_idx : result
|
|
81
|
+
end.map(&:first)
|
|
84
82
|
end
|
|
85
83
|
|
|
86
84
|
def columns &block
|
|
@@ -3,7 +3,15 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
|
|
5
5
|
class FileSystem
|
|
6
|
-
attr_accessor :logfile, :logfile_name
|
|
6
|
+
attr_accessor :logfile, :logfile_name, :log_only
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
# In almost all cases, this will be immediately replaced in the Exporter
|
|
10
|
+
# but if we fail before we get that far, this will at least let a useful
|
|
11
|
+
# error show up on the console.
|
|
12
|
+
@logfile = $stdout
|
|
13
|
+
@log_only = false
|
|
14
|
+
end
|
|
7
15
|
|
|
8
16
|
# Effectively the same as File.read except it forces the encoding to UTF-8
|
|
9
17
|
def load filename, supress_deprecation: false
|
|
@@ -31,6 +39,14 @@ class FileSystem
|
|
|
31
39
|
File.write(filename, content)
|
|
32
40
|
end
|
|
33
41
|
|
|
42
|
+
def mkdir path
|
|
43
|
+
FileUtils.mkdir_p path
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def utime file:, time:
|
|
47
|
+
File.utime time, time, file
|
|
48
|
+
end
|
|
49
|
+
|
|
34
50
|
def warning message, more: nil
|
|
35
51
|
log "Warning: #{message}", more: more, also_write_to_stderr: true
|
|
36
52
|
end
|
|
@@ -44,11 +60,43 @@ class FileSystem
|
|
|
44
60
|
|
|
45
61
|
logfile.puts message
|
|
46
62
|
logfile.puts more if more
|
|
47
|
-
return
|
|
63
|
+
return if log_only || !also_write_to_stderr
|
|
64
|
+
|
|
65
|
+
# Obscure edge-case where we're trying to log something before logging is even
|
|
66
|
+
# set up. Quick escape here so that we don't dump the error twice.
|
|
67
|
+
return if logfile == $stdout
|
|
48
68
|
|
|
49
69
|
$stderr.puts message # rubocop:disable Style/StderrPuts
|
|
50
70
|
end
|
|
51
71
|
|
|
72
|
+
def log_start message
|
|
73
|
+
logfile.puts message
|
|
74
|
+
return if log_only || logfile == $stdout
|
|
75
|
+
|
|
76
|
+
$stderr.print message
|
|
77
|
+
$stderr.flush
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def start_progress
|
|
81
|
+
return if log_only
|
|
82
|
+
|
|
83
|
+
$stderr.print ' '
|
|
84
|
+
$stderr.flush
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def progress_dot
|
|
88
|
+
return if log_only
|
|
89
|
+
|
|
90
|
+
$stderr.print '.'
|
|
91
|
+
$stderr.flush
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def end_progress
|
|
95
|
+
return if log_only
|
|
96
|
+
|
|
97
|
+
$stderr.puts '' # rubocop:disable Style/StderrPuts
|
|
98
|
+
end
|
|
99
|
+
|
|
52
100
|
# In some Jira instances, a sizeable portion of the JSON is made up of empty fields. I've seen
|
|
53
101
|
# cases where this simple compression will drop the filesize by half.
|
|
54
102
|
def compress node
|
|
@@ -66,7 +114,15 @@ class FileSystem
|
|
|
66
114
|
end
|
|
67
115
|
|
|
68
116
|
def file_exist? filename
|
|
69
|
-
File.exist? filename
|
|
117
|
+
File.exist?(filename) && File.file?(filename)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def dir_exist? path
|
|
121
|
+
File.exist?(path) && File.directory?(path)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def unlink filename
|
|
125
|
+
File.unlink filename
|
|
70
126
|
end
|
|
71
127
|
|
|
72
128
|
def deprecated message:, date:, depth: 2
|
|
@@ -11,11 +11,24 @@ class FixVersion
|
|
|
11
11
|
@raw['name']
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
+
def description
|
|
15
|
+
@raw['description']
|
|
16
|
+
end
|
|
17
|
+
|
|
14
18
|
def id
|
|
15
19
|
@raw['id'].to_i
|
|
16
20
|
end
|
|
17
21
|
|
|
22
|
+
def release_date
|
|
23
|
+
text = @raw['releaseDate']
|
|
24
|
+
text.nil? ? nil : Date.parse(text)
|
|
25
|
+
end
|
|
26
|
+
|
|
18
27
|
def released?
|
|
19
28
|
@raw['released']
|
|
20
29
|
end
|
|
30
|
+
|
|
31
|
+
def archived?
|
|
32
|
+
@raw['archived']
|
|
33
|
+
end
|
|
21
34
|
end
|
|
@@ -32,6 +32,8 @@ class FlowEfficiencyScatterplot < ChartBase
|
|
|
32
32
|
So be aware that your team may have to change their behaviours if you want this chart to be useful.
|
|
33
33
|
</div>
|
|
34
34
|
HTML
|
|
35
|
+
@x_axis_title = 'Total time (days)'
|
|
36
|
+
@y_axis_title = 'Time adding value (days)'
|
|
35
37
|
|
|
36
38
|
init_configuration_block block do
|
|
37
39
|
grouping_rules do |issue, rule|
|
|
@@ -60,7 +62,9 @@ class FlowEfficiencyScatterplot < ChartBase
|
|
|
60
62
|
create_dataset(issues: issues, label: rules.label, color: rules.color)
|
|
61
63
|
end
|
|
62
64
|
|
|
63
|
-
|
|
65
|
+
if data_sets.empty?
|
|
66
|
+
return "<h1 class='foldable'>#{@header_text}</h1><div>No data matched the selected criteria. Nothing to show.</div>"
|
|
67
|
+
end
|
|
64
68
|
|
|
65
69
|
wrap_and_render(binding, __FILE__)
|
|
66
70
|
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
class GithubGateway
|
|
7
|
+
attr_reader :repo
|
|
8
|
+
|
|
9
|
+
def initialize repo:, project_keys:, file_system:, raw_pr_cache: {}
|
|
10
|
+
@repo = repo
|
|
11
|
+
@project_keys = project_keys
|
|
12
|
+
@file_system = file_system
|
|
13
|
+
@raw_pr_cache = raw_pr_cache
|
|
14
|
+
@issue_key_pattern = build_issue_key_pattern
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def fetch_pull_requests since: nil
|
|
18
|
+
raw_prs = @raw_pr_cache[[@repo, since]] ||= fetch_raw_pull_requests(since: since)
|
|
19
|
+
raw_prs.filter_map { |pr| build_pr_data(pr) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def fetch_raw_pull_requests since: nil
|
|
23
|
+
# Note: 'commits' is intentionally excluded — including it triggers GitHub's GraphQL node
|
|
24
|
+
# limit (authors sub-connection × PRs × commits exceeds 500,000 nodes). Branch name,
|
|
25
|
+
# title, and body are sufficient for issue key extraction in the vast majority of cases.
|
|
26
|
+
json_fields = %w[number title body headRefName createdAt closedAt mergedAt
|
|
27
|
+
url state reviews additions deletions changedFiles].join(',')
|
|
28
|
+
args = ['pr', 'list', '--state', 'all', '--limit', '5000', '--json', json_fields]
|
|
29
|
+
args += ['--repo', @repo]
|
|
30
|
+
args += ['--search', "updated:>=#{since}"] if since
|
|
31
|
+
|
|
32
|
+
@file_system.log " Downloading pull requests from #{@repo}", also_write_to_stderr: true
|
|
33
|
+
run_command(args)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def build_pr_data raw_pr
|
|
37
|
+
issue_keys = extract_issue_keys(raw_pr)
|
|
38
|
+
return nil if issue_keys.empty?
|
|
39
|
+
|
|
40
|
+
PullRequest.new(raw: {
|
|
41
|
+
'number' => raw_pr['number'],
|
|
42
|
+
'repo' => @repo,
|
|
43
|
+
'url' => raw_pr['url'],
|
|
44
|
+
'title' => raw_pr['title'],
|
|
45
|
+
'branch' => raw_pr['headRefName'],
|
|
46
|
+
'opened_at' => raw_pr['createdAt'],
|
|
47
|
+
'closed_at' => raw_pr['closedAt'],
|
|
48
|
+
'merged_at' => raw_pr['mergedAt'],
|
|
49
|
+
'state' => raw_pr['state'],
|
|
50
|
+
'issue_keys' => issue_keys,
|
|
51
|
+
'reviews' => extract_reviews(raw_pr['reviews'] || []),
|
|
52
|
+
'additions' => raw_pr['additions'],
|
|
53
|
+
'deletions' => raw_pr['deletions'],
|
|
54
|
+
'changed_files' => raw_pr['changedFiles']
|
|
55
|
+
})
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def extract_issue_keys raw_pr
|
|
59
|
+
return [] if @issue_key_pattern.nil?
|
|
60
|
+
|
|
61
|
+
sources = [
|
|
62
|
+
raw_pr['headRefName'],
|
|
63
|
+
raw_pr['title'],
|
|
64
|
+
raw_pr['body']
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
keys = sources.compact.flat_map { |s| s.scan(@issue_key_pattern) }.uniq
|
|
68
|
+
return keys unless keys.empty?
|
|
69
|
+
|
|
70
|
+
commit_messages_for(raw_pr['number']).flat_map { |msg| msg.scan(@issue_key_pattern) }.uniq
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def extract_reviews raw_reviews
|
|
74
|
+
raw_reviews
|
|
75
|
+
.select { |r| %w[APPROVED CHANGES_REQUESTED].include?(r['state']) }
|
|
76
|
+
.map do |r|
|
|
77
|
+
{
|
|
78
|
+
'author' => r.dig('author', 'login'),
|
|
79
|
+
'submitted_at' => r['submittedAt'],
|
|
80
|
+
'state' => r['state']
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def commit_messages_for pr_number
|
|
88
|
+
args = ['pr', 'view', pr_number.to_s, '--json', 'commits', '--repo', @repo]
|
|
89
|
+
result = run_command(args)
|
|
90
|
+
(result['commits'] || []).flat_map do |commit|
|
|
91
|
+
[commit['messageHeadline'], commit['messageBody']].compact
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def build_issue_key_pattern
|
|
96
|
+
return nil if @project_keys.empty?
|
|
97
|
+
|
|
98
|
+
keys_pattern = @project_keys.map { |k| Regexp.escape(k) }.join('|')
|
|
99
|
+
Regexp.new("\\b(?:#{keys_pattern})-\\d+(?![A-Za-z0-9])")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def run_command args
|
|
103
|
+
stdout, stderr, status = Open3.capture3('gh', *args)
|
|
104
|
+
|
|
105
|
+
# This extra check seems to only matter on Windows. On the mac, auth failures don't pass status.success?
|
|
106
|
+
if stderr.include?('SAML enforcement')
|
|
107
|
+
raise "GitHub CLI is not authorized to access #{@repo}. " \
|
|
108
|
+
'Run: gh auth refresh -h github.com -s read:org'
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
raise "GitHub CLI command failed for #{@repo}: #{stderr}" unless status.success?
|
|
112
|
+
|
|
113
|
+
JSON.parse(stdout)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -15,14 +15,24 @@ module GroupableIssueChart
|
|
|
15
15
|
|
|
16
16
|
def group_issues completed_issues
|
|
17
17
|
result = {}
|
|
18
|
+
ignored_issues = []
|
|
19
|
+
@issue_hints = {}
|
|
20
|
+
@issue_periods = {}
|
|
18
21
|
completed_issues.each do |issue|
|
|
19
22
|
rules = GroupingRules.new
|
|
20
23
|
@group_by_block.call(issue, rules)
|
|
21
|
-
|
|
24
|
+
if rules.ignored?
|
|
25
|
+
ignored_issues << issue
|
|
26
|
+
next
|
|
27
|
+
end
|
|
22
28
|
|
|
29
|
+
@issue_hints[issue] = rules.issue_hint
|
|
30
|
+
@issue_periods[issue] = rules.last_day_of_period
|
|
23
31
|
(result[rules] ||= []) << issue
|
|
24
32
|
end
|
|
25
33
|
|
|
34
|
+
completed_issues.reject! { |issue| ignored_issues.include? issue }
|
|
35
|
+
|
|
26
36
|
result.each_key do |rules|
|
|
27
37
|
rules.color = random_color if rules.color.nil?
|
|
28
38
|
end
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class GroupingRules < Rules
|
|
4
|
-
attr_accessor :label
|
|
5
|
-
attr_reader :color
|
|
4
|
+
attr_accessor :label, :issue_hint, :label_hint
|
|
5
|
+
attr_reader :color, :last_day_of_period
|
|
6
|
+
|
|
7
|
+
def last_day_of_period= value
|
|
8
|
+
@last_day_of_period = value.is_a?(String) ? Date.parse(value) : value
|
|
9
|
+
end
|
|
6
10
|
|
|
7
11
|
def eql? other
|
|
8
12
|
other.label == @label && other.color == @color
|
|
@@ -13,7 +17,25 @@ class GroupingRules < Rules
|
|
|
13
17
|
end
|
|
14
18
|
|
|
15
19
|
def color= color
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
if color.is_a?(Array)
|
|
21
|
+
raise ArgumentError, 'Color pair must have exactly two elements: [light_color, dark_color]' unless color.size == 2
|
|
22
|
+
raise ArgumentError, 'Color pair elements must be strings' unless color.all?(String)
|
|
23
|
+
|
|
24
|
+
if color.any? { |c| c.start_with?('--') }
|
|
25
|
+
raise ArgumentError,
|
|
26
|
+
'CSS variable references are not supported as color pair elements; use a literal color value instead'
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
light, dark = color
|
|
30
|
+
@color = RawJavascript.new(
|
|
31
|
+
"(document.documentElement.dataset.theme === 'dark' || " \
|
|
32
|
+
'(!document.documentElement.dataset.theme && ' \
|
|
33
|
+
"window.matchMedia('(prefers-color-scheme: dark)').matches)) " \
|
|
34
|
+
"? #{dark.to_json} : #{light.to_json}"
|
|
35
|
+
)
|
|
36
|
+
else
|
|
37
|
+
color = CssVariable[color] unless color.is_a?(CssVariable)
|
|
38
|
+
@color = color
|
|
39
|
+
end
|
|
18
40
|
end
|
|
19
41
|
end
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
<%= seam_start %>
|
|
1
2
|
<div class="chart">
|
|
2
3
|
<canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
|
|
3
4
|
</div>
|
|
@@ -15,11 +16,9 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
|
|
|
15
16
|
x: {
|
|
16
17
|
type: 'time',
|
|
17
18
|
min: '<%= @date_range.begin.to_s %>',
|
|
18
|
-
max: '<%= (@date_range.end ).to_s %>',
|
|
19
|
+
max: '<%= (@date_range.end + 1).to_s %>',
|
|
19
20
|
stacked: false,
|
|
20
|
-
|
|
21
|
-
display: false
|
|
22
|
-
},
|
|
21
|
+
<%= render_axis_title :x %>
|
|
23
22
|
grid: {
|
|
24
23
|
color: <%= CssVariable['--grid-line-color'].to_json %>
|
|
25
24
|
},
|
|
@@ -30,6 +29,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
|
|
|
30
29
|
ticks: {
|
|
31
30
|
display: true
|
|
32
31
|
},
|
|
32
|
+
<%= render_axis_title :y %>
|
|
33
33
|
grid: {
|
|
34
34
|
color: <%= CssVariable['--grid-line-color'].to_json %>
|
|
35
35
|
},
|
|
@@ -66,4 +66,4 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
|
|
|
66
66
|
}
|
|
67
67
|
});
|
|
68
68
|
</script>
|
|
69
|
-
|
|
69
|
+
<%= seam_end %>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
<%= seam_start %>
|
|
1
2
|
<div class="chart">
|
|
2
3
|
<canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
|
|
3
4
|
</div>
|
|
@@ -40,7 +41,7 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
|
|
|
40
41
|
color: <%= CssVariable['--grid-line-color'].to_json %>,
|
|
41
42
|
z: 1 // draw the grid lines on top of the bars
|
|
42
43
|
},
|
|
43
|
-
stacked:
|
|
44
|
+
stacked: false,
|
|
44
45
|
max: <%= (@max_age * 1.1).to_i %>
|
|
45
46
|
}
|
|
46
47
|
},
|
|
@@ -73,3 +74,4 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
|
|
|
73
74
|
}
|
|
74
75
|
});
|
|
75
76
|
</script>
|
|
77
|
+
<%= seam_end %>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
<%= seam_start %>
|
|
1
2
|
<table class='standard'>
|
|
2
3
|
<thead>
|
|
3
4
|
<tr>
|
|
@@ -40,6 +41,9 @@
|
|
|
40
41
|
<%= link_to_issue parent, style: "color: #{color}" %>
|
|
41
42
|
</span>
|
|
42
43
|
<i><%= parent.summary.strip.inspect %></i>
|
|
44
|
+
<% if parent == issue && (text = not_visible_text(issue)) %>
|
|
45
|
+
<br /><%= text %>
|
|
46
|
+
<% end %>
|
|
43
47
|
</div>
|
|
44
48
|
<% end %>
|
|
45
49
|
</td>
|
|
@@ -54,3 +58,4 @@
|
|
|
54
58
|
<% end %>
|
|
55
59
|
</tbody>
|
|
56
60
|
</table>
|
|
61
|
+
<%= seam_end %>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
<table class='standard' id='<%= issues_id %>'
|
|
1
|
+
<div class='foldable startFolded'>Show details</div>
|
|
2
|
+
<table class='standard' id='<%= issues_id %>'>
|
|
3
3
|
<thead>
|
|
4
4
|
<tr>
|
|
5
5
|
<th>Issue</th>
|