ticket-replicator 0.1.1 → 1.1.0

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.
@@ -8,6 +8,10 @@ Feature: Transform and load extracted ticket queue
8
8
  | name |
9
9
  | TICKET_REPLICATOR_JIRA_PROJECT_KEY |
10
10
  | TICKET_REPLICATOR_JIRA_TICKET_TYPE_NAME |
11
+ And the following environment variables have been set:
12
+ | name | value |
13
+ | TICKET_REPLICATOR_SOURCE_TICKET_URL | http://url/to/source/ticket/<%= source_ticket_id %> |
14
+
11
15
  And a file named "config/ticket-replicator.mappings.yml" with:
12
16
  """
13
17
  field_mapping:
@@ -28,38 +32,11 @@ Feature: Transform and load extracted ticket queue
28
32
  defaults_to: keep_original_value
29
33
 
30
34
  resolution_mapping:
31
- defaults_to: keep_original_value
32
- "New":
33
- "Open":
34
- "In Process":
35
- "In Review":
36
- "On Hold":
37
- Deferred:
38
- Defect Correction in Process:
39
- "Fixed": "Fixed"
35
+ defaults_to: blank_value
36
+ "Fixed": "Done"
40
37
  "Closed": "Done"
41
- "Rejected": "Won't Do"
42
- "Resolved": "Fixed"
43
- "Confirmed":
44
- "Forwarded":
45
- "Information Required":
46
- Wait for Defect Correction:
47
- Solution Proposal:
48
- Tester Action:
49
- Wait on External:
50
-
51
- team_mapping:
52
- defaults_to: keep_original_value
53
- "Frontend": "Web Team"
54
- "Backend": "Server Team"
55
- "Integration": "Integration Team"
56
- "Mobile": "Mobile Team"
57
- "Security": "Security Team"
58
- "DevOps": "DevOps Team"
59
- "QA": "Quality Assurance"
60
- "Architecture": "Architecture Team"
61
- "UX": "Design Team"
62
- "Performance": "Performance Team"
38
+ "Withdrawn": "Won't Do"
39
+ "Resolved": "Done"
63
40
  """
64
41
  And the project has no tickets
65
42
 
@@ -74,7 +51,7 @@ Feature: Transform and load extracted ticket queue
74
51
  | 9400013816 | Summary | 2: High | Forwarded | A Team |
75
52
  | 9400011382 | Summary | 3: Medium | In Process | A Team |
76
53
  | 9400011393 | Summary | 3: Medium | Information Required | A Team |
77
- | 9400011381 | Summary | 3: Medium | New | A Team |
54
+ | 9400011381 | Summary | 3: Medium | Withdrawn | A Team |
78
55
  | 3000016617 | Summary | 3: Medium | No Error | A Team |
79
56
  | 9400011372 | Summary | 1: Critical | Open | A Team |
80
57
  | 9400011466 | Summary | 3: Medium | Withdrawn | A Team |
@@ -84,20 +61,20 @@ Feature: Transform and load extracted ticket queue
84
61
  And the current date time is "2025-05-10 07:52:00 UTC"
85
62
  When I successfully run `ticket-replicator --ticket-queue-transform-and-load`
86
63
  Then the Jira project should only have the following tickets:
87
- | status | resolution | priority | team | summary | source ticket url |
88
- | Closed | Done | Medium | A Team | SMAN-3000017049 \| Summary | https://example.com/defect/3000017049 |
89
- | Confirmed | | Low | A Team | SMAN-9400011377 \| Summary | https://example.com/defect/9400011377 |
90
- | Defect Correction in Process | | Medium | A Team | SMAN-3000016618 \| Summary | https://example.com/defect/3000016618 |
91
- | Deferred | | Medium | A Team | SMAN-9400013805 \| Summary | https://example.com/defect/9400013805 |
92
- | Forwarded | | High | A Team | SMAN-9400013816 \| Summary | https://example.com/defect/9400013816 |
93
- | In Process | | Medium | A Team | SMAN-9400011382 \| Summary | https://example.com/defect/9400011382 |
94
- | Information Required | | Medium | A Team | SMAN-9400011393 \| Summary | https://example.com/defect/9400011393 |
95
- | New | | Medium | A Team | SMAN-9400011381 \| Summary | https://example.com/defect/9400011381 |
96
- | No Error | | Medium | A Team | SMAN-3000016617 \| Summary | https://example.com/defect/3000016617 |
97
- | Open | | Highest | A Team | SMAN-9400011372 \| Summary | https://example.com/defect/9400011372 |
98
- | Withdrawn | Won't Do | Medium | A Team | SMAN-9400011466 \| Summary | https://example.com/defect/9400011466 |
99
- | Closed | Done | Medium | Another Team | SMAN-3000017667 \| Summary | https://example.com/defect/3000017667 |
100
- | No Error | | Medium | A Team | SMAN-3000018423 \| Summary | https://example.com/defect/3000018423 |
64
+ | status | resolution | priority | summary | source_ticket_url |
65
+ | Closed | Done | Medium | SMAN-3000017049 \| Summary | http://url/to/source/ticket/3000017049 |
66
+ | Confirmed | | Low | SMAN-9400011377 \| Summary | http://url/to/source/ticket/9400011377 |
67
+ | Defect Correction in Process | | Medium | SMAN-3000016618 \| Summary | http://url/to/source/ticket/3000016618 |
68
+ | Deferred | | Medium | SMAN-9400013805 \| Summary | http://url/to/source/ticket/9400013805 |
69
+ | Forwarded | | High | SMAN-9400013816 \| Summary | http://url/to/source/ticket/9400013816 |
70
+ | In Process | | Medium | SMAN-9400011382 \| Summary | http://url/to/source/ticket/9400011382 |
71
+ | Information Required | | Medium | SMAN-9400011393 \| Summary | http://url/to/source/ticket/9400011393 |
72
+ | Withdrawn | Won't Do | Medium | SMAN-9400011381 \| Summary | http://url/to/source/ticket/9400011381 |
73
+ | No Error | | Medium | SMAN-3000016617 \| Summary | http://url/to/source/ticket/3000016617 |
74
+ | Open | | Highest | SMAN-9400011372 \| Summary | http://url/to/source/ticket/9400011372 |
75
+ | Withdrawn | Won't Do | Medium | SMAN-9400011466 \| Summary | http://url/to/source/ticket/9400011466 |
76
+ | Closed | Done | Medium | SMAN-3000017667 \| Summary | http://url/to/source/ticket/3000017667 |
77
+ | No Error | | Medium | SMAN-3000018423 \| Summary | http://url/to/source/ticket/3000018423 |
101
78
  # TODO: And the source ticket URL is found in the ticket descriptions of those tickets
102
79
  And a file named "queue/30.archived/2025-05-10.07h52m00.sap_solution_manager_defects.xlsx" should exist
103
80
  And the file named "queue/10.extracted/sap_solution_manager_defects.xlsx" should not exist anymore
@@ -8,7 +8,7 @@ module Ticket
8
8
  class LoadError < StandardError; end
9
9
 
10
10
  def self.run_on(jira_project, file_path)
11
- log.info { "Loading #{file_path} into #{jira_project.project_key}..." }
11
+ log.debug { "Loading #{file_path} into #{jira_project.project_key}..." }
12
12
 
13
13
  new(jira_project, file_path).run
14
14
  end
@@ -30,6 +30,10 @@ module Ticket
30
30
  @ticket_type_name ||= ENV.fetch("TICKET_REPLICATOR_JIRA_TICKET_TYPE_NAME")
31
31
  end
32
32
 
33
+ def resolutions
34
+ @resolutions ||= jira_client.Resolution.all.to_h { |resolution| [resolution.name, resolution] }
35
+ end
36
+
33
37
  def replicated_tickets
34
38
  @replicated_tickets ||= all_replicated_ticket_pages.to_h { |ticket| [ticket.source_id, ticket] }
35
39
  end
@@ -20,6 +20,7 @@ module Ticket
20
20
 
21
21
  def run
22
22
  save_ticket
23
+ update_source_ticket_remote_link
23
24
  transition_ticket_to_the_expected_status
24
25
  end
25
26
 
@@ -27,6 +28,7 @@ module Ticket
27
28
  jira_project.replicated_tickets.key?(id)
28
29
  end
29
30
 
31
+ # rubocop:disable Metrics/MethodLength
30
32
  def save_ticket
31
33
  return unless ticket_fields_need_to_be_updated?
32
34
 
@@ -34,9 +36,8 @@ module Ticket
34
36
  fields: {
35
37
  project: { key: jira_project.project_key },
36
38
  issuetype: { name: jira_project.ticket_type_name },
37
- # resolution: { name: resolution },
39
+ resolution: jira_resolution_value,
38
40
  priority: { name: priority },
39
- # implementation_team: { name: team },
40
41
  summary: summary
41
42
  }
42
43
  })
@@ -44,6 +45,20 @@ module Ticket
44
45
  ticket.jira_ticket.fetch
45
46
  end
46
47
 
48
+ def jira_resolution_value
49
+ return if resolution.blank?
50
+ return { name: resolution } if jira_project.resolutions.key?(resolution)
51
+
52
+ log.warn do
53
+ "Setting resolution to unset since " \
54
+ "resolution #{resolution.inspect} not found in #{jira_project.resolutions.inspect}!"
55
+ end
56
+
57
+ nil
58
+ end
59
+
60
+ # rubocop:enable Metrics/MethodLength
61
+
47
62
  def ticket_fields_need_to_be_updated?
48
63
  !ticket_previously_replicated? || ticket_fields_changed?
49
64
  end
@@ -56,6 +71,53 @@ module Ticket
56
71
  %i[summary priority]
57
72
  end
58
73
 
74
+ def update_source_ticket_remote_link
75
+ return unless source_ticket_link_needs_update?
76
+
77
+ ticket.source_ticket_link&.delete
78
+
79
+ ticket.jira_ticket.remotelink.build.save!(ticket_link_attributes)
80
+ end
81
+
82
+ # rubocop:disable Metrics/MethodLength
83
+ def source_ticket_link_needs_update?
84
+ existing_ticket_link_attributes = ticket.source_ticket_link&.attrs
85
+
86
+ object = existing_ticket_link_attributes&.[]("object")
87
+ existing_url = object&.[]("url")
88
+ existing_title = object&.[]("title")
89
+
90
+ filtered_existing_ticket_link_attributes = ticket_link_attributes(existing_url, existing_title)
91
+
92
+ log.debug do
93
+ <<~EOLOG
94
+
95
+ existing_ticket_link_attributes = #{existing_ticket_link_attributes.inspect}
96
+ filtered_existing_ticket_link_attributes = #{filtered_existing_ticket_link_attributes}
97
+ ticket_link_attributes: #{ticket_link_attributes.inspect}
98
+ EOLOG
99
+ end
100
+
101
+ filtered_existing_ticket_link_attributes != ticket_link_attributes
102
+ end
103
+
104
+ # rubocop:enable Metrics/MethodLength
105
+
106
+ def source_ticket_link_title
107
+ "Source Ticket #{id}"
108
+ end
109
+
110
+ def ticket_link_attributes(url = source_ticket_url, title = source_ticket_link_title)
111
+ self.class.build_ticket_link_attributes(url, title)
112
+ end
113
+
114
+ def self.build_ticket_link_attributes(
115
+ url, title, application_name = Ticket::SOURCE_TICKET_REMOTE_LINK_APPLICATION
116
+ )
117
+ { "object" => { "url" => url, "title" => title } }
118
+ .merge(application_name ? { "application" => { "name" => application_name } } : {})
119
+ end
120
+
59
121
  def transition_ticket_to_the_expected_status
60
122
  ticket.transition_to(status)
61
123
  end
@@ -101,7 +163,8 @@ module Ticket
101
163
 
102
164
  class << self
103
165
  def fields_to_load
104
- @fields_to_load ||= RowTransformer.fields_to_transform.collect { |field| field.to_s.downcase.to_sym }
166
+ @fields_to_load ||=
167
+ RowTransformer.fields_to_transform.collect { |field| field.to_s.parameterize.underscore.to_sym }
105
168
  end
106
169
  end
107
170
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ticket/replicator/replicated_summary"
4
+ require "erb"
4
5
 
5
6
  module Ticket
6
7
  class Replicator
@@ -18,13 +19,25 @@ module Ticket
18
19
  end
19
20
 
20
21
  def run
21
- fields_to_transform.collect { |field| send("transformed_#{field.to_s.downcase}") }
22
+ fields_to_transform.collect { |field| send("transformed_#{field.to_s.parameterize.underscore}") }
22
23
  end
23
24
 
24
25
  def transformed_id
25
26
  remapped_field_extracted_value_for :id
26
27
  end
27
28
 
29
+ def transformed_source_ticket_url
30
+ source_ticket_url_builder(transformed_id)
31
+ end
32
+
33
+ def source_ticket_url_builder(source_ticket_id)
34
+ ERB.new(ticket_replicator_source_ticket_url).result(binding)
35
+ end
36
+
37
+ def ticket_replicator_source_ticket_url
38
+ ENV.fetch("TICKET_REPLICATOR_SOURCE_TICKET_URL") { |name| raise "#{name}: not set in environment!" }
39
+ end
40
+
28
41
  def transformed_status
29
42
  mapped_value_for :status
30
43
  end
@@ -50,17 +63,27 @@ module Ticket
50
63
  self.class.fields_to_transform
51
64
  end
52
65
 
66
+ def fields_to_remap
67
+ self.class.fields_to_remap
68
+ end
69
+
53
70
  def self.fields_to_transform
54
- %i[ID Status Resolution Priority Team Summary]
71
+ fields_to_remap + ["Source Ticket URL"]
72
+ end
73
+
74
+ def self.fields_to_remap
75
+ %i[ID Status Resolution Priority Summary]
55
76
  end
56
77
 
57
78
  def mapped_value_for(field, extracted_value = remapped_field_extracted_value_for(field))
58
79
  mapping_for(field).fetch(extracted_value) do |key|
59
- unless mapping_for(field)["defaults_to"] == "keep_original_value"
80
+ case mapping_for(field)["defaults_to"]
81
+ when "keep_original_value" then extracted_value
82
+ when "unset_value" then nil
83
+ when "blank_value" then ""
84
+ else
60
85
  raise "No mapping found for #{field.inspect} = #{key.inspect} in #{mapping_for(field).inspect}"
61
86
  end
62
-
63
- extracted_value
64
87
  end
65
88
  end
66
89
 
@@ -75,7 +98,7 @@ module Ticket
75
98
  end
76
99
 
77
100
  def remapped_field_extracted_row
78
- @remapped_field_extracted_row ||= fields_to_transform.to_h do |field|
101
+ @remapped_field_extracted_row ||= fields_to_remap.to_h do |field|
79
102
  mapped_field_key = remapped_field_key(field)
80
103
  [mapped_field_key, extracted_row_value_for(mapped_field_key)]
81
104
  end
@@ -36,8 +36,25 @@ module Ticket
36
36
  ReplicatedSummary.parse(summary).source_id
37
37
  end
38
38
 
39
+ SOURCE_TICKET_REMOTE_LINK_APPLICATION = "Ticket Source"
40
+
41
+ def source_ticket_link
42
+ @source_ticket_link ||=
43
+ jira_ticket.remotelink.all.find do |link|
44
+ source_ticket_link_attributes(link)&.[]("application")&.[]("name") == SOURCE_TICKET_REMOTE_LINK_APPLICATION
45
+ end
46
+ end
47
+
48
+ def source_ticket_url
49
+ source_ticket_link_attributes(source_ticket_link)&.[]("object")&.[]("url")
50
+ end
51
+
39
52
  private
40
53
 
54
+ def source_ticket_link_attributes(link)
55
+ link&.attrs
56
+ end
57
+
41
58
  def cmp_values(object)
42
59
  object&.source_id
43
60
  end
@@ -57,6 +74,8 @@ module Ticket
57
74
  value.name
58
75
  elsif value.respond_to?(:value)
59
76
  value.value
77
+ elsif value.is_a?(Hash)
78
+ value.values_at("name", "value").compact.first
60
79
  else
61
80
  value.nil? ? "" : value.to_s
62
81
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ticket
4
4
  class Replicator
5
- VERSION = "0.1.1"
5
+ VERSION = "1.1.0"
6
6
  end
7
7
  end
@@ -8,7 +8,7 @@ module Ticket
8
8
  RSpec.describe FileLoader do
9
9
  let(:loader) { described_class.send(:new, jira_project, "file.csv") }
10
10
  let(:jira_project) { instance_double(JiraProject) }
11
- let(:logger) { instance_double(Logger, info: nil) }
11
+ let(:logger) { instance_double(Logger, debug: nil) }
12
12
 
13
13
  describe ".run_on" do
14
14
  it "loads the given file" do
@@ -2,7 +2,6 @@
2
2
 
3
3
  module Ticket
4
4
  class Replicator
5
- # rubocop:disable Metrics/ClassLength
6
5
  class FileReplicator
7
6
  RSpec.describe FileReplicator do
8
7
  let(:file_replicator) { described_class.send(:new, replicator, "extracted.csv") }
@@ -148,6 +147,5 @@ module Ticket
148
147
  end
149
148
  end
150
149
  end
151
- # rubocop:enable Metrics/ClassLength
152
150
  end
153
151
  end
@@ -43,7 +43,8 @@ module Ticket
43
43
 
44
44
  describe "#transformed_headers" do
45
45
  it "returns the headers" do
46
- expect(transformer.transformed_headers).to eq(%w[ID Status Resolution Priority Team Summary])
46
+ expect(transformer.transformed_headers)
47
+ .to eq(%w[ID Status Resolution Priority Summary] + ["Source Ticket URL"])
47
48
  end
48
49
  end
49
50
 
@@ -28,6 +28,24 @@ module Ticket
28
28
  end
29
29
  end
30
30
 
31
+ describe "#resolutions" do
32
+ def build_resolution(name, id)
33
+ double(JIRA::Resource::Resolution, name: name, id: id)
34
+ end
35
+
36
+ let(:a_resolution) { build_resolution("a resolution", 1) }
37
+ let(:another_resolution) { build_resolution("another resolution", 2) }
38
+
39
+ let(:resolution_query) { double("jira_client.Resolution", all: [a_resolution, another_resolution]) }
40
+
41
+ before { allow(jira_client).to receive_messages(Resolution: resolution_query) }
42
+
43
+ it do
44
+ expect(project.resolutions)
45
+ .to eq({ "a resolution" => a_resolution, "another resolution" => another_resolution })
46
+ end
47
+ end
48
+
31
49
  describe "#ticket_type_name" do
32
50
  it do
33
51
  expect(ENV).to receive(:fetch).with("TICKET_REPLICATOR_JIRA_TICKET_TYPE_NAME").and_return("Ticket Type Name")
@@ -83,6 +101,7 @@ module Ticket
83
101
  build_expected_ticket("5", "SMAN-5 | a summary")
84
102
  ]
85
103
  end
104
+
86
105
  let(:actual_tickets) do
87
106
  [
88
107
  build_non_replicated_ticket("a non replicated ticket"),