jirametrics 2.4 → 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.
Files changed (100) hide show
  1. checksums.yaml +4 -4
  2. data/bin/jirametrics-mcp +5 -0
  3. data/lib/jirametrics/aggregate_config.rb +16 -3
  4. data/lib/jirametrics/aging_work_bar_chart.rb +193 -133
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +138 -42
  6. data/lib/jirametrics/aging_work_table.rb +63 -19
  7. data/lib/jirametrics/anonymizer.rb +81 -6
  8. data/lib/jirametrics/atlassian_document_format.rb +160 -0
  9. data/lib/jirametrics/bar_chart_range.rb +17 -0
  10. data/lib/jirametrics/blocked_stalled_change.rb +6 -4
  11. data/lib/jirametrics/board.rb +74 -22
  12. data/lib/jirametrics/board_config.rb +11 -3
  13. data/lib/jirametrics/board_feature.rb +14 -0
  14. data/lib/jirametrics/board_movement_calculator.rb +155 -0
  15. data/lib/jirametrics/cfd_data_builder.rb +108 -0
  16. data/lib/jirametrics/change_item.rb +54 -18
  17. data/lib/jirametrics/chart_base.rb +203 -30
  18. data/lib/jirametrics/css_variable.rb +2 -2
  19. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  20. data/lib/jirametrics/cycle_time_config.rb +137 -0
  21. data/lib/jirametrics/cycletime_histogram.rb +17 -38
  22. data/lib/jirametrics/cycletime_scatterplot.rb +18 -87
  23. data/lib/jirametrics/daily_view.rb +306 -0
  24. data/lib/jirametrics/daily_wip_by_age_chart.rb +5 -8
  25. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +15 -5
  26. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -6
  27. data/lib/jirametrics/daily_wip_chart.rb +36 -16
  28. data/lib/jirametrics/data_quality_report.rb +251 -42
  29. data/lib/jirametrics/dependency_chart.rb +42 -12
  30. data/lib/jirametrics/download_config.rb +27 -0
  31. data/lib/jirametrics/downloader.rb +185 -110
  32. data/lib/jirametrics/downloader_for_cloud.rb +287 -0
  33. data/lib/jirametrics/downloader_for_data_center.rb +95 -0
  34. data/lib/jirametrics/estimate_accuracy_chart.rb +75 -14
  35. data/lib/jirametrics/estimation_configuration.rb +25 -0
  36. data/lib/jirametrics/examples/aggregated_project.rb +9 -23
  37. data/lib/jirametrics/examples/standard_project.rb +57 -58
  38. data/lib/jirametrics/expedited_chart.rb +11 -10
  39. data/lib/jirametrics/exporter.rb +51 -14
  40. data/lib/jirametrics/file_config.rb +21 -6
  41. data/lib/jirametrics/file_system.rb +96 -4
  42. data/lib/jirametrics/fix_version.rb +13 -0
  43. data/lib/jirametrics/flow_efficiency_scatterplot.rb +115 -0
  44. data/lib/jirametrics/github_gateway.rb +115 -0
  45. data/lib/jirametrics/groupable_issue_chart.rb +12 -4
  46. data/lib/jirametrics/grouping_rules.rb +26 -4
  47. data/lib/jirametrics/html/aging_work_bar_chart.erb +8 -17
  48. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +24 -5
  49. data/lib/jirametrics/html/aging_work_table.erb +13 -4
  50. data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
  51. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  52. data/lib/jirametrics/html/daily_wip_chart.erb +41 -15
  53. data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
  54. data/lib/jirametrics/html/expedited_chart.erb +7 -24
  55. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +81 -0
  56. data/lib/jirametrics/html/hierarchy_table.erb +1 -1
  57. data/lib/jirametrics/html/index.css +336 -62
  58. data/lib/jirametrics/html/index.erb +16 -21
  59. data/lib/jirametrics/html/index.js +164 -0
  60. data/lib/jirametrics/html/legacy_colors.css +174 -0
  61. data/lib/jirametrics/html/sprint_burndown.erb +18 -25
  62. data/lib/jirametrics/html/throughput_chart.erb +43 -21
  63. data/lib/jirametrics/html/time_based_histogram.erb +123 -0
  64. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +16 -21
  65. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  66. data/lib/jirametrics/html_generator.rb +32 -0
  67. data/lib/jirametrics/html_report_config.rb +83 -76
  68. data/lib/jirametrics/issue.rb +499 -91
  69. data/lib/jirametrics/issue_collection.rb +33 -0
  70. data/lib/jirametrics/issue_printer.rb +97 -0
  71. data/lib/jirametrics/jira_gateway.rb +96 -16
  72. data/lib/jirametrics/mcp_server.rb +531 -0
  73. data/lib/jirametrics/project_config.rb +374 -130
  74. data/lib/jirametrics/pull_request.rb +30 -0
  75. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  76. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  77. data/lib/jirametrics/pull_request_review.rb +13 -0
  78. data/lib/jirametrics/raw_javascript.rb +17 -0
  79. data/lib/jirametrics/rules.rb +2 -2
  80. data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
  81. data/lib/jirametrics/settings.json +10 -2
  82. data/lib/jirametrics/sprint.rb +13 -0
  83. data/lib/jirametrics/sprint_burndown.rb +47 -39
  84. data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
  85. data/lib/jirametrics/status.rb +84 -19
  86. data/lib/jirametrics/status_collection.rb +83 -38
  87. data/lib/jirametrics/stitcher.rb +81 -0
  88. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  89. data/lib/jirametrics/throughput_chart.rb +73 -23
  90. data/lib/jirametrics/time_based_histogram.rb +139 -0
  91. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  92. data/lib/jirametrics/user.rb +12 -0
  93. data/lib/jirametrics/value_equality.rb +2 -2
  94. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  95. data/lib/jirametrics.rb +101 -66
  96. metadata +72 -16
  97. data/lib/jirametrics/cycletime_config.rb +0 -69
  98. data/lib/jirametrics/discard_changes_before.rb +0 -37
  99. data/lib/jirametrics/html/cycletime_histogram.erb +0 -47
  100. data/lib/jirametrics/html/data_quality_report.erb +0 -126
@@ -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
@@ -6,9 +6,7 @@ require 'jirametrics/grouping_rules'
6
6
  module GroupableIssueChart
7
7
  def init_configuration_block user_provided_block, &default_block
8
8
  instance_eval(&user_provided_block)
9
- return if @group_by_block
10
-
11
- instance_eval(&default_block)
9
+ instance_eval(&default_block) unless @group_by_block
12
10
  end
13
11
 
14
12
  def grouping_rules &block
@@ -17,14 +15,24 @@ module GroupableIssueChart
17
15
 
18
16
  def group_issues completed_issues
19
17
  result = {}
18
+ ignored_issues = []
19
+ @issue_hints = {}
20
+ @issue_periods = {}
20
21
  completed_issues.each do |issue|
21
22
  rules = GroupingRules.new
22
23
  @group_by_block.call(issue, rules)
23
- next if rules.ignored?
24
+ if rules.ignored?
25
+ ignored_issues << issue
26
+ next
27
+ end
24
28
 
29
+ @issue_hints[issue] = rules.issue_hint
30
+ @issue_periods[issue] = rules.last_day_of_period
25
31
  (result[rules] ||= []) << issue
26
32
  end
27
33
 
34
+ completed_issues.reject! { |issue| ignored_issues.include? issue }
35
+
28
36
  result.each_key do |rules|
29
37
  rules.color = random_color if rules.color.nil?
30
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
- color = CssVariable[color] unless color.is_a?(CssVariable)
17
- @color = color
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
- title: {
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
  },
@@ -38,22 +38,13 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
38
38
  plugins: {
39
39
  annotation: {
40
40
  annotations: {
41
- <% holidays.each_with_index do |range, index| %>
42
- holiday<%= index %>: {
43
- drawTime: 'beforeDraw',
44
- type: 'box',
45
- xMin: '<%= range.begin %>T00:00:00',
46
- xMax: '<%= range.end %>T23:59:59',
47
- backgroundColor: <%= CssVariable.new('--non-working-days-color').to_json %>,
48
- borderColor: <%= CssVariable.new('--non-working-days-color').to_json %>
49
- },
50
- <% end %>
41
+ <%= working_days_annotation %>
51
42
 
52
43
  <% if percentage_line_x %>
53
44
  line: {
54
45
  type: 'line',
55
- xMin: '<%= percentage_line_x %>',
56
- xMax: '<%= percentage_line_x %>',
46
+ scaleID: 'x',
47
+ value: '<%= percentage_line_x %>',
57
48
  borderColor: <%= CssVariable.new('--aging-work-bar-chart-percentage-line-color').to_json %>,
58
49
  borderWidth: 1,
59
50
  drawTime: 'afterDraw'
@@ -75,4 +66,4 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
75
66
  }
76
67
  });
77
68
  </script>
78
-
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>
@@ -6,7 +7,7 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
6
7
  {
7
8
  type: 'bar',
8
9
  data: {
9
- labels: [<%= column_headings.collect(&:inspect).join(',') %>],
10
+ labels: [<%= @board_columns.collect { |c| c.name.inspect }.join(',') %>],
10
11
  datasets: <%= JSON.generate(data_sets) %>
11
12
  },
12
13
  options: {
@@ -22,8 +23,10 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
22
23
  labelString: 'Date Completed'
23
24
  },
24
25
  grid: {
25
- color: <%= CssVariable['--grid-line-color'].to_json %>
26
+ color: <%= CssVariable['--grid-line-color'].to_json %>,
27
+ z: 1 // draw the grid lines on top of the bars
26
28
  },
29
+ stacked: true
27
30
  },
28
31
  y: {
29
32
  scaleLabel: {
@@ -35,8 +38,11 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
35
38
  text: 'Age in days'
36
39
  },
37
40
  grid: {
38
- color: <%= CssVariable['--grid-line-color'].to_json %>
41
+ color: <%= CssVariable['--grid-line-color'].to_json %>,
42
+ z: 1 // draw the grid lines on top of the bars
39
43
  },
44
+ stacked: false,
45
+ max: <%= (@max_age * 1.1).to_i %>
40
46
  }
41
47
  },
42
48
  plugins: {
@@ -44,15 +50,28 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
44
50
  callbacks: {
45
51
  label: function(context) {
46
52
  if( typeof(context.dataset.data[context.dataIndex]) == "number" ) {
47
- return "85% of the issues, leave this column in "+context.dataset.data[context.dataIndex]+" days";
53
+ let full_data = <%= @bar_data.inspect %>;
54
+ let columnIndex = context.dataIndex;
55
+ let rowIndex = context.datasetIndex - <%= @row_index_offset %>;
56
+ return context.dataset.label + " of completed work items left this column in " +full_data[rowIndex][columnIndex] + " days or less";
48
57
  }
49
58
  else {
50
- return context.dataset.data[context.dataIndex].title
59
+ return context.dataset.data[context.dataIndex].title;
51
60
  }
52
61
  }
53
62
  }
63
+ },
64
+ legend: {
65
+ labels: {
66
+ filter: function(item, chart) {
67
+ // Logic to remove a particular legend item goes here
68
+ return !item.text.includes('%');
69
+ }
70
+ }
54
71
  }
72
+
55
73
  }
56
74
  }
57
75
  });
58
76
  </script>
77
+ <%= seam_end %>
@@ -1,11 +1,14 @@
1
+ <%= seam_start %>
1
2
  <table class='standard'>
2
3
  <thead>
3
4
  <tr>
4
- <th>Age (days)</th>
5
- <th>E</th>
6
- <th>B</th>
5
+ <th title="Age in days">Age</th>
6
+ <th title="Expedited">E</th>
7
+ <th title="Blocked / Stalled">B/S</th>
8
+ <th title="Priority">P</th>
7
9
  <th>Issue</th>
8
10
  <th>Status</th>
11
+ <th>Forecast</th>
9
12
  <th>Fix versions</th>
10
13
  <% if any_scrum_boards %>
11
14
  <th>Sprints</th>
@@ -28,6 +31,7 @@
28
31
  <td style="text-align: right;"><%= issue_age || 'Not started' %></td>
29
32
  <td><%= expedited_text(issue) %></td>
30
33
  <td><%= blocked_text(issue) %></td>
34
+ <td><%= priority_text(issue) %></td>
31
35
  <td>
32
36
  <% parent_hierarchy(issue).each_with_index do |parent, index| %>
33
37
  <% color = parent != issue ? "var(--hierarchy-table-inactive-item-text-color)" : 'var(--default-text-color)' %>
@@ -37,10 +41,14 @@
37
41
  <%= link_to_issue parent, style: "color: #{color}" %>
38
42
  </span>
39
43
  <i><%= parent.summary.strip.inspect %></i>
44
+ <% if parent == issue && (text = not_visible_text(issue)) %>
45
+ <br /><%= text %>
46
+ <% end %>
40
47
  </div>
41
48
  <% end %>
42
49
  </td>
43
- <td><%= format_status issue.status.name, board: issue.board %></td>
50
+ <td><%= format_status issue.status, board: issue.board %></td>
51
+ <td><%= dates_text(issue) %></td>
44
52
  <td><%= fix_versions_text(issue) %></td>
45
53
  <% if any_scrum_boards %>
46
54
  <td><%= sprints_text(issue) %></td>
@@ -50,3 +58,4 @@
50
58
  <% end %>
51
59
  </tbody>
52
60
  </table>
61
+ <%= seam_end %>
@@ -1,5 +1,5 @@
1
- [<a id='<%= link_id %>' href="#" onclick='expand_collapse("<%= link_id %>", "<%= issues_id %>"); return false;'>Show details</a>]
2
- <table class='standard' id='<%= issues_id %>' style='display: none;'>
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>