diff --git a/lib/gitlab/ci/tags/parser.rb b/lib/gitlab/ci/tags/parser.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ff1296187a66692b0eb7df3c06dd4d1f8cd96456
--- /dev/null
+++ b/lib/gitlab/ci/tags/parser.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module Ci
+    module Tags
+      class Parser
+        def initialize(tag_list)
+          @tag_list = tag_list
+        end
+
+        def parse
+          string = @tag_list
+
+          string = string.join(', ') if string.respond_to?(:join)
+          TagList.new.tap do |tag_list|
+            string = string.to_s.dup
+
+            string.gsub!(double_quote_pattern) do
+              # Append the matched tag to the tag list
+              tag_list << Regexp.last_match[2]
+              # Return the matched delimiter ($3) to replace the matched items
+              ''
+            end
+
+            string.gsub!(single_quote_pattern) do
+              # Append the matched tag ($2) to the tag list
+              tag_list << Regexp.last_match[2]
+              # Return an empty string to replace the matched items
+              ''
+            end
+
+            # split the string by the delimiter
+            # and add to the tag_list
+            tag_list.add(string.split(Regexp.new(delimiter)))
+          end
+        end
+
+        private
+
+        def delimiter
+          ','
+        end
+
+        # (             # Tag start delimiter ($1)
+        # \A       |  # Either string start or
+        # #{delimiter}        # a delimiter
+        # )
+        # \s*"          # quote (") optionally preceded by whitespace
+        # (.*?)         # Tag ($2)
+        # "\s*          # quote (") optionally followed by whitespace
+        # (?=           # Tag end delimiter (not consumed; is zero-length lookahead)
+        # #{delimiter}\s*  |  # Either a delimiter optionally followed by whitespace or
+        # \z          # string end
+        # )
+        def double_quote_pattern
+          /(\A|#{delimiter})\s*"(.*?)"\s*(?=#{delimiter}\s*|\z)/
+        end
+
+        # (             # Tag start delimiter ($1)
+        # \A       |  # Either string start or
+        # #{delimiter}        # a delimiter
+        # )
+        # \s*'          # quote (') optionally preceded by whitespace
+        # (.*?)         # Tag ($2)
+        # '\s*          # quote (') optionally followed by whitespace
+        # (?=           # Tag end delimiter (not consumed; is zero-length lookahead)
+        # #{delimiter}\s*  | d # Either a delimiter optionally followed by whitespace or
+        # \z          # string end
+        # )
+        def single_quote_pattern
+          /(\A|#{delimiter})\s*'(.*?)'\s*(?=#{delimiter}\s*|\z)/
+        end
+      end
+    end
+  end
+end
diff --git a/lib/gitlab/ci/tags/tag_list.rb b/lib/gitlab/ci/tags/tag_list.rb
new file mode 100644
index 0000000000000000000000000000000000000000..953138dc4ea8d42b0d4cbfc8700629feccdd2348
--- /dev/null
+++ b/lib/gitlab/ci/tags/tag_list.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module Ci
+    module Tags
+      class TagList < Array
+        attr_accessor :owner, :parser
+
+        def initialize(*args)
+          @parser = Parser
+          add(*args)
+        end
+
+        def add(*names)
+          extract_and_apply_options!(names)
+          concat(names)
+          clean!
+          self
+        end
+
+        def <<(obj)
+          add(obj)
+        end
+
+        def +(other)
+          TagList.new.add(self).add(other)
+        end
+
+        def concat(other_tag_list)
+          super(other_tag_list).clean!
+          self
+        end
+
+        def remove(*names)
+          extract_and_apply_options!(names)
+          delete_if { |name| names.include?(name) }
+          self
+        end
+
+        def to_s
+          tags = frozen? ? dup : self
+          tags.clean!
+
+          tags.map do |name|
+            name.index(',') ? "\"#{name}\"" : name
+          end.join(', ')
+        end
+
+        protected
+
+        def clean!
+          reject!(&:blank?)
+          map!(&:to_s)
+          map!(&:strip)
+          uniq!
+
+          self
+        end
+
+        private
+
+        def extract_and_apply_options!(args)
+          options = args.last.is_a?(Hash) ? args.pop : {}
+          options.assert_valid_keys :parse, :parser
+
+          parser = options[:parser] || @parser
+
+          args.map! { |a| parser.new(a).parse } if options[:parse] || options[:parser]
+
+          args.flatten!
+        end
+      end
+    end
+  end
+end
diff --git a/spec/lib/gitlab/ci/tags/parser_spec.rb b/spec/lib/gitlab/ci/tags/parser_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..2119e6532c3b1279dd04539ac8a6b0c9262c940e
--- /dev/null
+++ b/spec/lib/gitlab/ci/tags/parser_spec.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::Tags::Parser, feature_category: :continuous_integration do
+  let(:parser) { described_class.new(input) }
+
+  subject { parser.parse }
+
+  context 'with an empty array' do
+    let(:input) { [] }
+
+    it { is_expected.to be_empty }
+  end
+
+  context 'with regular data' do
+    let(:input) { 'cool, data, I,have' }
+
+    it { is_expected.to match_array(%w[cool data I have]) }
+  end
+
+  context 'with multiple quoted tags' do
+    let(:input) { '"Ruby Monsters","eat Katzenzungen"' }
+
+    it { is_expected.to match_array(['Ruby Monsters', 'eat Katzenzungen']) }
+  end
+
+  context 'with single quotes' do
+    let(:input) { "'I have', cool, data" }
+
+    it { is_expected.to match_array(['I have', 'cool', 'data']) }
+  end
+
+  context 'with double quotes' do
+    let(:input) { '"I have",cool, data' }
+
+    it { is_expected.to match_array(['I have', 'cool', 'data']) }
+  end
+end
diff --git a/spec/lib/gitlab/ci/tags/tag_list_spec.rb b/spec/lib/gitlab/ci/tags/tag_list_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..920bf1f40d4f0978ddbdcabf8577cd40693506aa
--- /dev/null
+++ b/spec/lib/gitlab/ci/tags/tag_list_spec.rb
@@ -0,0 +1,116 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::Tags::TagList, feature_category: :continuous_integration do
+  let(:tag_list) { described_class.new('chunky', 'bacon') }
+  let(:another_tag_list) { described_class.new('chunky', 'crazy', 'cut') }
+
+  it { is_expected.to be_a_kind_of(Array) }
+
+  describe '#add' do
+    it 'adds a new word' do
+      tag_list.add('cool')
+
+      expect(tag_list).to include('cool')
+    end
+
+    it 'adds delimited lists of words' do
+      tag_list.add('cool, fox', parse: true)
+
+      expect(tag_list).to include('cool', 'fox')
+    end
+
+    it 'adds delimited list of words with quoted delimiters' do
+      tag_list.add("'cool, wicked', \"really cool, really wicked\"", parse: true)
+
+      expect(tag_list).to include('cool, wicked', 'really cool, really wicked')
+    end
+
+    it 'handles other uses of quotation marks correctly' do
+      tag_list.add("john's cool car, mary's wicked toy", parse: true)
+
+      expect(tag_list).to include("john's cool car", "mary's wicked toy")
+    end
+
+    it 'is able to add an array of words' do
+      tag_list.add(%w[cool fox], parse: true)
+
+      expect(tag_list).to include('cool', 'fox')
+    end
+
+    it 'escapes tags with commas in them' do
+      tag_list.add('cool', 'rad,fox')
+
+      expect(tag_list.to_s).to eq("chunky, bacon, cool, \"rad,fox\"")
+    end
+  end
+
+  describe '#remove' do
+    it 'removes words' do
+      tag_list.remove('chunky')
+
+      expect(tag_list).not_to include('chunky')
+    end
+
+    it 'removes delimited lists of words' do
+      tag_list.remove('chunky, bacon', parse: true)
+
+      expect(tag_list).to be_empty
+    end
+
+    it 'removes an array of words' do
+      tag_list.remove(%w[chunky bacon], parse: true)
+
+      expect(tag_list).to be_empty
+    end
+  end
+
+  describe '#+' do
+    it 'does not have duplicate tags' do
+      new_tag_list = tag_list + another_tag_list
+
+      expect(new_tag_list).to eq(%w[chunky bacon crazy cut])
+    end
+
+    it 'returns an instance of the same class' do
+      new_tag_list = tag_list + another_tag_list
+
+      expect(new_tag_list).to be_an_instance_of(described_class)
+    end
+  end
+
+  describe '#concat' do
+    it 'does not have duplicate tags' do
+      expect(tag_list.concat(another_tag_list)).to eq(%w[chunky bacon crazy cut])
+    end
+
+    it 'returns an instance of the same class' do
+      new_tag_list = tag_list.concat(another_tag_list)
+
+      expect(new_tag_list).to be_an_instance_of(described_class)
+    end
+
+    context 'without duplicates' do
+      let(:arr) { %w[crazy cut] }
+      let(:another_tag_list) { described_class.new(*arr) }
+
+      it { expect(tag_list.concat(another_tag_list)).to eq(%w[chunky bacon crazy cut]) }
+      it { expect(tag_list.concat(arr)).to eq(%w[chunky bacon crazy cut]) }
+    end
+  end
+
+  describe '#to_s' do
+    it 'gives a delimited list of words when converted to string' do
+      expect(tag_list.to_s).to eq('chunky, bacon')
+    end
+  end
+
+  describe 'cleaning' do
+    it 'removes duplicates and empty spaces' do
+      tag_list = described_class.new('Ruby On Rails', ' Ruby on Rails ', 'Ruby on Rails', ' ', '')
+
+      expect(tag_list.to_s).to eq('Ruby On Rails, Ruby on Rails')
+    end
+  end
+end