jirametrics 2.9 → 2.11

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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +1 -1
  3. data/lib/jirametrics/aging_work_in_progress_chart.rb +105 -41
  4. data/lib/jirametrics/aging_work_table.rb +50 -2
  5. data/lib/jirametrics/board.rb +30 -1
  6. data/lib/jirametrics/board_movement_calculator.rb +147 -0
  7. data/lib/jirametrics/change_item.rb +8 -1
  8. data/lib/jirametrics/chart_base.rb +19 -1
  9. data/lib/jirametrics/css_variable.rb +1 -1
  10. data/lib/jirametrics/cycletime_histogram.rb +65 -2
  11. data/lib/jirametrics/data_quality_report.rb +1 -1
  12. data/lib/jirametrics/downloader.rb +0 -14
  13. data/lib/jirametrics/exporter.rb +10 -8
  14. data/lib/jirametrics/file_config.rb +9 -4
  15. data/lib/jirametrics/file_system.rb +4 -0
  16. data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
  17. data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -12
  18. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +22 -5
  19. data/lib/jirametrics/html/aging_work_table.erb +5 -3
  20. data/lib/jirametrics/html/cycletime_histogram.erb +74 -0
  21. data/lib/jirametrics/html/cycletime_scatterplot.erb +1 -10
  22. data/lib/jirametrics/html/daily_wip_chart.erb +1 -10
  23. data/lib/jirametrics/html/expedited_chart.erb +1 -10
  24. data/lib/jirametrics/html/index.css +11 -2
  25. data/lib/jirametrics/html/index.erb +8 -1
  26. data/lib/jirametrics/html/sprint_burndown.erb +1 -10
  27. data/lib/jirametrics/html/throughput_chart.erb +1 -10
  28. data/lib/jirametrics/issue.rb +29 -8
  29. data/lib/jirametrics/jira_gateway.rb +16 -3
  30. data/lib/jirametrics/project_config.rb +1 -1
  31. data/lib/jirametrics/status.rb +3 -6
  32. metadata +3 -2
@@ -272,7 +272,7 @@ class DataQualityReport < ChartBase
272
272
 
273
273
  entry.report(
274
274
  problem_key: :items_blocked_on_closed_tickets,
275
- detail: "#{entry.issue.key} thinks it's blocked on #{link.other_issue.key}, " \
275
+ detail: "#{entry.issue.key} thinks it's blocked by #{link.other_issue.key}, " \
276
276
  "except #{link.other_issue.key} is closed."
277
277
  )
278
278
  end
@@ -103,8 +103,6 @@ class Downloader
103
103
  json = @jira_gateway.call_url relative_url: '/rest/api/2/search' \
104
104
  "?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
105
105
 
106
- exit_if_call_failed json
107
-
108
106
  json['issues'].each do |issue_json|
109
107
  issue_json['exporter'] = {
110
108
  'in_initial_query' => initial_query
@@ -139,15 +137,6 @@ class Downloader
139
137
  end
140
138
  end
141
139
 
142
- def exit_if_call_failed json
143
- # Sometimes Jira returns the singular form of errorMessage and sometimes the plural. Consistency FTW.
144
- return unless json['error'] || json['errorMessages'] || json['errorMessage']
145
-
146
- log "Download failed. See #{@file_system.logfile_name} for details.", both: true
147
- log " #{JSON.pretty_generate(json)}"
148
- exit 1
149
- end
150
-
151
140
  def download_statuses
152
141
  log ' Downloading all statuses', both: true
153
142
  json = @jira_gateway.call_url relative_url: '/rest/api/2/status'
@@ -188,8 +177,6 @@ class Downloader
188
177
  log " Downloading board configuration for board #{board_id}", both: true
189
178
  json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/configuration"
190
179
 
191
- exit_if_call_failed json
192
-
193
180
  @file_system.save_json(
194
181
  json: json,
195
182
  filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_configuration.json")
@@ -213,7 +200,6 @@ class Downloader
213
200
  while is_last == false
214
201
  json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/sprint?" \
215
202
  "maxResults=#{max_results}&startAt=#{start_at}"
216
- exit_if_call_failed json
217
203
 
218
204
  @file_system.save_json(
219
205
  json: json,
@@ -64,12 +64,8 @@ class Exporter
64
64
  selected = []
65
65
  each_project_config(name_filter: name_filter) do |project|
66
66
  project.evaluate_next_level
67
- # next if project.aggregated_project?
68
67
 
69
68
  project.run load_only: true
70
- project.board_configs.each do |board_config|
71
- board_config.run
72
- end
73
69
  project.issues.each do |issue|
74
70
  selected << [project, issue] if keys.include? issue.key
75
71
  end
@@ -79,9 +75,13 @@ class Exporter
79
75
  raise unless e.message.start_with? 'This is an aggregated project and issues should have been included'
80
76
  end
81
77
 
82
- selected.each do |project, issue|
83
- puts "\nProject #{project.name}"
84
- puts issue.dump
78
+ if selected.empty?
79
+ file_system.log "No issues found to match #{keys.collect(&:inspect).join(', ')}"
80
+ else
81
+ selected.each do |project, issue|
82
+ file_system.log "\nProject #{project.name}", also_write_to_stderr: true
83
+ file_system.log issue.dump, also_write_to_stderr: true
84
+ end
85
85
  end
86
86
  end
87
87
 
@@ -116,7 +116,9 @@ class Exporter
116
116
 
117
117
  def jira_config filename = nil
118
118
  if filename
119
- @jira_config = file_system.load_json(filename)
119
+ @jira_config = file_system.load_json(filename, fail_on_error: false)
120
+ raise "Unable to load Jira configuration file and cannot continue: #{filename.inspect}" if @jira_config.nil?
121
+
120
122
  @jira_config['url'] = $1 if @jira_config['url'] =~ /^(.+)\/+$/
121
123
  end
122
124
  @jira_config
@@ -66,15 +66,20 @@ class FileConfig
66
66
  # is that all empty values in the first column should be at the bottom.
67
67
  def sort_output all_lines
68
68
  all_lines.sort do |a, b|
69
+ result = nil
69
70
  if a[0] == b[0]
70
- a[1..] <=> b[1..]
71
+ result = a[1..] <=> b[1..]
71
72
  elsif a[0].nil?
72
- 1
73
+ result = 1
73
74
  elsif b[0].nil?
74
- -1
75
+ result = -1
75
76
  else
76
- a[0] <=> b[0]
77
+ result = a[0] <=> b[0]
77
78
  end
79
+
80
+ # This will only happen if one of the objects isn't comparable. Seen in production.
81
+ result = -1 if result.nil?
82
+ result
78
83
  end
79
84
  end
80
85
 
@@ -35,6 +35,10 @@ class FileSystem
35
35
  log "Warning: #{message}", more: more, also_write_to_stderr: true
36
36
  end
37
37
 
38
+ def error message, more: nil
39
+ log "Error: #{message}", more: more, also_write_to_stderr: true
40
+ end
41
+
38
42
  def log message, more: nil, also_write_to_stderr: false
39
43
  message += " See #{logfile_name} for more details about this message." if more
40
44
 
@@ -27,7 +27,7 @@ class FlowEfficiencyScatterplot < ChartBase
27
27
  </mfrac>
28
28
  </math>
29
29
  </div>
30
- <div style="background: yellow">Note that for this calculation to be accurate, we must be moving items into a
30
+ <div style="background: var(--warning-banner)">Note that for this calculation to be accurate, we must be moving items into a
31
31
  blocked or stalled state the moment we stop working on it, and most teams don't do that.
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>
@@ -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'
@@ -6,7 +6,7 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
6
6
  {
7
7
  type: 'bar',
8
8
  data: {
9
- labels: [<%= column_headings.collect(&:inspect).join(',') %>],
9
+ labels: [<%= @board_columns.collect { |c| c.name.inspect }.join(',') %>],
10
10
  datasets: <%= JSON.generate(data_sets) %>
11
11
  },
12
12
  options: {
@@ -22,8 +22,10 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
22
22
  labelString: 'Date Completed'
23
23
  },
24
24
  grid: {
25
- color: <%= CssVariable['--grid-line-color'].to_json %>
25
+ color: <%= CssVariable['--grid-line-color'].to_json %>,
26
+ z: 1 // draw the grid lines on top of the bars
26
27
  },
28
+ stacked: true
27
29
  },
28
30
  y: {
29
31
  scaleLabel: {
@@ -35,8 +37,11 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
35
37
  text: 'Age in days'
36
38
  },
37
39
  grid: {
38
- color: <%= CssVariable['--grid-line-color'].to_json %>
40
+ color: <%= CssVariable['--grid-line-color'].to_json %>,
41
+ z: 1 // draw the grid lines on top of the bars
39
42
  },
43
+ stacked: true,
44
+ max: <%= (@max_age * 1.1).to_i %>
40
45
  }
41
46
  },
42
47
  plugins: {
@@ -44,14 +49,26 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
44
49
  callbacks: {
45
50
  label: function(context) {
46
51
  if( typeof(context.dataset.data[context.dataIndex]) == "number" ) {
47
- return "85% of the issues, leave this column in "+context.dataset.data[context.dataIndex]+" days";
52
+ let full_data = <%= @bar_data.inspect %>;
53
+ let columnIndex = context.dataIndex;
54
+ let rowIndex = context.datasetIndex - <%= @row_index_offset %>;
55
+ return context.dataset.label + " of completed work items left this column in " +full_data[rowIndex][columnIndex] + " days or less";
48
56
  }
49
57
  else {
50
- return context.dataset.data[context.dataIndex].title
58
+ return context.dataset.data[context.dataIndex].title;
51
59
  }
52
60
  }
53
61
  }
62
+ },
63
+ legend: {
64
+ labels: {
65
+ filter: function(item, chart) {
66
+ // Logic to remove a particular legend item goes here
67
+ return !item.text.includes('%');
68
+ }
69
+ }
54
70
  }
71
+
55
72
  }
56
73
  }
57
74
  });
@@ -1,11 +1,12 @@
1
1
  <table class='standard'>
2
2
  <thead>
3
3
  <tr>
4
- <th>Age (days)</th>
5
- <th>E</th>
6
- <th>B</th>
4
+ <th title="Age in days">Age</th>
5
+ <th title="Expedited">E</th>
6
+ <th title="Blocked / Stalled">B/S</th>
7
7
  <th>Issue</th>
8
8
  <th>Status</th>
9
+ <th>Forecast</th>
9
10
  <th>Fix versions</th>
10
11
  <% if any_scrum_boards %>
11
12
  <th>Sprints</th>
@@ -41,6 +42,7 @@
41
42
  <% end %>
42
43
  </td>
43
44
  <td><%= format_status issue.status, board: issue.board %></td>
45
+ <td><%= dates_text(issue) %></td>
44
46
  <td><%= fix_versions_text(issue) %></td>
45
47
  <% if any_scrum_boards %>
46
48
  <td><%= sprints_text(issue) %></td>
@@ -1,6 +1,57 @@
1
1
  <div class="chart">
2
2
  <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
3
3
  </div>
4
+ <%
5
+ if show_stats
6
+ link_id = next_id
7
+ issues_id = next_id
8
+ %>
9
+ [<a id='<%= link_id %>' href="#" onclick='expand_collapse("<%= link_id %>", "<%= issues_id %>"); return false;'>Show details</a>]
10
+ <div id="<%= issues_id %>" style="display: none;">
11
+ <div>
12
+ <table class="standard">
13
+ <tr>
14
+ <th>Issue Type</th>
15
+ <th>Min</th>
16
+ <th>Max</th>
17
+ <th>Avg</th>
18
+ <th>Mode</th>
19
+ <% percentiles.each do |p| %>
20
+ <th><%= p %>th</th>
21
+ <% end %>
22
+ </tr>
23
+ <% the_stats.each do |k, v| %>
24
+ <tr>
25
+ <td><%= k %></td>
26
+ <td style="text-align: right;"><%= v[:min] %></td>
27
+ <td style="text-align: right;"><%= v[:max] %></td>
28
+ <td style="text-align: right;"><%= sprintf('%.2f', v[:average]) %></td>
29
+ <td><%= v[:mode].join(', ') %></td>
30
+ <% percentiles.each do |p| %>
31
+ <td style="text-align: right;"><%= v[:percentiles][p] %></td>
32
+ <% end %>
33
+ </tr>
34
+ <% end %>
35
+ </table>
36
+ </div>
37
+ <div>
38
+ <p>These statistics help understand the <i>"shape"</i> of the cycletime histogram distribution, to help us with predictions.</p>
39
+ <ul>
40
+ <li><b>Min & Max:</b> the observed spread for the data set. Useful to judge how wide the variation is. </li>
41
+ <li><b>Average:</b> the arithmetic mean of the data set. Useful as a <i>"typical representative"</i> of the complete set.</li>
42
+ <li><b>Mode:</b> the most repeated value(s) in the data set. This is the value we're most likely to remember. </li>
43
+ <li><b>Percentiles:</b> they partition the data set. If X is the Nth percentile, it means that N% of cycletime values are X or less. Typical percentiles of interest are:</li>
44
+ <ul>
45
+ <li><b>50%</b>: also known as the <b>Median</b>. Useful to establish short feedback loops, to monitor that it's not drifting to the right.</li>
46
+ <li><b>85%</b>: useful to establish service level expectations, accounting for rare events..</li>
47
+ <li><b>98% (or higher)</b>: useful to gauge worst case expectations..</li>
48
+ </ul>
49
+ </ul>
50
+ </div>
51
+ </div>
52
+ <%
53
+ end
54
+ %>
4
55
  <script>
5
56
  new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
6
57
  {
@@ -21,6 +72,8 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
21
72
  grid: {
22
73
  color: <%= CssVariable['--grid-line-color'].to_json %>
23
74
  },
75
+ min: 0,
76
+ offset: false, // Gets rid of the ugly padding on left.
24
77
  },
25
78
  y: {
26
79
  stacked: true,
@@ -34,6 +87,27 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
34
87
  }
35
88
  },
36
89
  plugins: {
90
+ annotation: {
91
+ annotations: {
92
+ <%
93
+ results = the_stats[:all][:percentiles]
94
+ results.each do |percentile, value|
95
+ %>
96
+ percentile<%= percentile.to_s %>: {
97
+ type: 'line',
98
+ scaleID: 'x',
99
+ value: <%= value %>,
100
+ borderWidth: 1,
101
+ drawTime: 'beforeDatasetsDraw',
102
+ label: {
103
+ enabled: true,
104
+ content: '<%= "#{percentile}%" %>',
105
+ position: 'start',
106
+ }
107
+ },
108
+ <% end %>
109
+ },
110
+ },
37
111
  tooltip: {
38
112
  callbacks: {
39
113
  label: function(context) {
@@ -53,16 +53,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
53
53
  autocolors: false,
54
54
  annotation: {
55
55
  annotations: {
56
- <% holidays.each_with_index do |range, index| %>
57
- holiday<%= index %>: {
58
- drawTime: 'beforeDraw',
59
- type: 'box',
60
- xMin: '<%= range.begin %>T00:00:00',
61
- xMax: '<%= range.end %>T23:59:59',
62
- backgroundColor: <%= CssVariable.new('--non-working-days-color').to_json %>,
63
- borderColor: <%= CssVariable.new('--non-working-days-color').to_json %>
64
- },
65
- <% end %>
56
+ <%= working_days_annotation %>
66
57
 
67
58
  <% @percentage_lines.each_with_index do |args, index| %>
68
59
  <% percent, color = args %>
@@ -50,16 +50,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
50
50
  },
51
51
  annotation: {
52
52
  annotations: {
53
- <% holidays.each_with_index do |range, index| %>
54
- holiday<%= index %>: {
55
- drawTime: 'beforeDraw',
56
- type: 'box',
57
- xMin: '<%= range.begin %>T00:00:00',
58
- xMax: '<%= range.end %>T23:59:59',
59
- backgroundColor: <%= CssVariable.new('--non-working-days-color').to_json %>,
60
- borderColor: <%= CssVariable.new('--non-working-days-color').to_json %>
61
- },
62
- <% end %>
53
+ <%= working_days_annotation %>
63
54
  }
64
55
  },
65
56
  legend: {
@@ -55,16 +55,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
55
55
  autocolors: false,
56
56
  annotation: {
57
57
  annotations: {
58
- <% holidays.each_with_index do |range, index| %>
59
- holiday<%= index %>: {
60
- drawTime: 'beforeDraw',
61
- type: 'box',
62
- xMin: '<%= range.begin %>T00:00:00',
63
- xMax: '<%= range.end %>T23:59:59',
64
- backgroundColor: <%= CssVariable.new('--non-working-days-color').to_json %>,
65
- borderColor: <%= CssVariable.new('--non-working-days-color').to_json %>
66
- },
67
- <% end %>
58
+ <%= working_days_annotation %>
68
59
  }
69
60
  }
70
61
  }
@@ -2,6 +2,7 @@
2
2
  --body-background: white;
3
3
  --default-text-color: black;
4
4
  --grid-line-color: lightgray;
5
+ --warning-banner: yellow;
5
6
 
6
7
  --cycletime-scatterplot-overall-trendline-color: gray;
7
8
 
@@ -27,8 +28,16 @@
27
28
  --throughput_chart_total_line_color: gray;
28
29
 
29
30
  --aging-work-in-progress-chart-shading-color: lightgray;
31
+ --aging-work-in-progress-chart-shading-50-color: #2E8BC0; // green;
32
+ --aging-work-in-progress-chart-shading-85-color: #ADD8E6; // yellow;
33
+ --aging-work-in-progress-chart-shading-98-color: #FF8A8A; // orange;
34
+ --aging-work-in-progress-chart-shading-100-color: #FF2E2E; // red;
35
+
30
36
  --aging-work-in-progress-by-age-trend-line-color: gray;
31
37
 
38
+ --aging-work-table-date-in-jeopardy: yellow;
39
+ --aging-work-table-date-overdue: red;
40
+
32
41
  --hierarchy-table-inactive-item-text-color: gray;
33
42
 
34
43
  --wip-chart-completed-color: #00ff00;
@@ -135,6 +144,8 @@ ul.quality_report {
135
144
 
136
145
  @media screen and (prefers-color-scheme: dark) {
137
146
  :root {
147
+ --warning-banner: #9F2B00;
148
+
138
149
  --non-working-days-color: #2f2f2f;
139
150
  --type-story-color: #6fb86f;
140
151
  --type-task-color: #0021b3;
@@ -150,8 +161,6 @@ ul.quality_report {
150
161
  --dead-color: black;
151
162
  --wip-chart-active-color: #2551c1;
152
163
 
153
- --aging-work-in-progress-chart-shading-color: #b4b4b4;
154
-
155
164
  --status-category-inprogress-color: #1c49bb;
156
165
 
157
166
  --cycletime-scatterplot-overall-trendline-color: gray;
@@ -23,13 +23,20 @@
23
23
  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {
24
24
  location.reload()
25
25
  })
26
-
27
26
  </script>
28
27
  <style>
29
28
  <%= css %>
30
29
  </style>
30
+ <script type="text/javascript">
31
+ Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--default-text-color');
32
+ </script>
31
33
  </head>
32
34
  <body>
35
+ <noscript>
36
+ <div style="padding: 1em; background: gray; color: white; font-size: 2em;">
37
+ Javascript is currently disabled and that means that almost all of the charts in this report won't render. If you'd loaded this from a folder on SharePoint then save it locally and load it again.
38
+ </div>
39
+ </noscript>
33
40
  <%= "\n" + @sections.collect { |text, type| text if type == :header }.compact.join("\n\n") %>
34
41
  <%= "\n" + @sections.collect { |text, type| text if type == :body }.compact.join("\n\n") %>
35
42
  </body>
@@ -56,16 +56,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
56
56
  },
57
57
  annotation: {
58
58
  annotations: {
59
- <% holidays().each_with_index do |range, index| %>
60
- holiday<%= index %>: {
61
- drawTime: 'beforeDraw',
62
- type: 'box',
63
- xMin: '<%= range.begin %>T00:00:00',
64
- xMax: '<%= range.end %>T23:59:59',
65
- backgroundColor: <%= CssVariable.new('--non-working-days-color').to_json %>,
66
- borderColor: <%= CssVariable.new('--non-working-days-color').to_json %>
67
- },
68
- <% end %>
59
+ <%= working_days_annotation %>
69
60
  }
70
61
  }
71
62
  }
@@ -52,16 +52,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
52
52
  },
53
53
  annotation: {
54
54
  annotations: {
55
- <% holidays.each_with_index do |range, index| %>
56
- holiday<%= index %>: {
57
- drawTime: 'beforeDraw',
58
- type: 'box',
59
- xMin: '<%= range.begin %>T00:00:00',
60
- xMax: '<%= range.end %>T23:59:59',
61
- backgroundColor: <%= CssVariable.new('--non-working-days-color').to_json %>,
62
- borderColor: <%= CssVariable.new('--non-working-days-color').to_json %>
63
- },
64
- <% end %>
55
+ <%= working_days_annotation %>
65
56
  }
66
57
  }
67
58
  }
@@ -49,14 +49,26 @@ class Issue
49
49
 
50
50
  def summary = @raw['fields']['summary']
51
51
 
52
- def status = Status.from_raw(@raw['fields']['status'])
53
-
54
52
  def labels = @raw['fields']['labels'] || []
55
53
 
56
54
  def author = @raw['fields']['creator']&.[]('displayName') || ''
57
55
 
58
56
  def resolution = @raw['fields']['resolution']&.[]('name')
59
57
 
58
+ def status
59
+ @status = Status.from_raw(@raw['fields']['status']) unless @status
60
+ @status
61
+ end
62
+
63
+ def status= status
64
+ @status = status
65
+ end
66
+
67
+ def due_date
68
+ text = @raw['fields']['duedate']
69
+ text.nil? ? nil : Date.parse(text)
70
+ end
71
+
60
72
  def url
61
73
  # Strangely, the URL isn't anywhere in the returned data so we have to fabricate it.
62
74
  "#{@board.server_url_prefix}/browse/#{key}"
@@ -129,13 +141,16 @@ class Issue
129
141
  end
130
142
 
131
143
  def most_recent_status_change
132
- # We artificially insert a status change to represent creation so by definition there will always be at least one.
133
- changes.reverse.find { |change| change.status? }
144
+ # Any issue that we loaded from its own file will always have a status as we artificially insert a status
145
+ # change to represent creation. Issues that were just fragments referenced from a main issue (ie a linked issue)
146
+ # may not have any status changes as we have no idea when it was created. This will be nil in that case
147
+ status_changes.last
134
148
  end
135
149
 
136
150
  # Are we currently in this status? If yes, then return the most recent status change.
137
151
  def currently_in_status *status_names
138
152
  change = most_recent_status_change
153
+ return false if change.nil?
139
154
 
140
155
  change if change.current_status_matches(*status_names)
141
156
  end
@@ -145,6 +160,7 @@ class Issue
145
160
  category_ids = find_status_category_ids_by_names category_names
146
161
 
147
162
  change = most_recent_status_change
163
+ return false if change.nil?
148
164
 
149
165
  status = find_or_create_status id: change.value_id, name: change.value
150
166
  change if status && category_ids.include?(status.category.id)
@@ -604,12 +620,17 @@ class Issue
604
620
  end
605
621
 
606
622
  (changes + (@discarded_changes || [])).each do |change|
607
- value = change.value
608
- old_value = change.old_value
623
+ if change.status?
624
+ value = "#{change.value.inspect}:#{change.value_id.inspect}"
625
+ old_value = change.old_value ? "#{change.old_value.inspect}:#{change.old_value_id.inspect}" : nil
626
+ else
627
+ value = compact_text(change.value).inspect
628
+ old_value = change.old_value ? compact_text(change.old_value).inspect : nil
629
+ end
609
630
 
610
631
  message = +''
611
- message << "#{compact_text(old_value).inspect} -> " unless old_value.nil? || old_value.empty?
612
- message << compact_text(value).inspect
632
+ message << "#{old_value} -> " unless old_value.nil? || old_value.empty?
633
+ message << value
613
634
  if change.artificial?
614
635
  message << ' (Artificial entry)' if change.artificial?
615
636
  else
@@ -14,9 +14,15 @@ class JiraGateway
14
14
  def call_url relative_url:
15
15
  command = make_curl_command url: "#{@jira_url}#{relative_url}"
16
16
  result = call_command command
17
- JSON.parse result
18
- rescue => e # rubocop:disable Style/RescueStandardError
19
- raise "Error #{e.message.inspect} when parsing result: #{result.inspect}"
17
+ begin
18
+ json = JSON.parse(result)
19
+ rescue # rubocop:disable Style/RescueStandardError
20
+ raise "Error when parsing result: #{result.inspect}"
21
+ end
22
+
23
+ raise "Download failed with: #{JSON.pretty_generate(json)}" unless json_successful?(json)
24
+
25
+ json
20
26
  end
21
27
 
22
28
  def call_command command
@@ -61,4 +67,11 @@ class JiraGateway
61
67
  command << " --url \"#{url}\""
62
68
  command
63
69
  end
70
+
71
+ def json_successful? json
72
+ return false if json.is_a?(Hash) && (json['error'] || json['errorMessages'] || json['errorMessage'])
73
+ return false if json.is_a?(Array) && json.first == 'errorMessage'
74
+
75
+ true
76
+ end
64
77
  end
@@ -106,7 +106,7 @@ class ProjectConfig
106
106
 
107
107
  def board id:, &block
108
108
  config = BoardConfig.new(id: id, block: block, project_config: self)
109
- config.run
109
+ config.run if data_downloaded?
110
110
  @board_configs << config
111
111
  end
112
112
 
@@ -36,13 +36,10 @@ class Status
36
36
  end
37
37
 
38
38
  def self.from_raw raw
39
- category_config = raw['statusCategory']
39
+ raise "raw cannot be nil" if raw.nil?
40
40
 
41
- legal_keys = %w[new indeterminate done]
42
- unless legal_keys.include? category_config['key']
43
- puts "Category key #{category_config['key'].inspect} should be one of #{legal_keys.inspect}. Found:\n" \
44
- "#{category_config}"
45
- end
41
+ category_config = raw['statusCategory']
42
+ raise "statusCategory can't be nil in #{category_config.inspect}" if category_config.nil?
46
43
 
47
44
  Status.new(
48
45
  name: raw['name'],