From 00c0acce7febec09b6cf4d03456c936e5dfb559f Mon Sep 17 00:00:00 2001 From: Baodong Date: Tue, 24 Aug 2021 15:09:49 +0800 Subject: [PATCH 1/2] Add Zentao models and its associations See: https://gitlab.com/gitlab-org/gitlab/-/issues/338178 Changelog: added --- .../concerns/integrations/has_data_fields.rb | 1 + app/models/integrations/zentao.rb | 78 +++++++++++++ .../integrations/zentao_tracker_data.rb | 23 ++++ app/models/project.rb | 3 +- ee/app/models/ee/project.rb | 4 + ee/app/models/license.rb | 1 + lib/gitlab/integrations/sti_type.rb | 2 +- lib/gitlab/zentao/client.rb | 73 ++++++++++++ locale/gitlab.pot | 24 ++++ spec/factories/integration_data.rb | 10 +- spec/factories/integrations.rb | 26 +++++ spec/lib/gitlab/import_export/all_models.yml | 2 + spec/lib/gitlab/zentao/client_spec.rb | 105 ++++++++++++++++++ spec/models/integrations/zentao_spec.rb | 53 +++++++++ .../integrations/zentao_tracker_data_spec.rb | 21 ++++ 15 files changed, 423 insertions(+), 3 deletions(-) create mode 100644 app/models/integrations/zentao.rb create mode 100644 app/models/integrations/zentao_tracker_data.rb create mode 100644 lib/gitlab/zentao/client.rb create mode 100644 spec/lib/gitlab/zentao/client_spec.rb create mode 100644 spec/models/integrations/zentao_spec.rb create mode 100644 spec/models/integrations/zentao_tracker_data_spec.rb diff --git a/app/models/concerns/integrations/has_data_fields.rb b/app/models/concerns/integrations/has_data_fields.rb index e9aaaac8226300..1709b56080eb5d 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 00000000000000..68c02f54c61246 --- /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 00000000000000..468e4e5d7d7ff1 --- /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 6615f9dabb2185..6d4d25695dfbee 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 54e9224c8eff4b..bbc4fa565c75aa 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 de9bd88972b111..4744ee0fbbbd5f 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 0fa9f435b5c681..91797a7b99bcaf 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 00000000000000..bdfa4b3a308e85 --- /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 6c1ac4de1a5107..db84b9e2d69ad0 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 a7406794437c38..4d0892556f8e12 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 ca8ffb135f95be..cb1c94c25c1018 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 cf340b47b68d1a..3b8fd8f7fc47d3 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 00000000000000..e3a335c1e89ae8 --- /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/models/integrations/zentao_spec.rb b/spec/models/integrations/zentao_spec.rb new file mode 100644 index 00000000000000..a1503ecc0920f1 --- /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 00000000000000..b078c57830b094 --- /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 -- GitLab From 77ffe7730364864107e8d0caffe8a17b8c7962a9 Mon Sep 17 00:00:00 2001 From: Baodong Date: Tue, 31 Aug 2021 10:47:52 +0800 Subject: [PATCH 2/2] Fix AddTriggersToIntegrationsTypeNew test --- .../add_triggers_to_integrations_type_new_spec.rb | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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 07845715a524f7..01af588417023a 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( -- GitLab