ticket-replicator 1.2.2 → 1.2.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9d8cdf36c2c6bc96ceb08272851ec42e13930c197445ee42e260353df78f057d
4
- data.tar.gz: 96ad413118741babd686b45616b7cb9e30cc8c4db217360bae328dcba2cbadcb
3
+ metadata.gz: f526835796d74716dc38bff10d6be52892b547ad78c089b8a7e77e5fc080ffde
4
+ data.tar.gz: 0f004dbeda462964f94aa14f8ddaf51edca62dc1ac73d3f1fb89fab209e3a5d7
5
5
  SHA512:
6
- metadata.gz: 1f97d775bf301d0a8d7dfac17b1a78baa41ff98e2a68bfc72c87af863c36dff6b53511d67591a44e4a7539f75271a687dab4cca63c7f0e3ef6aa34c207dd63ba
7
- data.tar.gz: d03951718b1d61b6e82e8094614ce4c68ebef62ea55900c8d53d0b2411a1a96b48b61ed1b8dfe4c5c733476f6b84d8fb156a25654c44cb5e74deb619734457e5
6
+ metadata.gz: 34c1f9ad5dd0f245d72f483c507515c44055f2489039320667ad5430aeea3a58585b2be0e2c4075eebccc98ee2924495bf07afa1f78c94463d27c14566eba58f
7
+ data.tar.gz: dcd4d81a0ce4361f906e4fe921421035987db61693ed224fbbcf14bd2d3d6e8f3eaa534d1b90c26f2f8d1958af34050ddfd4dffb947021f1c51652c73e8ac705
data/README.md CHANGED
@@ -52,6 +52,8 @@ Optional environment variables:
52
52
  - `JAT_RATE_INTERVAL_IN_SECONDS` - Interval for rate limiting in seconds (e.g., "1").
53
53
  - `JAT_RATE_LIMIT_PER_INTERVAL` - Rate limit per interval for Jira API calls (e.g., "1")
54
54
  - `TICKET_REPLICATOR_DISABLE_RESOLUTION_LOADING` - To deal with tickets not having a resolution field.
55
+ - `TICKET_REPLICATOR_ONLY_UPDATE_LINKS_FOR_MODIFIED_TICKETS` - Limit the amount of requests
56
+ to modified tickets only (default to "true" when unset). Set to "false" to disable this feature.
55
57
 
56
58
  ### Setup Queue Folder And Field Mapping
57
59
 
@@ -112,12 +112,37 @@ Feature: Load tickets into Jira
112
112
  """
113
113
  And I successfully run `ticket-replicator --jira-http-debug --load`
114
114
  Then the Jira project should only have the following tickets:
115
- | example purpose | status | resolution | priority | summary | source_ticket_url |
116
- | one update due to status change | Closed | | Highest | SMAN-10001 \| Login page randomly fails to load CSS assets (10001) | http://url/to/source/ticket/10001 |
117
- | one update due to priority, resolution and summary updates | In Process | | High | SMAN-10002 \| *Recurring* database deadlock during order processing (10002) | http://url/to/source/ticket/10002 |
118
- | three updates due to new ticket created (fields, source ticket link and status) | Open | | Highest | SMAN-10008 \| Memory leak in caching implementation (10008) | http://url/to/source/ticket/10008 |
119
- | no update since no status or field change | Confirmed | Done | High | SMAN-10016 \| Slow response time on product search API (10016) | http://url/to/source/ticket/10016 |
120
- And only 5 Jira update requests were emitted
115
+ | example purpose | status | resolution | priority | summary | source_ticket_url |
116
+ | one update due to status change | Closed | | Highest | SMAN-10001 \| Login page randomly fails to load CSS assets (10001) | http://url/to/source/ticket/10001 |
117
+ | two updates due to field updated (priority, resolution and summary) and updating the remote link | In Process | | High | SMAN-10002 \| *Recurring* database deadlock during order processing (10002) | http://url/to/source/ticket/10002 |
118
+ | three updates due to new ticket created (fields, source ticket link and status) | Open | | Highest | SMAN-10008 \| Memory leak in caching implementation (10008) | http://url/to/source/ticket/10008 |
119
+ | no update since no status or field change | Confirmed | Done | High | SMAN-10016 \| Slow response time on product search API (10016) | http://url/to/source/ticket/10016 |
120
+ And only 6 Jira update requests were emitted
121
+
122
+ Scenario: Updating fields and statuses of closed and withdrawn tickets is possible
123
+ Given a file named "queue/20.transformed/10.initial_replication.csv" with:
124
+ """
125
+ "ID","Status","Resolution","Priority","Summary","Source Ticket URL"
126
+ "10001","Closed","","Highest","SMAN-10001 | Possible to update summary and resolution of closed ticket (100001)","http://url/to/source/ticket/10001"
127
+ "10002","Closed","","Low","SMAN-10002 | Reopening ticket while updating priority (10002)","http://url/to/source/ticket/10002"
128
+ "10003","Withdrawn","Won't Do","Medium","SMAN-10003 | Possible to update a resolved ticket (10003)","http://url/to/source/ticket/10003"
129
+ "10004","Withdrawn","Done","Low","SMAN-10004 | Re-opening resolved ticket (10004)","http://url/to/source/ticket/10004"
130
+ """
131
+ And a file named "queue/20.transformed/20.update_replication.csv" with:
132
+ """
133
+ "ID","Status","Resolution","Priority","Summary","Source Ticket URL"
134
+ "10001","Closed","Done","Highest","SMAN-10001 | Successfully updated summary and resolution of the closed ticket (100001)","http://url/to/source/ticket/10001"
135
+ "10002","Open","","Highest","SMAN-10002 | Successfully reopened ticket while its updating priority and summary (10002)","http://url/to/source/ticket/10002"
136
+ "10003","Withdrawn","Done","High","SMAN-10003 | Successfully updated resolved ticket (10003)","http://url/to/source/ticket/10003"
137
+ "10004","Open","","Low","SMAN-10004 | Successfully re-opened and updated the ticket (10004)","http://url/to/source/ticket/10004"
138
+ """
139
+ When I successfully run `ticket-replicator --load`
140
+ Then the Jira project should only have the following tickets:
141
+ | status | resolution | priority | summary | source_ticket_url |
142
+ | Closed | Done | Highest | SMAN-10001 \| Successfully updated summary and resolution of the closed ticket (100001) | http://url/to/source/ticket/10001 |
143
+ | Open | | Highest | SMAN-10002 \| Successfully reopened ticket while its updating priority and summary (10002) | http://url/to/source/ticket/10002 |
144
+ | Withdrawn | Done | High | SMAN-10003 \| Successfully updated resolved ticket (10003) | http://url/to/source/ticket/10003 |
145
+ | Open | | Low | SMAN-10004 \| Successfully re-opened and updated the ticket (10004) | http://url/to/source/ticket/10004 |
121
146
 
122
147
  Scenario: Attempting to set an unexpected status generates information about the current row being loaded
123
148
  Given a file named "queue/20.transformed/sap_solution_manager_defects.csv" with:
@@ -137,7 +162,7 @@ Feature: Load tickets into Jira
137
162
  Then it should fail with:
138
163
  """
139
164
  ERROR Object : Ticket::Replicator::FileLoader::LoadError: queue/20.transformed/sap_solution_manager_defects.csv:5: error while loading row:
140
- No transition found for "____ INEXISTING STATUS ____" in ["No Error -> No Error", "Tester Action -> Tester Action", "Close Bug -> Closed", "Confirmed -> Confirmed", "Defect Correction in Process -> Defect Correction in Process", "Deferred -> Deferred", "Forwarded -> Forwarded", "Information Required -> Information Required", "New -> New", "Open -> Open", "Solution Proposal -> Solution Proposal", "Wait for Defect Correction -> Wait for Defect Correction", "Wait on External -> Wait on External", "Withdrawn -> Withdrawn", "In Process -> In Process"].:
165
+ No transition found for "____ INEXISTING STATUS ____" in ["No Error => No Error", "Tester Action => Tester Action", "Close Bug => Closed", "Confirmed => Confirmed", "Defect Correction in Process => Defect Correction in Process", "Deferred => Deferred", "Forwarded => Forwarded", "Information Required => Information Required", "New => New", "Open => Open", "Solution Proposal => Solution Proposal", "Wait for Defect Correction => Wait for Defect Correction", "Wait on External => Wait on External", "Withdrawn => Withdrawn", "In Process => In Process"].:
141
166
  #<CSV::Row id:"10004" status:"____ INEXISTING STATUS ____" resolution:"Done" priority:"Low" summary:"SMAN-10004 | App crashes when offline on Android 12 (10004)" source_ticket_url:"http://url/to/source/ticket/10004">
142
167
  """
143
168
 
@@ -9,7 +9,7 @@ Feature: Transform and load extracted ticket queue
9
9
  | TICKET_REPLICATOR_JIRA_PROJECT_KEY |
10
10
  | TICKET_REPLICATOR_JIRA_TICKET_TYPE_NAME |
11
11
  And the following environment variables have been set:
12
- | name | value |
12
+ | name | value |
13
13
  | TICKET_REPLICATOR_SOURCE_TICKET_URL | http://url/to/source/ticket/<%= source_ticket_id %> |
14
14
 
15
15
  And a file named "config/ticket-replicator.mappings.yml" with:
@@ -34,7 +34,7 @@ module Ticket
34
34
  #{row.inspect}
35
35
  EOERROR
36
36
 
37
- raise LoadError, message
37
+ raise LoadError, message, e.backtrace
38
38
  end
39
39
  end
40
40
 
@@ -40,17 +40,23 @@ module Ticket
40
40
  end
41
41
  end
42
42
 
43
+ private
44
+
43
45
  def extracted_rows
44
46
  raise NotImplementedError, "You must implement this method in your subclass"
45
47
  end
46
48
 
49
+ def mappings
50
+ @mappings ||= Mappings.new
51
+ end
52
+
47
53
  FIRST_EXTRACTED_DATA_ROW_LINE_NUMBER = 2
48
54
  private_constant :FIRST_EXTRACTED_DATA_ROW_LINE_NUMBER
49
55
 
50
56
  def transformed_rows
51
57
  row_index = FIRST_EXTRACTED_DATA_ROW_LINE_NUMBER
52
58
  extracted_rows.collect do |row|
53
- RowTransformer.run_on(row)
59
+ RowTransformer.run_on(row, mappings)
54
60
  rescue StandardError => e
55
61
  raise TransformError, <<~EOERRORMSG
56
62
  #{extracted_path}:#{row_index}: error while transforming row:
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ticket
4
+ class Replicator
5
+ class Mappings
6
+ def self.file_path
7
+ File.join("config", "ticket-replicator.mappings.yml")
8
+ end
9
+
10
+ def to_h
11
+ @to_h = YAML.safe_load_file(self.class.file_path, symbolize_names: false)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -38,10 +38,14 @@ module Ticket
38
38
  def save_ticket
39
39
  return unless ticket_fields_need_to_be_updated?
40
40
 
41
+ reopen_ticket_if_status_in_done_category
42
+
41
43
  ticket.jira_ticket.save!(attributes_for_save)
42
44
 
43
45
  ticket.jira_ticket.fetch
44
46
 
47
+ @ticket_was_just_updated = true
48
+
45
49
  jira_project.replicated_tickets[id] = ticket
46
50
  end
47
51
 
@@ -78,6 +82,12 @@ module Ticket
78
82
  nil
79
83
  end
80
84
 
85
+ def reopen_ticket_if_status_in_done_category
86
+ return unless ticket_previously_replicated? && ticket.status_in_done_category?
87
+
88
+ ticket.transition_to_not_done
89
+ end
90
+
81
91
  def ticket_fields_need_to_be_updated?
82
92
  !ticket_previously_replicated? || ticket_fields_changed?
83
93
  end
@@ -100,6 +110,8 @@ module Ticket
100
110
 
101
111
  # rubocop:disable Metrics/MethodLength
102
112
  def source_ticket_link_needs_update?
113
+ return ticket_was_just_updated? if only_update_links_for_modified_ticket?
114
+
103
115
  existing_ticket_link_attributes = ticket.source_ticket_link&.attrs
104
116
 
105
117
  object = existing_ticket_link_attributes&.[]("object")
@@ -120,6 +132,10 @@ module Ticket
120
132
  filtered_existing_ticket_link_attributes != ticket_link_attributes
121
133
  end
122
134
 
135
+ def only_update_links_for_modified_ticket?
136
+ ENV.fetch("TICKET_REPLICATOR_ONLY_UPDATE_LINKS_FOR_MODIFIED_TICKETS", "true") != "false"
137
+ end
138
+
123
139
  # rubocop:enable Metrics/MethodLength
124
140
 
125
141
  def source_ticket_link_title
@@ -139,10 +155,15 @@ module Ticket
139
155
  if ticket_previously_replicated?
140
156
  fetch_ticket
141
157
  else
158
+ @ticket_was_just_updated = true
142
159
  create_ticket
143
160
  end
144
161
  end
145
162
 
163
+ def ticket_was_just_updated?
164
+ @ticket_was_just_updated
165
+ end
166
+
146
167
  def fetch_ticket
147
168
  jira_project.replicated_tickets.fetch(id)
148
169
  end
@@ -2,21 +2,23 @@
2
2
 
3
3
  require "ticket/replicator/replicated_summary"
4
4
  require "ticket/replicator/file_transformer"
5
+ require "ticket/replicator/mappings"
5
6
  require "erb"
6
7
 
7
8
  module Ticket
8
9
  class Replicator
9
10
  class RowTransformer
10
- def self.run_on(extracted_row)
11
- new(extracted_row).run
11
+ def self.run_on(extracted_row, mappings)
12
+ new(extracted_row, mappings).run
12
13
  end
13
14
 
14
15
  private_class_method :new
15
16
 
16
- attr_reader :extracted_row
17
+ attr_reader :extracted_row, :mappings
17
18
 
18
- def initialize(extracted_row)
19
+ def initialize(extracted_row, mappings)
19
20
  @extracted_row = extracted_row
21
+ @mappings = mappings.to_h
20
22
  end
21
23
 
22
24
  def run
@@ -115,10 +117,6 @@ module Ticket
115
117
  end
116
118
  end
117
119
 
118
- def mappings
119
- @mappings ||= YAML.safe_load_file(mapping_file_path, symbolize_names: false)
120
- end
121
-
122
120
  def remapped_field_extracted_row
123
121
  @remapped_field_extracted_row ||= fields_to_remap.to_h do |field|
124
122
  mapped_field_key = remapped_field_key(field)
@@ -142,10 +140,6 @@ module Ticket
142
140
  @extracted_field_mapping ||= mapping_for :field
143
141
  end
144
142
 
145
- def self.mapping_file_path
146
- File.join("config", "ticket-replicator.mappings.yml")
147
- end
148
-
149
143
  private
150
144
 
151
145
  def extracted_row_value_for(mapped_field_key)
@@ -35,7 +35,7 @@ module Ticket
35
35
  def config_file_path
36
36
  @config_file_path ||=
37
37
  begin
38
- path = RowTransformer.mapping_file_path
38
+ path = Mappings.file_path
39
39
  FileUtils.mkdir_p(File.dirname(path))
40
40
  path
41
41
  end
@@ -21,7 +21,7 @@ module Ticket
21
21
  end
22
22
 
23
23
  def transition_to(status)
24
- TicketStatusTransitioner.new(self).transition_to(status)
24
+ status_transitioner.transition_to(status)
25
25
  end
26
26
 
27
27
  def <=>(other)
@@ -49,8 +49,20 @@ module Ticket
49
49
  source_ticket_link_attributes(source_ticket_link)&.[]("object")&.[]("url")
50
50
  end
51
51
 
52
+ def status_in_done_category?
53
+ jira_ticket.status.attrs.fetch("statusCategory").fetch("key") == "done"
54
+ end
55
+
56
+ def transition_to_not_done
57
+ status_transitioner.transition_to_not_done
58
+ end
59
+
52
60
  private
53
61
 
62
+ def status_transitioner
63
+ TicketStatusTransitioner.new(self)
64
+ end
65
+
54
66
  def source_ticket_link_attributes(link)
55
67
  link&.attrs
56
68
  end
@@ -2,7 +2,10 @@
2
2
 
3
3
  module Ticket
4
4
  class Replicator
5
+ # Assumption: The ticket workflow allows for transitioning to any other status.
5
6
  class TicketStatusTransitioner
7
+ StatusTransitionerError = Class.new(StandardError)
8
+
6
9
  attr_reader :ticket
7
10
 
8
11
  def initialize(ticket)
@@ -21,14 +24,27 @@ module Ticket
21
24
  http_headers)
22
25
  end
23
26
 
27
+ def transition_to_not_done
28
+ not_done_status =
29
+ available_transitions.find do |transition|
30
+ transition.fetch("to").fetch("statusCategory").fetch("key") != "done"
31
+ end or
32
+ raise StatusTransitionerError, build_error_message("No non-done transition found")
33
+
34
+ transition_to(not_done_status.fetch("to").fetch("name"))
35
+ end
36
+
24
37
  def transition_payload_for(status)
25
38
  transition = available_transitions.find { |transition| transition.fetch("to").fetch("name") == status } or
26
- raise "No transition found for #{status.inspect} in " \
27
- "#{available_transitions.collect { |t| "#{t["name"]} -> #{t["to"]["name"]}" }.inspect}."
39
+ raise StatusTransitionerError, build_error_message("No transition found for #{status.inspect}")
28
40
 
29
41
  { transition: { id: transition["id"] } }.to_json
30
42
  end
31
43
 
44
+ def build_error_message(message)
45
+ message + " in #{available_transitions.collect { |t| "#{t["name"]} => #{t["to"]["name"]}" }.inspect}."
46
+ end
47
+
32
48
  def available_transitions
33
49
  response = tool.jira_client.get(tool.jira_url("/rest/api/2/issue/#{ticket.key}?expand=transitions"))
34
50
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ticket
4
4
  class Replicator
5
- VERSION = "1.2.2"
5
+ VERSION = "1.2.3"
6
6
  end
7
7
  end
@@ -43,34 +43,34 @@ module Ticket
43
43
 
44
44
  describe "#transformed_headers" do
45
45
  it "returns the headers" do
46
- expect(transformer.transformed_headers)
46
+ expect(transformer.send(:transformed_headers))
47
47
  .to eq(%w[ID Status Resolution Priority Summary] + ["Source Ticket URL"])
48
48
  end
49
49
  end
50
50
 
51
51
  describe "#transformed_rows" do
52
52
  before do
53
- allow(transformer).to receive_messages(extracted_rows: %i[a_row another_row])
53
+ allow(transformer).to receive_messages(extracted_rows: %i[a_row another_row], mappings: :mappings)
54
54
  end
55
55
 
56
56
  it "returns the rows" do
57
- allow(RowTransformer).to receive(:run_on).with(:a_row).and_return(:a_tranformed_row)
58
- allow(RowTransformer).to receive(:run_on).with(:another_row).and_return(:another_transformed_row)
57
+ allow(RowTransformer).to receive(:run_on).with(:a_row, :mappings).and_return(:a_tranformed_row)
58
+ allow(RowTransformer).to receive(:run_on).with(:another_row, :mappings).and_return(:another_transformed_row)
59
59
 
60
- expect(transformer.transformed_rows).to eq(%i[a_tranformed_row another_transformed_row])
60
+ expect(transformer.send(:transformed_rows)).to eq(%i[a_tranformed_row another_transformed_row])
61
61
  end
62
62
 
63
63
  context "when a row transformation error occurs" do
64
64
  before do
65
- allow(RowTransformer).to receive(:run_on).with(:a_row).and_return(:a_tranformed_row)
65
+ allow(RowTransformer).to receive(:run_on).with(:a_row, :mappings).and_return(:a_tranformed_row)
66
66
 
67
67
  allow(RowTransformer)
68
- .to receive(:run_on).with(:another_row)
68
+ .to receive(:run_on).with(:another_row, :mappings)
69
69
  .and_raise(TransformError, "transformation error")
70
70
  end
71
71
 
72
72
  it "raises an error message including the file name and line number of the row that was processed" do
73
- expect { transformer.transformed_rows }
73
+ expect { transformer.send(:transformed_rows) }
74
74
  .to raise_error(TransformError,
75
75
  /source\.csv:3: error while transforming row:\ntransformation error:\n:another_row\n/)
76
76
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ticket
4
+ class Replicator
5
+ class Mappings
6
+ RSpec.describe Mappings do
7
+ let(:mappings) { described_class.new }
8
+
9
+ describe ".file_path" do
10
+ it { expect(described_class.file_path).to eq("config/ticket-replicator.mappings.yml") }
11
+ end
12
+
13
+ describe "#to_h" do
14
+ let(:mappings_yaml) do
15
+ <<~'YAML'
16
+ field_mapping:
17
+ id: Defect
18
+ summary: Defect (2)
19
+ id_extraction_regex: "\\((?<id>\\d+)\\)$"
20
+ status_mapping:
21
+ defaults_to: keep_original_value
22
+ Open: Open
23
+ Closed: Closed
24
+ resolution_mapping:
25
+ Closed: Done
26
+ Rejected: Won't Fix
27
+ team_mapping:
28
+ Source Team: Transformed Team
29
+ Front End: Web Team
30
+ priority_mapping:
31
+ 1 - Critical: Critical
32
+ 2 - High: High
33
+ 3 - Medium: Medium
34
+ 4 - Low: Low
35
+ YAML
36
+ end
37
+
38
+ let(:file_io) { instance_double(IO) }
39
+
40
+ it "loads the mappings from the mappings.yml file" do
41
+ expect(IO).to receive(:open).with("config/ticket-replicator.mappings.yml",
42
+ "r:bom|utf-8").and_yield(mappings_yaml)
43
+
44
+ expect(mappings.to_h)
45
+ .to eq(
46
+ "field_mapping" => { "id" => "Defect", "summary" => "Defect (2)" },
47
+ "id_extraction_regex" => "\\((?<id>\\d+)\\)$",
48
+ "status_mapping" => { "defaults_to" => "keep_original_value", "Open" => "Open",
49
+ "Closed" => "Closed" },
50
+ "resolution_mapping" => { "Closed" => "Done", "Rejected" => "Won't Fix" },
51
+ "team_mapping" => { "Source Team" => "Transformed Team", "Front End" => "Web Team" },
52
+ "priority_mapping" =>
53
+ { "1 - Critical" => "Critical", "2 - High" => "High", "3 - Medium" => "Medium",
54
+ "4 - Low" => "Low" }
55
+ )
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -158,8 +158,9 @@ module Ticket
158
158
 
159
159
  it do
160
160
  allow(loader).to receive_messages(attributes_for_save: { fields: { a: "b" } })
161
- expect(jira_ticket).to receive(:save!).with({ fields: { a: "b" } })
162
161
 
162
+ expect(loader).to receive(:reopen_ticket_if_status_in_done_category)
163
+ expect(jira_ticket).to receive(:save!).with({ fields: { a: "b" } })
163
164
  expect(jira_ticket).to receive(:fetch)
164
165
 
165
166
  # TODO: fix this check that should work
@@ -181,6 +182,52 @@ module Ticket
181
182
  end
182
183
  end
183
184
 
185
+ describe "#reopen_ticket_if_status_in_done_category" do
186
+ let(:loader) { described_class.send(:new, jira_project, double("row", id: "123", status: "Closed")) }
187
+ let(:ticket) { instance_double(Ticket) }
188
+
189
+ before do
190
+ allow(loader).to receive_messages(ticket: ticket,
191
+ :ticket_previously_replicated? => actual_ticket_previously_replicated)
192
+ end
193
+
194
+ context "when the ticket was not previously replicated" do
195
+ let(:actual_ticket_previously_replicated) { false }
196
+
197
+ it do
198
+ expect(ticket).not_to receive(:transition_to_not_done)
199
+
200
+ loader.send(:reopen_ticket_if_status_in_done_category)
201
+ end
202
+ end
203
+
204
+ context "when the ticket was previously replicated" do
205
+ let(:actual_ticket_previously_replicated) { true }
206
+
207
+ before { allow(ticket).to receive_messages(status_in_done_category?: actual_status_in_done_category) }
208
+
209
+ context "when the ticket is not closed" do
210
+ let(:actual_status_in_done_category) { false }
211
+
212
+ it do
213
+ expect(ticket).not_to receive(:transition_to_not_done)
214
+
215
+ loader.send(:reopen_ticket_if_status_in_done_category)
216
+ end
217
+ end
218
+
219
+ context "when the ticket is closed" do
220
+ let(:actual_status_in_done_category) { true }
221
+
222
+ it do
223
+ expect(ticket).to receive(:transition_to_not_done)
224
+
225
+ loader.send(:reopen_ticket_if_status_in_done_category)
226
+ end
227
+ end
228
+ end
229
+ end
230
+
184
231
  describe "#attributes_for_save" do
185
232
  let(:loader) { described_class.send(:new, jira_project, row) }
186
233
 
@@ -410,6 +457,24 @@ module Ticket
410
457
  it { expect(loader.send(:source_ticket_link_title)).to eq("Source Ticket 123") }
411
458
  end
412
459
 
460
+ describe "#only_update_links_for_modified_ticket?" do
461
+ it "defaults to true when unset" do
462
+ expect(ENV)
463
+ .to receive(:fetch).with("TICKET_REPLICATOR_ONLY_UPDATE_LINKS_FOR_MODIFIED_TICKETS",
464
+ "true").and_return("true")
465
+
466
+ expect(loader.send(:only_update_links_for_modified_ticket?)).to be_truthy
467
+ end
468
+
469
+ it "when TICKET_REPLICATOR_ONLY_UPDATE_LINKS_FOR_MODIFIED_TICKETS set to false" do
470
+ expect(ENV)
471
+ .to receive(:fetch).with("TICKET_REPLICATOR_ONLY_UPDATE_LINKS_FOR_MODIFIED_TICKETS", "true")
472
+ .and_return("false")
473
+
474
+ expect(loader.send(:only_update_links_for_modified_ticket?)).to be_falsey
475
+ end
476
+ end
477
+
413
478
  describe "#update_source_ticket_remote_link" do
414
479
  before do
415
480
  allow(loader).to receive_messages(source_ticket_link_needs_update?: needs_update)
@@ -454,57 +519,87 @@ module Ticket
454
519
 
455
520
  describe "#source_ticket_link_needs_update?" do
456
521
  before do
457
- allow(ticket).to receive_messages(source_ticket_link: remote_link)
522
+ allow(loader).to receive_messages(only_update_links_for_modified_ticket?: only_update_links_for_new_ticket)
458
523
  end
459
524
 
460
- context "when link has yet to be created" do
461
- let(:remote_link) { nil }
525
+ context "when only for modified tickets" do
526
+ let(:only_update_links_for_new_ticket) { true }
462
527
 
463
- it { expect(loader.send(:source_ticket_link_needs_update?)).to be_truthy }
528
+ before { allow(loader).to receive_messages(ticket_was_just_updated?: actual_was_updated) }
529
+
530
+ context "when the ticket has already been replicated (i.e., exists)" do
531
+ let(:actual_was_updated) { false }
532
+
533
+ it "does not request the remote link information" do
534
+ expect(ticket).not_to receive(:source_ticket_link)
535
+
536
+ expect(loader.send(:source_ticket_link_needs_update?)).to be_falsey
537
+ end
538
+ end
539
+
540
+ context "when the ticket is new" do
541
+ let(:actual_was_updated) { true }
542
+
543
+ it { expect(loader.send(:source_ticket_link_needs_update?)).to be_truthy }
544
+ end
464
545
  end
465
546
 
466
- context "when link already exists" do
467
- let(:remote_link) do
468
- instance_double(JIRA::Resource::Remotelink,
469
- attrs: source_ticket_link,
470
- inspect: "<JIRA::Resource::Remotelink attrs: #{source_ticket_link}>")
547
+ context "when not only for new tickets" do
548
+ let(:only_update_links_for_new_ticket) { false }
549
+
550
+ before do
551
+ allow(ticket).to receive_messages(source_ticket_link: remote_link)
471
552
  end
472
553
 
473
- context "when the link is not up to date" do
474
- let(:source_ticket_link) { build_link(existing_url, existing_title) }
554
+ context "when link has yet to be created" do
555
+ let(:remote_link) { nil }
475
556
 
476
- context "when url has changed" do
477
- let(:existing_url) { "https://__OLD_URL__/to/source/ticket/1234" }
478
- let(:existing_title) { "Source Ticket 123" }
557
+ it { expect(loader.send(:source_ticket_link_needs_update?)).to be_truthy }
558
+ end
479
559
 
480
- it { expect(loader.send(:source_ticket_link_needs_update?)).to be_truthy }
560
+ context "when link already exists" do
561
+ let(:remote_link) do
562
+ instance_double(JIRA::Resource::Remotelink,
563
+ attrs: source_ticket_link,
564
+ inspect: "<JIRA::Resource::Remotelink attrs: #{source_ticket_link}>")
481
565
  end
482
566
 
483
- context "when title has changed" do
484
- let(:existing_url) { "https://url/to/source/ticket/123" }
485
- let(:existing_title) { "__OLD TITLE__Source Ticket 1234" }
567
+ context "when the link is not up to date" do
568
+ let(:source_ticket_link) { build_link(existing_url, existing_title) }
486
569
 
487
- it { expect(loader.send(:source_ticket_link_needs_update?)).to be_truthy }
488
- end
489
- end
570
+ context "when url has changed" do
571
+ let(:existing_url) { "https://__OLD_URL__/to/source/ticket/1234" }
572
+ let(:existing_title) { "Source Ticket 123" }
573
+
574
+ it { expect(loader.send(:source_ticket_link_needs_update?)).to be_truthy }
575
+ end
490
576
 
491
- context "when the link is up to date" do
492
- let(:existing_url) { "https://url/to/source/ticket/123" }
493
- let(:existing_title) { "Source Ticket 123" }
494
- let(:source_ticket_link) do
495
- { "object" => {
496
- "url" => "https://url/to/source/ticket/123",
497
- "title" => "Source Ticket 123",
498
- "icon" => { "title" => "link", "url16x16" => "https://url/to/source/ticket/123/icon" }
499
- },
500
- "application" => { "name" => "Ticket Source" },
501
- "extra_attributes" =>
502
- { "id" => 10_443, "self" => "https://url/to/source/ticket/123/remotelink/10443" } }
577
+ context "when title has changed" do
578
+ let(:existing_url) { "https://url/to/source/ticket/123" }
579
+ let(:existing_title) { "__OLD TITLE__Source Ticket 1234" }
580
+
581
+ it { expect(loader.send(:source_ticket_link_needs_update?)).to be_truthy }
582
+ end
503
583
  end
504
584
 
505
- it("only checks the needed attributes") {
506
- expect(loader.send(:source_ticket_link_needs_update?)).to be_falsey
507
- }
585
+ context "when the link is up to date" do
586
+ let(:existing_url) { "https://url/to/source/ticket/123" }
587
+ let(:existing_title) { "Source Ticket 123" }
588
+ let(:source_ticket_link) do
589
+ { "object" => {
590
+ "url" => "https://url/to/source/ticket/123",
591
+ "title" => "Source Ticket 123",
592
+ "icon" => { "title" => "link", "url16x16" => "https://url/to/source/ticket/123/icon" }
593
+ },
594
+ "application" => { "name" => "Ticket Source" },
595
+ "extra_attributes" =>
596
+ { "id" => 10_443, "self" => "https://url/to/source/ticket/123/remotelink/10443" } }
597
+ end
598
+
599
+ it("only checks the needed attributes") {
600
+ expect(loader.send(:source_ticket_link_needs_update?)).to be_falsey
601
+ }
602
+ end
508
603
  end
509
604
  end
510
605
  end
@@ -524,6 +619,44 @@ module Ticket
524
619
  end
525
620
  end
526
621
  end
622
+
623
+ describe "#ticket_was_just_updated?" do
624
+ let(:loader) { described_class.send(:new, jira_project, { id: 789 }) }
625
+
626
+ context "when the ticket was just created" do
627
+ it do
628
+ allow(loader).to receive_messages(ticket_previously_replicated?: false)
629
+ allow(loader).to receive_messages(create_ticket: nil)
630
+
631
+ loader.send(:ticket)
632
+
633
+ expect(loader.send(:ticket_was_just_updated?)).to be_truthy
634
+ end
635
+ end
636
+
637
+ context "when the ticket had modified fields" do
638
+ let(:jira_ticket) { double(JIRA::Resource::Issue, save!: nil, fetch: nil) }
639
+ let(:ticket) { instance_double(Ticket, jira_ticket: jira_ticket) }
640
+
641
+ it do
642
+ allow(loader)
643
+ .to receive_messages(ticket_fields_need_to_be_updated?: true,
644
+ reopen_ticket_if_status_in_done_category: nil,
645
+ ticket: ticket,
646
+ attributes_for_save: {})
647
+
648
+ allow(jira_project).to receive_messages(replicated_tickets: { "789" => "a_ticket" })
649
+
650
+ loader.send(:save_ticket)
651
+
652
+ expect(loader.send(:ticket_was_just_updated?)).to be_truthy
653
+ end
654
+ end
655
+
656
+ context "when the ticket was not new or had modified fields" do
657
+ it { expect(loader.send(:ticket_was_just_updated?)).to be_falsey }
658
+ end
659
+ end
527
660
  end
528
661
  # rubocop:enable Metrics/ClassLength
529
662
  end
@@ -7,17 +7,18 @@ module Ticket
7
7
  # rubocop:disable Metrics/ClassLength
8
8
  class RowTransformer
9
9
  RSpec.describe RowTransformer do
10
- let(:transformer) { described_class.send(:new, extracted_row) }
10
+ let(:transformer) { described_class.send(:new, extracted_row, mappings) }
11
+ let(:mappings) { instance_double(Mappings, to_h: {}) }
11
12
 
12
13
  describe ".run_on" do
13
14
  let(:extracted_row) { :a_row_to_transform }
14
15
 
15
16
  it "transforms the given file" do
16
- expect(described_class).to receive(:new).with(:a_row_to_transform).and_return(transformer)
17
+ expect(described_class).to receive(:new).with(:a_row_to_transform, mappings).and_return(transformer)
17
18
 
18
19
  expect(transformer).to receive(:run)
19
20
 
20
- described_class.run_on(:a_row_to_transform)
21
+ described_class.run_on(:a_row_to_transform, mappings)
21
22
  end
22
23
  end
23
24
 
@@ -46,50 +47,13 @@ module Ticket
46
47
  end
47
48
 
48
49
  describe "#mappings" do
49
- let(:mappings_yaml) do
50
- <<~'YAML'
51
- field_mapping:
52
- id: Defect
53
- summary: Defect (2)
54
- id_extraction_regex: "\\((?<id>\\d+)\\)$"
55
- status_mapping:
56
- defaults_to: keep_original_value
57
- Open: Open
58
- Closed: Closed
59
- resolution_mapping:
60
- Closed: Done
61
- Rejected: Won't Fix
62
- team_mapping:
63
- Source Team: Transformed Team
64
- Front End: Web Team
65
- priority_mapping:
66
- 1 - Critical: Critical
67
- 2 - High: High
68
- 3 - Medium: Medium
69
- 4 - Low: Low
70
- YAML
71
- end
72
-
73
50
  let(:extracted_row) { {} }
51
+ let(:mappings) { instance_double(Mappings, to_h: { "field_mapping" => {} }) }
52
+
53
+ it "uses the mapping object" do
54
+ RowTransformer.send(:new, extracted_row, mappings)
74
55
 
75
- let(:file_io) { instance_double(IO) }
76
-
77
- it "loads the mappings from the mappings.yml file" do
78
- expect(IO).to receive(:open).with("config/ticket-replicator.mappings.yml",
79
- "r:bom|utf-8").and_yield(mappings_yaml)
80
-
81
- expect(transformer.mappings)
82
- .to eq(
83
- "field_mapping" => { "id" => "Defect", "summary" => "Defect (2)" },
84
- "id_extraction_regex" => "\\((?<id>\\d+)\\)$",
85
- "status_mapping" => { "defaults_to" => "keep_original_value", "Open" => "Open",
86
- "Closed" => "Closed" },
87
- "resolution_mapping" => { "Closed" => "Done", "Rejected" => "Won't Fix" },
88
- "team_mapping" => { "Source Team" => "Transformed Team", "Front End" => "Web Team" },
89
- "priority_mapping" =>
90
- { "1 - Critical" => "Critical", "2 - High" => "High", "3 - Medium" => "Medium",
91
- "4 - Low" => "Low" }
92
- )
56
+ expect(transformer.mappings).to eq({ "field_mapping" => {} })
93
57
  end
94
58
  end
95
59
 
@@ -217,6 +217,28 @@ module Ticket
217
217
  it { expect(ticket.status).to eq("To Do") }
218
218
  end
219
219
 
220
+ describe "#status_in_done_category?" do
221
+ let(:jira_ticket) do
222
+ double(JIRA::Resource::Issue, status: actual_status)
223
+ end
224
+
225
+ let(:actual_status) do
226
+ double(JIRA::Resource::Status, attrs: { "name" => "A status", "statusCategory" => actual_status_category })
227
+ end
228
+
229
+ context "when status is in done category" do
230
+ let(:actual_status_category) { { "key" => "done", "name" => "Done" } }
231
+
232
+ it { expect(ticket.status_in_done_category?).to be_truthy }
233
+ end
234
+
235
+ context "when status is not in done category" do
236
+ let(:actual_status_category) { { "key" => "new", "name" => "To Do" } }
237
+
238
+ it { expect(ticket.status_in_done_category?).to be_falsy }
239
+ end
240
+ end
241
+
220
242
  describe "#resolution" do
221
243
  it { expect(ticket.resolution).to eq("") }
222
244
  end
@@ -280,7 +302,8 @@ module Ticket
280
302
 
281
303
  describe "#transition_to" do
282
304
  let(:transitioner) { instance_double(TicketStatusTransitioner) }
283
- it do
305
+
306
+ it "uses a status transitioner" do
284
307
  expect(TicketStatusTransitioner).to receive(:new).with(ticket).and_return(transitioner)
285
308
  expect(transitioner).to receive(:transition_to).with("Testing")
286
309
 
@@ -288,6 +311,17 @@ module Ticket
288
311
  end
289
312
  end
290
313
 
314
+ describe "#transition_to_not_done" do
315
+ let(:transitioner) { instance_double(TicketStatusTransitioner) }
316
+
317
+ it "uses a status transitioner" do
318
+ expect(TicketStatusTransitioner).to receive(:new).with(ticket).and_return(transitioner)
319
+ expect(transitioner).to receive(:transition_to_not_done)
320
+
321
+ ticket.transition_to_not_done
322
+ end
323
+ end
324
+
291
325
  describe "#sanitize" do
292
326
  let(:expectations) do
293
327
  [
@@ -311,4 +345,5 @@ module Ticket
311
345
  end
312
346
  end
313
347
  end
348
+
314
349
  # rubocop:enable Metrics/ClassLength
@@ -6,7 +6,7 @@ require "ticket/replicator/ticket_status_transitioner"
6
6
  module Ticket
7
7
  class Replicator
8
8
  RSpec.describe TicketStatusTransitioner do
9
- let(:transitioner) { described_class.send(:new, ticket) }
9
+ let(:transitioner) { described_class.new(ticket) }
10
10
 
11
11
  let(:ticket) do
12
12
  double(Ticket, key: "PROJKEY-16", status: "Todo", jira_auto_tool: jira_auto_tool, jira_client: jira_client)
@@ -15,7 +15,7 @@ module Ticket
15
15
  let(:jira_auto_tool) { instance_double(Jira::Auto::Tool, jira_client: jira_client) }
16
16
  let(:jira_client) { instance_double(JIRA::Client) }
17
17
 
18
- describe "#transistion_to" do
18
+ describe "#transition_to" do
19
19
  let(:expected_payload) { { transition: { id: 131 } }.to_json }
20
20
  let(:expected_headers) { { "Content-Type" => "application/json" } }
21
21
 
@@ -48,6 +48,53 @@ module Ticket
48
48
  end
49
49
  end
50
50
 
51
+ describe "#transition_to_not_done" do
52
+ let(:available_transitions) do
53
+ [
54
+ { "name" => "Withdraw",
55
+ "to" => { "name" => "Withdrawn",
56
+ "statusCategory" => { "key" => "done", "colorName" => "green", "name" => "Done" } } },
57
+ { "name" => "Set As Defect Correction in Process",
58
+ "to" => {
59
+ "name" => "Defect Correction in Process",
60
+ "statusCategory" => { "key" => "indeterminate", "colorName" => "yellow", "name" => "In Progress" }
61
+ } },
62
+ { "name" => "Confirm",
63
+ "to" => { "name" => "Confirmed",
64
+ "statusCategory" => { "key" => "new", "colorName" => "blue-gray", "name" => "To Do" } } }
65
+ ]
66
+ end
67
+
68
+ before do
69
+ expect(transitioner).to receive(:available_transitions).at_least(:once).and_return(available_transitions)
70
+ end
71
+
72
+ it "finds a non-done transition and transitions to it" do
73
+ expect(transitioner).to receive(:transition_to).with("Defect Correction in Process")
74
+
75
+ transitioner.transition_to_not_done
76
+ end
77
+
78
+ context "when no non-done transition is found" do
79
+ let(:available_transitions) do
80
+ [
81
+ { "name" => "Withdraw",
82
+ "to" => { "name" => "Withdrawn",
83
+ "statusCategory" => { "key" => "done", "colorName" => "green", "name" => "Done" } } },
84
+ { "name" => "Set To Done",
85
+ "to" => { "name" => "Done",
86
+ "statusCategory" => { "key" => "done", "colorName" => "green", "name" => "Done" } } }
87
+ ]
88
+ end
89
+
90
+ it "transitions to the first done transition" do
91
+ expect { transitioner.transition_to_not_done }
92
+ .to raise_error(TicketStatusTransitioner::StatusTransitionerError,
93
+ /No non-done transition found in .+/)
94
+ end
95
+ end
96
+ end
97
+
51
98
  describe "#transition_payload_for" do
52
99
  let(:available_transitions) do
53
100
  [
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ticket-replicator
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.2
4
+ version: 1.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Christophe Broult
@@ -183,6 +183,7 @@ files:
183
183
  - lib/ticket/replicator/file_transformer/for_csv.rb
184
184
  - lib/ticket/replicator/file_transformer/for_xlsx.rb
185
185
  - lib/ticket/replicator/jira_project.rb
186
+ - lib/ticket/replicator/mappings.rb
186
187
  - lib/ticket/replicator/replicated_summary.rb
187
188
  - lib/ticket/replicator/row_loader.rb
188
189
  - lib/ticket/replicator/row_transformer.rb
@@ -199,6 +200,7 @@ files:
199
200
  - spec/ticket/replicator/file_transformer/for_xlsx_spec.rb
200
201
  - spec/ticket/replicator/file_transformer_spec.rb
201
202
  - spec/ticket/replicator/jira_project_spec.rb
203
+ - spec/ticket/replicator/mappings_spec.rb
202
204
  - spec/ticket/replicator/replicated_summary_spec.rb
203
205
  - spec/ticket/replicator/row_loader_spec.rb
204
206
  - spec/ticket/replicator/row_transformer_spec.rb
@@ -230,7 +232,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
230
232
  - !ruby/object:Gem::Version
231
233
  version: '0'
232
234
  requirements: []
233
- rubygems_version: 3.6.7
235
+ rubygems_version: 3.6.8
234
236
  specification_version: 4
235
237
  summary: Automate replicating tickets from one system to a Jira project
236
238
  test_files: []