diff --git a/app/models/concerns/integrations/has_data_fields.rb b/app/models/concerns/integrations/has_data_fields.rb index e9aaaac8226300101178f17433acc396d9a0cd01..1709b56080eb5d01ebfbdaec77c3058eb51f59dd 100644 --- a/app/models/concerns/integrations/has_data_fields.rb +++ b/app/models/concerns/integrations/has_data_fields.rb @@ -46,6 +46,7 @@ def #{arg}_was has_one :issue_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :service_id, class_name: 'Integrations::IssueTrackerData' has_one :jira_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :service_id, class_name: 'Integrations::JiraTrackerData' has_one :open_project_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :service_id, class_name: 'Integrations::OpenProjectTrackerData' + has_one :zentao_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :integration_id, class_name: 'Integrations::ZentaoTrackerData' def data_fields raise NotImplementedError diff --git a/app/models/integrations/zentao.rb b/app/models/integrations/zentao.rb new file mode 100644 index 0000000000000000000000000000000000000000..68c02f54c61246ae52edc3a152e2f8d4870093ae --- /dev/null +++ b/app/models/integrations/zentao.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Integrations + class Zentao < Integration + data_field :url, :api_url, :api_token, :zentao_product_xid + + validates :url, public_url: true, presence: true, if: :activated? + validates :api_url, public_url: true, allow_blank: true + validates :api_token, presence: true, if: :activated? + validates :zentao_product_xid, presence: true, if: :activated? + + def data_fields + zentao_tracker_data || self.build_zentao_tracker_data + end + + def title + self.class.name.demodulize + end + + def description + s_("ZentaoIntegration|Use Zentao as this project's issue tracker.") + end + + def self.to_param + name.demodulize.downcase + end + + def test(*_args) + client.ping + end + + def self.supported_events + %w() + end + + def self.supported_event_actions + %w() + end + + def fields + [ + { + type: 'text', + name: 'url', + title: s_('ZentaoIntegration|Zentao Web URL'), + placeholder: 'https://www.zentao.net', + help: s_('ZentaoIntegration|Base URL of the Zentao instance.'), + required: true + }, + { + type: 'text', + name: 'api_url', + title: s_('ZentaoIntegration|Zentao API URL (optional)'), + help: s_('ZentaoIntegration|If different from Web URL.') + }, + { + type: 'password', + name: 'api_token', + title: s_('ZentaoIntegration|Zentao API token'), + non_empty_password_title: s_('ZentaoIntegration|Enter API token'), + required: true + }, + { + type: 'text', + name: 'zentao_product_xid', + title: s_('ZentaoIntegration|Zentao Product ID'), + required: true + } + ] + end + + private + + def client + @client ||= ::Gitlab::Zentao::Client.new(self) + end + end +end diff --git a/app/models/integrations/zentao_tracker_data.rb b/app/models/integrations/zentao_tracker_data.rb new file mode 100644 index 0000000000000000000000000000000000000000..468e4e5d7d7ff177b70258dfecde3787a7e751f7 --- /dev/null +++ b/app/models/integrations/zentao_tracker_data.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Integrations + class ZentaoTrackerData < ApplicationRecord + belongs_to :integration, inverse_of: :zentao_tracker_data, foreign_key: :integration_id + delegate :activated?, to: :integration + validates :integration, presence: true + + scope :encryption_options, -> do + { + key: Settings.attr_encrypted_db_key_base_32, + encode: true, + mode: :per_attribute_iv, + algorithm: 'aes-256-gcm' + } + end + + attr_encrypted :url, encryption_options + attr_encrypted :api_url, encryption_options + attr_encrypted :zentao_product_xid, encryption_options + attr_encrypted :api_token, encryption_options + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 6615f9dabb21857008b284179ea0d6d44280801a..6d4d25695dfbeec8b69e7b4d05aa9d4fe0d7207d 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -209,6 +209,7 @@ def self.integration_association_name(name) has_one :unify_circuit_integration, class_name: 'Integrations::UnifyCircuit' has_one :webex_teams_integration, class_name: 'Integrations::WebexTeams' has_one :youtrack_integration, class_name: 'Integrations::Youtrack' + has_one :zentao_integration, class_name: 'Integrations::Zentao' has_one :root_of_fork_network, foreign_key: 'root_project_id', @@ -1455,7 +1456,7 @@ def find_or_initialize_integrations end def disabled_integrations - [] + [:zentao] end def find_or_initialize_integration(name) diff --git a/ee/app/models/ee/project.rb b/ee/app/models/ee/project.rb index 54e9224c8eff4bfa054ebf5f640b9cb7eb30f91b..bbc4fa565c75aaa3460e167191d03898d99301af 100644 --- a/ee/app/models/ee/project.rb +++ b/ee/app/models/ee/project.rb @@ -387,6 +387,10 @@ def jira_issues_integration_available? feature_available?(:jira_issues_integration) end + def zentao_issues_integration_available? + feature_available?(:zentao_issues_integration) + end + def multiple_approval_rules_available? feature_available?(:multiple_approval_rules) end diff --git a/ee/app/models/license.rb b/ee/app/models/license.rb index de9bd88972b111165bbd529a993524f2ea2dc8b1..4744ee0fbbbd5f63ad697a2c3cbc42b7edc6fe96 100644 --- a/ee/app/models/license.rb +++ b/ee/app/models/license.rb @@ -137,6 +137,7 @@ class License < ApplicationRecord oncall_schedules escalation_policies export_user_permissions + zentao_issues_integration ] EEP_FEATURES.freeze diff --git a/lib/gitlab/integrations/sti_type.rb b/lib/gitlab/integrations/sti_type.rb index 0fa9f435b5c68181eb32d126e1a53fa887622241..91797a7b99bcafd911aa68a3eed1361f063e07b7 100644 --- a/lib/gitlab/integrations/sti_type.rb +++ b/lib/gitlab/integrations/sti_type.rb @@ -7,7 +7,7 @@ class StiType < ActiveRecord::Type::String Asana Assembla Bamboo Bugzilla Buildkite Campfire Confluence CustomIssueTracker Datadog Discord DroneCi EmailsOnPush Ewm ExternalWiki Flowdock HangoutsChat Irker Jenkins Jira Mattermost MattermostSlashCommands MicrosoftTeams MockCi MockMonitoring Packagist PipelinesEmail Pivotaltracker - Prometheus Pushover Redmine Slack SlackSlashCommands Teamcity UnifyCircuit WebexTeams Youtrack + Prometheus Pushover Redmine Slack SlackSlashCommands Teamcity UnifyCircuit WebexTeams Youtrack Zentao )).freeze def self.namespaced_integrations diff --git a/lib/gitlab/zentao/client.rb b/lib/gitlab/zentao/client.rb new file mode 100644 index 0000000000000000000000000000000000000000..bdfa4b3a308e853188261641dab898abf66013af --- /dev/null +++ b/lib/gitlab/zentao/client.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Gitlab + module Zentao + class Client + Error = Class.new(StandardError) + ConfigError = Class.new(Error) + + attr_reader :integration + + def initialize(integration) + raise ConfigError, 'Please check your integration configuration.' unless integration + + @integration = integration + end + + def ping + response = fetch_product(zentao_product_xid) + + active = response.fetch('deleted') == '0' rescue false + + if active + { success: true } + else + { success: false, message: 'Not Found' } + end + end + + def fetch_product(product_id) + get("products/#{product_id}") + end + + def fetch_issues(params = {}) + get("products/#{zentao_product_xid}/issues", + params.reverse_merge(page: 1, limit: 20)) + end + + def fetch_issue(issue_id) + get("issues/#{issue_id}") + end + + private + + def get(path, params = {}) + options = { headers: headers, query: params } + response = Gitlab::HTTP.get(url(path), options) + + return {} unless response.success? + + Gitlab::Json.parse(response.body) + rescue JSON::ParserError + {} + end + + def url(path) + host = integration.api_url.presence || integration.url + + URI.join(host, '/api.php/v1/', path) + end + + def headers + { + 'Content-Type': 'application/json', + 'Token': integration.api_token + } + end + + def zentao_product_xid + integration.zentao_product_xid + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 6c1ac4de1a5107dc6b1e566b4cf5f7b6f1747c26..db84b9e2d69ad07f01950a3a3b4b00457df7ce64 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -38898,6 +38898,30 @@ msgstr "" msgid "Your username is %{username}." msgstr "" +msgid "ZentaoIntegration|Base URL of the Zentao instance." +msgstr "" + +msgid "ZentaoIntegration|Enter API token" +msgstr "" + +msgid "ZentaoIntegration|If different from Web URL." +msgstr "" + +msgid "ZentaoIntegration|Use Zentao as this project's issue tracker." +msgstr "" + +msgid "ZentaoIntegration|Zentao API URL (optional)" +msgstr "" + +msgid "ZentaoIntegration|Zentao API token" +msgstr "" + +msgid "ZentaoIntegration|Zentao Product ID" +msgstr "" + +msgid "ZentaoIntegration|Zentao Web URL" +msgstr "" + msgid "Zoom meeting added" msgstr "" diff --git a/spec/factories/integration_data.rb b/spec/factories/integration_data.rb index a7406794437c389270049a7c927295075ac9adbe..4d0892556f8e12a621fd2adb6186807c45705a38 100644 --- a/spec/factories/integration_data.rb +++ b/spec/factories/integration_data.rb @@ -7,13 +7,21 @@ integration factory: :jira_integration end + factory :zentao_tracker_data, class: 'Integrations::ZentaoTrackerData' do + integration factory: :zentao_integration + url { 'https://jihudemo.zentao.net' } + api_url { '' } + api_token { 'ZENTAO_TOKEN' } + zentao_product_xid { '3' } + end + factory :issue_tracker_data, class: 'Integrations::IssueTrackerData' do integration end factory :open_project_tracker_data, class: 'Integrations::OpenProjectTrackerData' do integration factory: :open_project_service - url { 'http://openproject.example.com'} + url { 'http://openproject.example.com' } token { 'supersecret' } project_identifier_code { 'PRJ-1' } closed_status_id { '15' } diff --git a/spec/factories/integrations.rb b/spec/factories/integrations.rb index ca8ffb135f95be61e11915e8dd231f5c15a24ee6..cb1c94c25c1018b6fefd5533c65d74750b2f9089 100644 --- a/spec/factories/integrations.rb +++ b/spec/factories/integrations.rb @@ -85,6 +85,32 @@ end end + factory :zentao_integration, class: 'Integrations::Zentao' do + project + active { true } + type { 'ZentaoService' } + + transient do + create_data { true } + url { 'https://jihudemo.zentao.net' } + api_url { '' } + api_token { 'ZENTAO_TOKEN' } + zentao_product_xid { '3' } + end + + after(:build) do |integration, evaluator| + if evaluator.create_data + integration.zentao_tracker_data = build(:zentao_tracker_data, + integration: integration, + url: evaluator.url, + api_url: evaluator.api_url, + api_token: evaluator.api_token, + zentao_product_xid: evaluator.zentao_product_xid + ) + end + end + end + factory :confluence_integration, class: 'Integrations::Confluence' do project active { true } diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index cf340b47b68d1af255f076e33ae133e11fb04ff3..3b8fd8f7fc47d336baaa1282797d55dbccca28a8 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -319,6 +319,7 @@ integrations: - project - service_hook - jira_tracker_data +- zentao_tracker_data - issue_tracker_data - open_project_tracker_data hooks: @@ -398,6 +399,7 @@ project: - teamcity_integration - pushover_integration - jira_integration +- zentao_integration - redmine_integration - youtrack_integration - custom_issue_tracker_integration diff --git a/spec/lib/gitlab/zentao/client_spec.rb b/spec/lib/gitlab/zentao/client_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e3a335c1e89ae890227b42fc969494f40f159d51 --- /dev/null +++ b/spec/lib/gitlab/zentao/client_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Zentao::Client do + subject(:integration) { described_class.new(zentao_integration) } + + let(:zentao_integration) { create(:zentao_integration) } + let(:mock_get_products_url) { integration.send(:url, "products/#{zentao_integration.zentao_product_xid}") } + + describe '#new' do + context 'if integration is nil' do + let(:zentao_integration) { nil } + + it 'raises ConfigError' do + expect { integration }.to raise_error(described_class::ConfigError) + end + end + + context 'integration is provided' do + it 'is initialized successfully' do + expect { integration }.not_to raise_error + end + end + end + + describe '#fetch_product' do + let(:mock_headers) do + { + headers: { + 'Content-Type' => 'application/json', + 'Token' => zentao_integration.api_token + } + } + end + + context 'with valid product' do + let(:mock_response) { { 'id' => zentao_integration.zentao_product_xid } } + + before do + WebMock.stub_request(:get, mock_get_products_url) + .with(mock_headers).to_return(status: 200, body: mock_response.to_json) + end + + it 'fetches the product' do + expect(integration.fetch_product(zentao_integration.zentao_product_xid)).to eq mock_response + end + end + + context 'with invalid product' do + before do + WebMock.stub_request(:get, mock_get_products_url) + .with(mock_headers).to_return(status: 404, body: {}.to_json) + end + + it 'fetches the empty product' do + expect(integration.fetch_product(zentao_integration.zentao_product_xid)).to eq({}) + end + end + + context 'with invalid response' do + before do + WebMock.stub_request(:get, mock_get_products_url) + .with(mock_headers).to_return(status: 200, body: '[invalid json}') + end + + it 'fetches the empty product' do + expect(integration.fetch_product(zentao_integration.zentao_product_xid)).to eq({}) + end + end + end + + describe '#ping' do + let(:mock_headers) do + { + headers: { + 'Content-Type' => 'application/json', + 'Token' => zentao_integration.api_token + } + } + end + + context 'with valid resource' do + before do + WebMock.stub_request(:get, mock_get_products_url) + .with(mock_headers).to_return(status: 200, body: { 'deleted' => '0' }.to_json) + end + + it 'responds with success' do + expect(integration.ping[:success]).to eq true + end + end + + context 'with deleted resource' do + before do + WebMock.stub_request(:get, mock_get_products_url) + .with(mock_headers).to_return(status: 200, body: { 'deleted' => '1' }.to_json) + end + + it 'responds with unsuccess' do + expect(integration.ping[:success]).to eq false + end + end + end +end diff --git a/spec/migrations/add_triggers_to_integrations_type_new_spec.rb b/spec/migrations/add_triggers_to_integrations_type_new_spec.rb index 07845715a524f7d119596934c215ae2d90fe52d8..01af588417023a1943b7917b21087e41c21519c9 100644 --- a/spec/migrations/add_triggers_to_integrations_type_new_spec.rb +++ b/spec/migrations/add_triggers_to_integrations_type_new_spec.rb @@ -8,6 +8,18 @@ let(:migration) { described_class.new } let(:integrations) { table(:integrations) } + # This matches Gitlab::Integrations::StiType at the time the trigger was added + let(:namespaced_integrations) do + %w[ + Asana Assembla Bamboo Bugzilla Buildkite Campfire Confluence CustomIssueTracker Datadog + Discord DroneCi EmailsOnPush Ewm ExternalWiki Flowdock HangoutsChat Irker Jenkins Jira Mattermost + MattermostSlashCommands MicrosoftTeams MockCi MockMonitoring Packagist PipelinesEmail Pivotaltracker + Prometheus Pushover Redmine Slack SlackSlashCommands Teamcity UnifyCircuit WebexTeams Youtrack + + Github GitlabSlackApplication + ] + end + describe '#up' do before do migrate! @@ -15,7 +27,7 @@ describe 'INSERT trigger' do it 'sets `type_new` to the transformed `type` class name' do - Gitlab::Integrations::StiType.namespaced_integrations.each do |type| + namespaced_integrations.each do |type| integration = integrations.create!(type: "#{type}Service") expect(integration.reload).to have_attributes( diff --git a/spec/models/integrations/zentao_spec.rb b/spec/models/integrations/zentao_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a1503ecc0920f1d9d6567872e3c1e579a80289c4 --- /dev/null +++ b/spec/models/integrations/zentao_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::Zentao do + let(:url) { 'https://jihudemo.zentao.net' } + let(:api_url) { 'https://jihudemo.zentao.net' } + let(:api_token) { 'ZENTAO_TOKEN' } + let(:zentao_product_xid) { '3' } + let(:zentao_integration) { create(:zentao_integration) } + + describe '#create' do + let(:project) { create(:project, :repository) } + let(:params) do + { + project: project, + url: url, + api_url: api_url, + api_token: api_token, + zentao_product_xid: zentao_product_xid + } + end + + it 'stores data in data_fields correctly' do + tracker_data = described_class.create!(params).zentao_tracker_data + + expect(tracker_data.url).to eq(url) + expect(tracker_data.api_url).to eq(api_url) + expect(tracker_data.api_token).to eq(api_token) + expect(tracker_data.zentao_product_xid).to eq(zentao_product_xid) + end + end + + describe '#fields' do + it 'returns custom fields' do + expect(zentao_integration.fields.pluck(:name)).to eq(%w[url api_url api_token zentao_product_xid]) + end + end + + describe '#test' do + let(:test_response) { { success: true } } + + before do + allow_next_instance_of(Gitlab::Zentao::Client) do |client| + allow(client).to receive(:ping).and_return(test_response) + end + end + + it 'gets response from Gitlab::Zentao::Client#ping' do + expect(zentao_integration.test).to eq(test_response) + end + end +end diff --git a/spec/models/integrations/zentao_tracker_data_spec.rb b/spec/models/integrations/zentao_tracker_data_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b078c57830b094048c4c1e70d34a3d19489fe6db --- /dev/null +++ b/spec/models/integrations/zentao_tracker_data_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::ZentaoTrackerData do + describe 'factory available' do + let(:zentao_tracker_data) { create(:zentao_tracker_data) } + + it { expect(zentao_tracker_data.valid?).to eq true } + end + + describe 'associations' do + it { is_expected.to belong_to(:integration) } + end + + describe 'encrypted attributes' do + subject { described_class.encrypted_attributes.keys } + + it { is_expected.to contain_exactly(:url, :api_url, :zentao_product_xid, :api_token) } + end +end