diff --git a/app/controllers/concerns/integrations/params.rb b/app/controllers/concerns/integrations/params.rb
index 62585ab95af7b19a2b31a356b99b637617bb4fa4..201fb1dc83f2c913bb233cd3f034ca9a39f4e27e 100644
--- a/app/controllers/concerns/integrations/params.rb
+++ b/app/controllers/concerns/integrations/params.rb
@@ -9,6 +9,7 @@ module Params
:add_pusher,
:alert_events,
:api_key,
+ :api_token,
:api_url,
:bamboo_url,
:branches_to_be_notified,
@@ -74,7 +75,8 @@ module Params
:url,
:user_key,
:username,
- :webhook
+ :webhook,
+ :zentao_product_xid
].freeze
def integration_params
diff --git a/app/helpers/integrations_helper.rb b/app/helpers/integrations_helper.rb
index 8819aa9e9cc1b2ba365c99d2bd01f16028209a15..bb4a7fef6be1dc57b331810a86e0187d4dc9801d 100644
--- a/app/helpers/integrations_helper.rb
+++ b/app/helpers/integrations_helper.rb
@@ -132,6 +132,20 @@ def jira_issue_breadcrumb_link(issue_reference)
end
end
+ def zentao_issue_breadcrumb_link(issue)
+ link_to issue[:web_url], { target: '_blank', rel: 'noopener noreferrer', class: 'gl-display-flex gl-align-items-center gl-white-space-nowrap' } do
+ icon = image_tag image_path('logos/zentao.svg'), width: 15, height: 15, class: 'gl-mr-2'
+ [icon, html_escape(issue[:id])].join.html_safe
+ end
+ end
+
+ def zentao_issues_show_data
+ {
+ issues_show_path: project_integrations_zentao_issue_path(@project, params[:id], format: :json),
+ issues_list_path: project_integrations_zentao_issues_path(@project)
+ }
+ end
+
extend self
private
diff --git a/app/models/integration.rb b/app/models/integration.rb
index 158764bb783e9f20659047b548e55ba79991f891..4dd3e1a1785831e69b854f162868d4d27aed0279 100644
--- a/app/models/integration.rb
+++ b/app/models/integration.rb
@@ -14,7 +14,7 @@ class Integration < ApplicationRecord
asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker datadog discord
drone_ci emails_on_push ewm external_wiki flowdock hangouts_chat irker jira
mattermost mattermost_slash_commands microsoft_teams packagist pipelines_email
- pivotaltracker prometheus pushover redmine slack slack_slash_commands teamcity unify_circuit webex_teams youtrack
+ pivotaltracker prometheus pushover redmine slack slack_slash_commands teamcity unify_circuit webex_teams youtrack zentao
].freeze
PROJECT_SPECIFIC_INTEGRATION_NAMES = %w[
diff --git a/app/models/integrations/zentao.rb b/app/models/integrations/zentao.rb
index 68c02f54c61246ae52edc3a152e2f8d4870093ae..2b3e5b6001fa8fa2ec02ae080ad2271c7ce2381f 100644
--- a/app/models/integrations/zentao.rb
+++ b/app/models/integrations/zentao.rb
@@ -9,16 +9,25 @@ class Zentao < Integration
validates :api_token, presence: true, if: :activated?
validates :zentao_product_xid, presence: true, if: :activated?
+ def self.feature_flag_enabled?(project)
+ Feature.enabled?(:zentao_issues_integration, project)
+ end
+
+ # License Level: EEP_FEATURES
+ def self.issues_license_available?(project)
+ project&.licensed_feature_available?(:zentao_issues_integration)
+ end
+
def data_fields
zentao_tracker_data || self.build_zentao_tracker_data
end
def title
- self.class.name.demodulize
+ 'ZenTao'
end
def description
- s_("ZentaoIntegration|Use Zentao as this project's issue tracker.")
+ s_("ZentaoIntegration|Use ZenTao as this project's issue tracker.")
end
def self.to_param
@@ -42,28 +51,28 @@ def fields
{
type: 'text',
name: 'url',
- title: s_('ZentaoIntegration|Zentao Web URL'),
+ title: s_('ZentaoIntegration|ZenTao Web URL'),
placeholder: 'https://www.zentao.net',
- help: s_('ZentaoIntegration|Base URL of the Zentao instance.'),
+ help: s_('ZentaoIntegration|Base URL of the ZenTao instance.'),
required: true
},
{
type: 'text',
name: 'api_url',
- title: s_('ZentaoIntegration|Zentao API URL (optional)'),
+ 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'),
+ 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'),
+ title: s_('ZentaoIntegration|ZenTao Product ID'),
required: true
}
]
diff --git a/app/models/project.rb b/app/models/project.rb
index 6eb19b4462cfbbdbd018db55d6425dd01d1e2334..b827f48e706eb6cb0c2540ff60bda164765c51d9 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -1453,7 +1453,10 @@ def find_or_initialize_integrations
end
def disabled_integrations
- [:zentao]
+ disabled_integrations = []
+ disabled_integrations << :zentao unless ::Integrations::Zentao.feature_flag_enabled?(self)
+
+ disabled_integrations
end
def find_or_initialize_integration(name)
diff --git a/config/feature_flags/development/zentao_issues_integration.yml b/config/feature_flags/development/zentao_issues_integration.yml
new file mode 100644
index 0000000000000000000000000000000000000000..150340874cb54aebcc87b3c2b3eee3b595516b9c
--- /dev/null
+++ b/config/feature_flags/development/zentao_issues_integration.yml
@@ -0,0 +1,8 @@
+---
+name: zentao_issues_integration
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69602
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/338775
+milestone: '14.4'
+type: development
+group: group::integrations
+default_enabled: false
diff --git a/config/metrics/counts_all/20210730011801_projects_zentao_active.yml b/config/metrics/counts_all/20210730011801_projects_zentao_active.yml
new file mode 100644
index 0000000000000000000000000000000000000000..8a9c3f969a07cbbaa80a33bb2f1ad747bbcd4db5
--- /dev/null
+++ b/config/metrics/counts_all/20210730011801_projects_zentao_active.yml
@@ -0,0 +1,22 @@
+---
+key_path: counts.projects_zentao_active
+name: count_all_projects_zentao_active
+description: Count of projects with active Zentao integrations
+product_section: dev
+product_stage: ecosystem
+product_group: group::integrations
+product_category: integrations
+value_type: number
+status: active
+milestone: "14.4"
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/338178
+time_frame: all
+data_source: database
+data_category: Operational
+distribution:
+- ce
+- ee
+tier:
+- free
+- premium
+- ultimate
diff --git a/config/metrics/counts_all/20210730011802_groups_zentao_active.yml b/config/metrics/counts_all/20210730011802_groups_zentao_active.yml
new file mode 100644
index 0000000000000000000000000000000000000000..c8a1dfbbdd22fd304975010dabdeb93e6fa1d316
--- /dev/null
+++ b/config/metrics/counts_all/20210730011802_groups_zentao_active.yml
@@ -0,0 +1,22 @@
+---
+key_path: counts.groups_zentao_active
+name: count_all_groups_zentao_active
+description: Count of groups with active Zentao integrations
+product_section: dev
+product_stage: ecosystem
+product_group: group::integrations
+product_category: integrations
+value_type: number
+status: active
+milestone: "14.4"
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/338178
+time_frame: all
+data_source: database
+data_category: Operational
+distribution:
+- ce
+- ee
+tier:
+- free
+- premium
+- ultimate
diff --git a/config/metrics/counts_all/20210730011804_instances_zentao_active.yml b/config/metrics/counts_all/20210730011804_instances_zentao_active.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b1c2a9d8b13e5be6bc402a84b6ba2f45eb697fde
--- /dev/null
+++ b/config/metrics/counts_all/20210730011804_instances_zentao_active.yml
@@ -0,0 +1,22 @@
+---
+key_path: counts.instances_zentao_active
+name: count_all_instances_zentao_active
+description: Count of instances with active Zentao integrations
+product_section: dev
+product_stage: ecosystem
+product_group: group::integrations
+product_category: integrations
+value_type: number
+status: active
+milestone: "14.4"
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/338178
+time_frame: all
+data_source: database
+data_category: Operational
+distribution:
+- ce
+- ee
+tier:
+- free
+- premium
+- ultimate
diff --git a/config/metrics/counts_all/20210730011805_projects_inheriting_zentao_active.yml b/config/metrics/counts_all/20210730011805_projects_inheriting_zentao_active.yml
new file mode 100644
index 0000000000000000000000000000000000000000..34c48ada0ee71e61f477630db212a920f2abf6ca
--- /dev/null
+++ b/config/metrics/counts_all/20210730011805_projects_inheriting_zentao_active.yml
@@ -0,0 +1,22 @@
+---
+key_path: counts.projects_inheriting_zentao_active
+name: count_all_projects_inheriting_zentao_active
+description: Count of projects that inherit active Zentao integrations
+product_section: dev
+product_stage: ecosystem
+product_group: group::integrations
+product_category: integrations
+value_type: number
+status: active
+milestone: "14.4"
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/338178
+time_frame: all
+data_source: database
+data_category: Operational
+distribution:
+- ce
+- ee
+tier:
+- free
+- premium
+- ultimate
diff --git a/config/metrics/counts_all/20210730011806_groups_inheriting_zentao_active.yml b/config/metrics/counts_all/20210730011806_groups_inheriting_zentao_active.yml
new file mode 100644
index 0000000000000000000000000000000000000000..4d374db026e80d20b78aaa9d4accf0bd364c2e89
--- /dev/null
+++ b/config/metrics/counts_all/20210730011806_groups_inheriting_zentao_active.yml
@@ -0,0 +1,22 @@
+---
+key_path: counts.groups_inheriting_zentao_active
+name: count_all_groups_inheriting_zentao_active
+description: Count of groups that inherit active Zentao integrations
+product_section: dev
+product_stage: ecosystem
+product_group: group::integrations
+product_category: integrations
+value_type: number
+status: active
+milestone: "14.4"
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/338178
+time_frame: all
+data_source: database
+data_category: Operational
+distribution:
+- ce
+- ee
+tier:
+- free
+- premium
+- ultimate
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 17d62ea19ad8a2b0aeaca791608d834e0776da87..c228d8ae205a308e7237ed40ccb8d9a9b2c0660c 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -16578,6 +16578,7 @@ State of a Sentry error.
| `UNIFY_CIRCUIT_SERVICE` | UnifyCircuitService type. |
| `WEBEX_TEAMS_SERVICE` | WebexTeamsService type. |
| `YOUTRACK_SERVICE` | YoutrackService type. |
+| `ZENTAO_SERVICE` | ZentaoService type. |
### `SharedRunnersSetting`
diff --git a/ee/app/controllers/projects/integrations/zentao/issues_controller.rb b/ee/app/controllers/projects/integrations/zentao/issues_controller.rb
new file mode 100644
index 0000000000000000000000000000000000000000..0d40c46f793d976d8ad92a9b7f5a4b123ee27386
--- /dev/null
+++ b/ee/app/controllers/projects/integrations/zentao/issues_controller.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+module Projects
+ module Integrations
+ module Zentao
+ class IssuesController < Projects::ApplicationController
+ include RecordUserLastActivity
+
+ before_action :check_feature_enabled!
+
+ rescue_from ::Gitlab::Zentao::Client::Error, with: :render_error
+
+ feature_category :integrations
+
+ def index
+ respond_to do |format|
+ format.html
+ format.json do
+ render json: issues_json
+ end
+ end
+ end
+
+ def show
+ @issue_json = issue_json
+ respond_to do |format|
+ format.html
+ format.json do
+ render json: @issue_json
+ end
+ end
+ end
+
+ private
+
+ def query_params
+ params.permit(:id, :page, :limit, :search, :sort, :state, :labels)
+ end
+
+ def query
+ ::Gitlab::Zentao::Query.new(project.zentao_integration, query_params)
+ end
+
+ def issue_json
+ ::Integrations::ZentaoSerializers::IssueDetailSerializer.new
+ .represent(query.issue, project: project)
+ end
+
+ def issues_json
+ ::Integrations::ZentaoSerializers::IssueSerializer.new
+ .with_pagination(request, response)
+ .represent(query.issues, project: project)
+ end
+
+ def check_feature_enabled!
+ return render_404 unless ::Integrations::Zentao.feature_flag_enabled?(project)
+
+ return render_404 unless ::Integrations::Zentao.issues_license_available?(project) && project.zentao_integration&.active?
+ end
+
+ def render_error(exception)
+ log_exception(exception)
+
+ render json: { errors: [s_('ZentaoIntegration|An error occurred while requesting data from the ZenTao service.')] },
+ status: :bad_request
+ end
+ end
+ end
+ end
+end
diff --git a/ee/app/models/ee/project.rb b/ee/app/models/ee/project.rb
index d4ac2be5e98217b99355bb4ca7421e72cb0078bf..1f68b5aa44e733b882db00fee837c17efd097c2b 100644
--- a/ee/app/models/ee/project.rb
+++ b/ee/app/models/ee/project.rb
@@ -404,10 +404,6 @@ 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/serializers/integrations/zentao_serializers/issue_detail_entity.rb b/ee/app/serializers/integrations/zentao_serializers/issue_detail_entity.rb
new file mode 100644
index 0000000000000000000000000000000000000000..0f40bf87a95037be8e82a3b525642e9deb448229
--- /dev/null
+++ b/ee/app/serializers/integrations/zentao_serializers/issue_detail_entity.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Integrations
+ module ZentaoSerializers
+ class IssueDetailEntity < IssueEntity
+ expose :description_html do |item|
+ sanitize(item['desc'])
+ end
+
+ expose :comments do |item|
+ item['comments'].map do |comment|
+ {
+ id: comment['id']&.to_i,
+ created_at: comment['date']&.to_datetime&.utc,
+ body_html: body_html(comment),
+ author: user_info(comment['actor'])
+ }
+ end
+ end
+
+ private
+
+ def body_html(comment)
+ content = [comment['title'], comment['body_html']].join('
')
+ sanitize(content)
+ end
+ end
+ end
+end
diff --git a/ee/app/serializers/integrations/zentao_serializers/issue_detail_serializer.rb b/ee/app/serializers/integrations/zentao_serializers/issue_detail_serializer.rb
new file mode 100644
index 0000000000000000000000000000000000000000..72bd7e41b080e3e387daeb97c34ebef1c02555fe
--- /dev/null
+++ b/ee/app/serializers/integrations/zentao_serializers/issue_detail_serializer.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Integrations
+ module ZentaoSerializers
+ class IssueDetailSerializer < BaseSerializer
+ entity ::Integrations::ZentaoSerializers::IssueDetailEntity
+ end
+ end
+end
diff --git a/ee/app/serializers/integrations/zentao_serializers/issue_entity.rb b/ee/app/serializers/integrations/zentao_serializers/issue_entity.rb
new file mode 100644
index 0000000000000000000000000000000000000000..266af532362dfb5234cc6bad4511085a2b6a6de4
--- /dev/null
+++ b/ee/app/serializers/integrations/zentao_serializers/issue_entity.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+module Integrations
+ module ZentaoSerializers
+ class IssueEntity < Grape::Entity
+ include ActionView::Helpers::SanitizeHelper
+ include RequestAwareEntity
+
+ expose :id do |item|
+ sanitize(item['id'])
+ end
+
+ expose :project_id do |item|
+ project.id
+ end
+
+ expose :title do |item|
+ sanitize(item['title'])
+ end
+
+ expose :created_at do |item|
+ item['openedDate']&.to_datetime&.utc
+ end
+
+ expose :updated_at do |item|
+ item['lastEditedDate']&.to_datetime&.utc
+ end
+
+ expose :closed_at do |item|
+ item['lastEditedDate']&.to_datetime&.utc if item['status'] == 'closed'
+ end
+
+ expose :status do |item|
+ sanitize(item['status'])
+ end
+
+ expose :state do |item|
+ sanitize(item['status'])
+ end
+
+ expose :labels do |item|
+ item['labels'].compact.map do |label|
+ name = sanitize(label)
+ {
+ id: name,
+ title: name,
+ name: name,
+ color: '#0052CC',
+ text_color: '#FFFFFF'
+ }
+ end
+ end
+
+ expose :author do |item|
+ user_info(item['openedBy'])
+ end
+
+ expose :assignees do |item|
+ item['assignedTo'].compact.map do |user|
+ user_info(user)
+ end
+ end
+
+ expose :web_url do |item|
+ item['url']
+ end
+
+ expose :gitlab_web_url do |item|
+ project_integrations_zentao_issue_path(project, item['id'])
+ end
+
+ private
+
+ def project
+ @project ||= options[:project]
+ end
+
+ def user_info(user)
+ return {} unless user.present?
+
+ {
+ "name": sanitize(user['realname'].presence || user['account']),
+ "web_url": user['url'],
+ "avatar_url": user['avatar']
+ }
+ end
+ end
+ end
+end
diff --git a/ee/app/serializers/integrations/zentao_serializers/issue_serializer.rb b/ee/app/serializers/integrations/zentao_serializers/issue_serializer.rb
new file mode 100644
index 0000000000000000000000000000000000000000..064833554f290a0262c7ef622994fe88200af690
--- /dev/null
+++ b/ee/app/serializers/integrations/zentao_serializers/issue_serializer.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Integrations
+ module ZentaoSerializers
+ class IssueSerializer < BaseSerializer
+ include WithPagination
+
+ entity ::Integrations::ZentaoSerializers::IssueEntity
+ end
+ end
+end
diff --git a/ee/app/views/projects/integrations/zentao/issues/index.html.haml b/ee/app/views/projects/integrations/zentao/issues/index.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..16420a300a0cd8d7cda50f28295b2c7e67f8fe79
--- /dev/null
+++ b/ee/app/views/projects/integrations/zentao/issues/index.html.haml
@@ -0,0 +1,10 @@
+- page_title _('ZentaoIntegration|Zentao issues')
+- add_page_specific_style 'page_bundles/issues_list'
+
+.js-zentao-issues-list{ data: { issues_fetch_path: project_integrations_zentao_issues_path(@project, format: :json),
+ page: params[:page],
+ initial_state: params[:state],
+ initial_sort_by: params[:sort],
+ project_full_path: @project.full_path,
+ issue_create_url: @project.zentao_integration.url,
+ empty_state_path: image_path('illustrations/issues.svg') } }
diff --git a/ee/app/views/projects/integrations/zentao/issues/show.html.haml b/ee/app/views/projects/integrations/zentao/issues/show.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..057a2fd8f26413d2459da6504f62ebe0ab1bce0d
--- /dev/null
+++ b/ee/app/views/projects/integrations/zentao/issues/show.html.haml
@@ -0,0 +1,5 @@
+- add_to_breadcrumbs _('Zentao issues'), project_integrations_zentao_issues_path(@project)
+- breadcrumb_title zentao_issue_breadcrumb_link(@issue_json)
+- page_title html_escape(@issue_json[:title])
+
+.js-zentao-issues-show-app{ data: zentao_issues_show_data }
diff --git a/ee/config/routes/project.rb b/ee/config/routes/project.rb
index 5ba33247a6c8df76cb3bd122b3d785ced8978a73..f8ca182ad1d4dcb8afc49589cded4586ed7f49d3 100644
--- a/ee/config/routes/project.rb
+++ b/ee/config/routes/project.rb
@@ -119,6 +119,10 @@
end
end
end
+
+ namespace :zentao do
+ resources :issues, only: [:index, :show]
+ end
end
# Added for backward compatibility with https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39543
diff --git a/ee/lib/ee/sidebars/projects/menus/zentao_menu.rb b/ee/lib/ee/sidebars/projects/menus/zentao_menu.rb
new file mode 100644
index 0000000000000000000000000000000000000000..049cbc063d1cf2ef4bb54bb637d81e210de9f0d6
--- /dev/null
+++ b/ee/lib/ee/sidebars/projects/menus/zentao_menu.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module EE
+ module Sidebars
+ module Projects
+ module Menus
+ module ZentaoMenu
+ extend ::Gitlab::Utils::Override
+
+ override :link
+ def link
+ return super unless feature_available?
+
+ project_integrations_zentao_issues_path(context.project)
+ end
+
+ override :add_items
+ def add_items
+ add_item(issue_list_menu_item) if feature_available?
+ super
+ end
+
+ private
+
+ def feature_available?
+ ::Integrations::Zentao.issues_license_available?(context.project)
+ end
+
+ def issue_list_menu_item
+ ::Sidebars::MenuItem.new(
+ title: s_('ZentaoIntegration|Issue list'),
+ link: project_integrations_zentao_issues_path(context.project),
+ active_routes: { controller: 'projects/integrations/zentao/issues' },
+ item_id: :issue_list
+ )
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/ee/spec/controllers/projects/integrations/zentao/issues_controller_spec.rb b/ee/spec/controllers/projects/integrations/zentao/issues_controller_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f60bd94ee8c15332f31789c9c0690be64922eb81
--- /dev/null
+++ b/ee/spec/controllers/projects/integrations/zentao/issues_controller_spec.rb
@@ -0,0 +1,127 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::Integrations::Zentao::IssuesController do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user, developer_projects: [project]) }
+ let_it_be(:zentao_integration) { create(:zentao_integration, project: project) }
+
+ before do
+ stub_licensed_features(zentao_issues_integration: true)
+ sign_in(user)
+ end
+
+ describe 'GET #index' do
+ context 'when zentao_issues_integration licensed feature is not available' do
+ before do
+ stub_licensed_features(zentao_issues_integration: false)
+ end
+
+ it 'returns 404 status' do
+ get :index, params: { namespace_id: project.namespace, project_id: project }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ it_behaves_like 'unauthorized when external service denies access' do
+ subject { get :index, params: { namespace_id: project.namespace, project_id: project } }
+ end
+
+ it 'renders the "index" template' do
+ get :index, params: { namespace_id: project.namespace, project_id: project }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template(:index)
+ end
+
+ context 'json request' do
+ let(:zentao_issue) { [] }
+
+ it 'returns a list of serialized zentao issues' do
+ expect_next_instance_of(::Gitlab::Zentao::Query) do |query|
+ expect(query).to receive(:issues).and_return(zentao_issue)
+ end
+
+ expect_next_instance_of(Integrations::ZentaoSerializers::IssueSerializer) do |serializer|
+ expect(serializer).to receive(:represent).with(zentao_issue, project: project)
+ end
+
+ get :index, params: { namespace_id: project.namespace, project_id: project }, format: :json
+ end
+
+ it 'renders bad request for Error' do
+ expect_next_instance_of(::Gitlab::Zentao::Query) do |query|
+ expect(query).to receive(:issues).and_raise(::Gitlab::Zentao::Client::Error)
+ end
+ expect(Gitlab::ErrorTracking).to receive(:track_exception)
+
+ get :index, params: { namespace_id: project.namespace, project_id: project }, format: :json
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['errors']).to match_array [s_('ZentaoIntegration|An error occurred while requesting data from the ZenTao service.')]
+ end
+ end
+ end
+
+ describe 'GET #show' do
+ context 'when zentao_issues_integration licensed feature is not available' do
+ before do
+ stub_licensed_features(zentao_issues_integration: false)
+ end
+
+ it 'returns 404 status' do
+ get :show, params: { namespace_id: project.namespace, project_id: project, id: 1 }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when zentao_issues_integration licensed feature is available' do
+ let(:zentao_issue) { { 'from' => 'zentao' } }
+ let(:issue_json) { { 'from' => 'backend' } }
+
+ before do
+ stub_licensed_features(zentao_issues_integration: true)
+
+ expect_next_instance_of(::Gitlab::Zentao::Query) do |query|
+ allow(query).to receive(:issue).and_return(zentao_issue)
+ end
+
+ allow_next_instance_of(Integrations::ZentaoSerializers::IssueDetailSerializer) do |serializer|
+ allow(serializer).to receive(:represent).with(zentao_issue, project: project).and_return(issue_json)
+ end
+ end
+
+ it 'renders `show` template' do
+ get :show, params: { namespace_id: project.namespace, project_id: project, id: 'story-1' }
+
+ expect(assigns(:issue_json)).to eq(issue_json)
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template(:show)
+ end
+
+ it 'returns JSON response' do
+ get :show, params: { namespace_id: project.namespace, project_id: project, id: 'story-1', format: :json }
+
+ expect(json_response).to eq(issue_json)
+ end
+
+ context 'when the JSON fetched from ZenTao contains HTML' do
+ let(:payload) { "" }
+ let(:issue_json) { { id: payload, title: payload, status: payload, labels: [payload] } }
+
+ render_views
+
+ it 'escapes the HTML in issue' do
+ get :show, params: { namespace_id: project.namespace, project_id: project, id: 'story-1' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.body).not_to include(payload)
+ expect(response.body).to include(html_escape(payload))
+ end
+ end
+ end
+ end
+end
diff --git a/ee/spec/lib/ee/sidebars/projects/menus/zentao_menu_spec.rb b/ee/spec/lib/ee/sidebars/projects/menus/zentao_menu_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..37b9a5347b9502b460d969dcd066bfbe9fe747da
--- /dev/null
+++ b/ee/spec/lib/ee/sidebars/projects/menus/zentao_menu_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Projects::Menus::ZentaoMenu do
+ let(:project) { create(:project, has_external_issue_tracker: true) }
+ let(:user) { project.owner }
+ let(:context) { Sidebars::Projects::Context.new(current_user: user, container: project) }
+ let(:zentao_integration) { create(:zentao_integration, project: project) }
+
+ subject { described_class.new(context) }
+
+ describe 'when feature is not licensed' do
+ before do
+ stub_licensed_features(zentao_issues_integration: false)
+ end
+
+ it_behaves_like 'ZenTao menu with CE version'
+ end
+
+ describe 'when feature is licensed' do
+ before do
+ stub_licensed_features(zentao_issues_integration: true)
+ end
+
+ context 'when issues integration is disabled' do
+ before do
+ zentao_integration.update!(active: false)
+ end
+
+ it 'returns false' do
+ expect(subject.render?).to eq false
+ end
+ end
+
+ context 'when issues integration is enabled' do
+ before do
+ zentao_integration.update!(active: true)
+ end
+
+ it 'returns true' do
+ expect(subject.render?).to eq true
+ end
+
+ it 'renders menu link' do
+ expect(subject.link).to include('/-/integrations/zentao/issues')
+ end
+
+ it 'contains issue list and open ZenTao menu items' do
+ expect(subject.renderable_items.map(&:item_id)).to match_array [:issue_list, :open_zentao]
+ end
+ end
+ end
+end
diff --git a/lib/api/helpers/integrations_helpers.rb b/lib/api/helpers/integrations_helpers.rb
index e0ef9099104e4d72ac05fe713cfebf65f4ec2d11..7ae46abd0971c51630fbfd84d3b7cd6424cd7b9a 100644
--- a/lib/api/helpers/integrations_helpers.rb
+++ b/lib/api/helpers/integrations_helpers.rb
@@ -254,7 +254,7 @@ def self.integrations
type: Boolean,
desc: 'DEPRECATED: This parameter has no effect since SSL verification will always be enabled'
}
- ],
+ ],
'campfire' => [
{
required: true,
@@ -768,7 +768,33 @@ def self.integrations
desc: 'The Webex Teams webhook. For example, https://api.ciscospark.com/v1/webhooks/incoming/...'
},
chat_notification_events
- ].flatten
+ ].flatten,
+ 'zentao' => [
+ {
+ required: true,
+ name: :url,
+ type: String,
+ desc: 'The base URL to the ZenTao instance web interface which is being linked to this GitLab project. For example, https://www.zentao.net'
+ },
+ {
+ required: false,
+ name: :api_url,
+ type: String,
+ desc: 'The base URL to the ZenTao instance API. Web URL value will be used if not set. For example, https://www.zentao.net'
+ },
+ {
+ required: true,
+ name: :api_token,
+ type: String,
+ desc: 'The API token created from ZenTao dashboard'
+ },
+ {
+ required: true,
+ name: :zentao_product_xid,
+ type: String,
+ desc: 'The product ID of ZenTao project'
+ }
+ ]
}
end
@@ -805,7 +831,8 @@ def self.integration_classes
::Integrations::Slack,
::Integrations::SlackSlashCommands,
::Integrations::Teamcity,
- ::Integrations::Youtrack
+ ::Integrations::Youtrack,
+ ::Integrations::Zentao
]
end
diff --git a/lib/gitlab/zentao/client.rb b/lib/gitlab/zentao/client.rb
index bdfa4b3a308e853188261641dab898abf66013af..e22d57faeca425a1e8388bb07f65105bf3cfe14d 100644
--- a/lib/gitlab/zentao/client.rb
+++ b/lib/gitlab/zentao/client.rb
@@ -15,10 +15,8 @@ def initialize(integration)
end
def ping
- response = fetch_product(zentao_product_xid)
-
+ response = fetch_product(zentao_product_xid) rescue {}
active = response.fetch('deleted') == '0' rescue false
-
if active
{ success: true }
else
@@ -31,25 +29,30 @@ def fetch_product(product_id)
end
def fetch_issues(params = {})
- get("products/#{zentao_product_xid}/issues",
- params.reverse_merge(page: 1, limit: 20))
+ get("products/#{zentao_product_xid}/issues", params)
end
def fetch_issue(issue_id)
+ raise Gitlab::Zentao::Client::Error unless issue_id_pattern.match(issue_id)
+
get("issues/#{issue_id}")
end
private
+ def issue_id_pattern
+ /\A\S+-\d+\z/
+ end
+
def get(path, params = {})
options = { headers: headers, query: params }
response = Gitlab::HTTP.get(url(path), options)
- return {} unless response.success?
+ raise Gitlab::Zentao::Client::Error unless response.success?
Gitlab::Json.parse(response.body)
rescue JSON::ParserError
- {}
+ raise Gitlab::Zentao::Client::Error
end
def url(path)
diff --git a/lib/gitlab/zentao/query.rb b/lib/gitlab/zentao/query.rb
new file mode 100644
index 0000000000000000000000000000000000000000..8f3f7b6d316aee5703ea550a6d894fea65a98a7d
--- /dev/null
+++ b/lib/gitlab/zentao/query.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Zentao
+ class Query
+ STATUSES = %w[all opened closed].freeze
+ ISSUES_DEFAULT_LIMIT = 20
+ ISSUES_MAX_LIMIT = 50
+
+ attr_reader :client, :params
+
+ def initialize(integration, params)
+ @client = Client.new(integration)
+ @params = params
+ end
+
+ def issues
+ issues_response = client.fetch_issues(query_options)
+ return [] if issues_response.blank?
+
+ Kaminari.paginate_array(
+ issues_response['issues'],
+ limit: issues_response['limit'],
+ total_count: issues_response['total']
+ )
+ end
+
+ def issue
+ issue_response = client.fetch_issue(params[:id])
+ issue_response['issue']
+ end
+
+ private
+
+ def query_options
+ {
+ order: query_order,
+ status: query_status,
+ labels: query_labels,
+ page: query_page,
+ limit: query_limit,
+ search: query_search
+ }
+ end
+
+ def query_page
+ params[:page].presence || 1
+ end
+
+ def query_limit
+ limit = params[:limit].presence || ISSUES_DEFAULT_LIMIT
+ [limit.to_i, ISSUES_MAX_LIMIT].min
+ end
+
+ def query_search
+ params[:search] || ''
+ end
+
+ def query_order
+ key, order = params['sort'].to_s.split('_', 2)
+ zentao_key = (key == 'created' ? 'openedDate' : 'lastEditedDate')
+ zentao_order = (order == 'asc' ? 'asc' : 'desc')
+
+ "#{zentao_key}_#{zentao_order}"
+ end
+
+ def query_status
+ return params[:state] if params[:state].present? && params[:state].in?(STATUSES)
+
+ 'opened'
+ end
+
+ def query_labels
+ (params[:labels].presence || []).join(',')
+ end
+ end
+ end
+end
diff --git a/lib/sidebars/projects/menus/zentao_menu.rb b/lib/sidebars/projects/menus/zentao_menu.rb
new file mode 100644
index 0000000000000000000000000000000000000000..fe96fb6d9f485ceeae2e61baf1c14dac584a9c80
--- /dev/null
+++ b/lib/sidebars/projects/menus/zentao_menu.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+module Sidebars
+ module Projects
+ module Menus
+ class ZentaoMenu < ::Sidebars::Menu
+ override :configure_menu_items
+ def configure_menu_items
+ render?.tap do |render|
+ break unless render
+
+ add_items
+ end
+ end
+
+ override :link
+ def link
+ zentao_integration.url
+ end
+
+ override :title
+ def title
+ s_('ZentaoIntegration|ZenTao issues')
+ end
+
+ override :title_html_options
+ def title_html_options
+ {
+ id: 'js-onboarding-settings-link'
+ }
+ end
+
+ override :image_path
+ def image_path
+ 'logos/zentao.svg'
+ end
+
+ # Hardcode sizes so image doesn't flash before CSS loads https://gitlab.com/gitlab-org/gitlab/-/issues/321022
+ override :image_html_options
+ def image_html_options
+ {
+ size: 16
+ }
+ end
+
+ override :render?
+ def render?
+ return false if zentao_integration.blank?
+
+ zentao_integration.active?
+ end
+
+ def add_items
+ add_item(open_zentao_menu_item)
+ end
+
+ private
+
+ def zentao_integration
+ @zentao_integration ||= context.project.zentao_integration
+ end
+
+ def open_zentao_menu_item
+ ::Sidebars::MenuItem.new(
+ title: s_('ZentaoIntegration|Open ZenTao'),
+ link: zentao_integration.url,
+ active_routes: {},
+ item_id: :open_zentao,
+ sprite_icon: 'external-link',
+ container_html_options: {
+ target: '_blank',
+ rel: 'noopener noreferrer'
+ }
+ )
+ end
+ end
+ end
+ end
+end
+
+::Sidebars::Projects::Menus::ZentaoMenu.prepend_mod
diff --git a/lib/sidebars/projects/panel.rb b/lib/sidebars/projects/panel.rb
index d5311c0a0c143d20297b1c1a630a0af8d025d274..f37f404d03cd1324f2d29f8324500a0226823baf 100644
--- a/lib/sidebars/projects/panel.rb
+++ b/lib/sidebars/projects/panel.rb
@@ -23,6 +23,7 @@ def add_menus
add_menu(Sidebars::Projects::Menus::RepositoryMenu.new(context))
add_menu(Sidebars::Projects::Menus::IssuesMenu.new(context))
add_menu(Sidebars::Projects::Menus::ExternalIssueTrackerMenu.new(context))
+ add_menu(Sidebars::Projects::Menus::ZentaoMenu.new(context)) if ::Integrations::Zentao.feature_flag_enabled?(context.project)
add_menu(Sidebars::Projects::Menus::MergeRequestsMenu.new(context))
add_menu(Sidebars::Projects::Menus::CiCdMenu.new(context))
add_menu(Sidebars::Projects::Menus::SecurityComplianceMenu.new(context))
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index c235817cff66c61a7df50692d0e832eef5ee319e..f9fd2bd83d3c1292f2a2dfb7c537e460cec8e416 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -39678,7 +39678,13 @@ msgstr ""
msgid "ZenTaoIntegration|ZenTao user"
msgstr ""
-msgid "ZentaoIntegration|Base URL of the Zentao instance."
+msgid "Zentao issues"
+msgstr ""
+
+msgid "ZentaoIntegration|An error occurred while requesting data from the ZenTao service."
+msgstr ""
+
+msgid "ZentaoIntegration|Base URL of the ZenTao instance."
msgstr ""
msgid "ZentaoIntegration|Enter API token"
@@ -39687,19 +39693,31 @@ msgstr ""
msgid "ZentaoIntegration|If different from Web URL."
msgstr ""
-msgid "ZentaoIntegration|Use Zentao as this project's issue tracker."
+msgid "ZentaoIntegration|Issue list"
+msgstr ""
+
+msgid "ZentaoIntegration|Open ZenTao"
+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 API URL (optional)"
+msgid "ZentaoIntegration|ZenTao Product ID"
msgstr ""
-msgid "ZentaoIntegration|Zentao API token"
+msgid "ZentaoIntegration|ZenTao Web URL"
msgstr ""
-msgid "ZentaoIntegration|Zentao Product ID"
+msgid "ZentaoIntegration|ZenTao issues"
msgstr ""
-msgid "ZentaoIntegration|Zentao Web URL"
+msgid "ZentaoIntegration|Zentao issues"
msgstr ""
msgid "Zoom meeting added"
diff --git a/spec/lib/gitlab/zentao/client_spec.rb b/spec/lib/gitlab/zentao/client_spec.rb
index e3a335c1e89ae890227b42fc969494f40f159d51..6e56ac3a8ff0c394d39bbce9621fbc172cbd7694 100644
--- a/spec/lib/gitlab/zentao/client_spec.rb
+++ b/spec/lib/gitlab/zentao/client_spec.rb
@@ -6,7 +6,23 @@
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}") }
+
+ def mock_get_products_url
+ integration.send(:url, "products/#{zentao_integration.zentao_product_xid}")
+ end
+
+ def mock_fetch_issue_url(issue_id)
+ integration.send(:url, "issues/#{issue_id}")
+ end
+
+ let(:mock_headers) do
+ {
+ headers: {
+ 'Content-Type' => 'application/json',
+ 'Token' => zentao_integration.api_token
+ }
+ }
+ end
describe '#new' do
context 'if integration is nil' do
@@ -25,15 +41,6 @@
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 } }
@@ -54,7 +61,9 @@
end
it 'fetches the empty product' do
- expect(integration.fetch_product(zentao_integration.zentao_product_xid)).to eq({})
+ expect do
+ integration.fetch_product(zentao_integration.zentao_product_xid)
+ end.to raise_error(Gitlab::Zentao::Client::Error)
end
end
@@ -65,21 +74,14 @@
end
it 'fetches the empty product' do
- expect(integration.fetch_product(zentao_integration.zentao_product_xid)).to eq({})
+ expect do
+ integration.fetch_product(zentao_integration.zentao_product_xid)
+ end.to raise_error(Gitlab::Zentao::Client::Error)
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)
@@ -102,4 +104,29 @@
end
end
end
+
+ describe '#fetch_issue' do
+ context 'with invalid id' do
+ let(:invalid_ids) { ['story', 'story-', '-', '123', ''] }
+
+ it 'returns empty object' do
+ invalid_ids.each do |id|
+ expect { integration.fetch_issue(id) }.to raise_error(Gitlab::Zentao::Client::Error)
+ end
+ end
+ end
+
+ context 'with valid id' do
+ let(:valid_ids) { %w[story-1 bug-23] }
+
+ it 'fetches current issue' do
+ valid_ids.each do |id|
+ WebMock.stub_request(:get, mock_fetch_issue_url(id))
+ .with(mock_headers).to_return(status: 200, body: { issue: { id: id } }.to_json)
+
+ expect(integration.fetch_issue(id).dig('issue', 'id')).to eq id
+ end
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/zentao/query_spec.rb b/spec/lib/gitlab/zentao/query_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f7495e640c32e2a9042ed12aa6881d323443c10d
--- /dev/null
+++ b/spec/lib/gitlab/zentao/query_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Zentao::Query do
+ let(:zentao_integration) { create(:zentao_integration) }
+ let(:params) { {} }
+
+ subject(:query) { described_class.new(zentao_integration, ActionController::Parameters.new(params)) }
+
+ describe '#issues' do
+ let(:response) { { 'page' => 1, 'total' => 0, 'limit' => 20, 'issues' => [] } }
+
+ def expect_query_option_include(expected_params)
+ expect_next_instance_of(Gitlab::Zentao::Client) do |client|
+ expect(client).to receive(:fetch_issues)
+ .with(hash_including(expected_params))
+ .and_return(response)
+ end
+
+ query.issues
+ end
+
+ context 'when params are empty' do
+ it 'fills default params' do
+ expect_query_option_include(status: 'opened', order: 'lastEditedDate_desc', labels: '')
+ end
+ end
+
+ context 'when params contain valid options' do
+ let(:params) { { state: 'closed', sort: 'created_asc', labels: %w[Bugs Features] } }
+
+ it 'fills params with standard of ZenTao' do
+ expect_query_option_include(status: 'closed', order: 'openedDate_asc', labels: 'Bugs,Features')
+ end
+ end
+
+ context 'when params contain invalid options' do
+ let(:params) { { state: 'xxx', sort: 'xxx', labels: %w[xxx] } }
+
+ it 'fills default params with standard of ZenTao' do
+ expect_query_option_include(status: 'opened', order: 'lastEditedDate_desc', labels: 'xxx')
+ end
+ end
+ end
+
+ describe '#issue' do
+ let(:response) { { 'issue' => { 'id' => 'story-1' } } }
+
+ before do
+ expect_next_instance_of(Gitlab::Zentao::Client) do |client|
+ expect(client).to receive(:fetch_issue)
+ .and_return(response)
+ end
+ end
+
+ it 'returns issue object by client' do
+ expect(query.issue).to include('id' => 'story-1')
+ end
+ end
+end
diff --git a/spec/lib/sidebars/projects/menus/zentao_menu_spec.rb b/spec/lib/sidebars/projects/menus/zentao_menu_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f0bce6b7ea57fb310e4e94b67efde3cdb8e9d155
--- /dev/null
+++ b/spec/lib/sidebars/projects/menus/zentao_menu_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sidebars::Projects::Menus::ZentaoMenu do
+ it_behaves_like 'ZenTao menu with CE version'
+end
diff --git a/spec/support/shared_examples/lib/sidebars/projects/menus/zentao_menu_shared_examples.rb b/spec/support/shared_examples/lib/sidebars/projects/menus/zentao_menu_shared_examples.rb
new file mode 100644
index 0000000000000000000000000000000000000000..d3fd28727b59170066d35c93958cd47d71d6c3ee
--- /dev/null
+++ b/spec/support/shared_examples/lib/sidebars/projects/menus/zentao_menu_shared_examples.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.shared_examples 'ZenTao menu with CE version' do
+ let(:project) { create(:project, has_external_issue_tracker: true) }
+ let(:user) { project.owner }
+ let(:context) { Sidebars::Projects::Context.new(current_user: user, container: project) }
+ let(:zentao_integration) { create(:zentao_integration, project: project) }
+
+ subject { described_class.new(context) }
+
+ describe '#render?' do
+ context 'when issues integration is disabled' do
+ before do
+ zentao_integration.update!(active: false)
+ end
+
+ it 'returns false' do
+ expect(subject.render?).to eq false
+ end
+ end
+
+ context 'when issues integration is enabled' do
+ before do
+ zentao_integration.update!(active: true)
+ end
+
+ it 'returns true' do
+ expect(subject.render?).to eq true
+ end
+
+ it 'renders menu link' do
+ expect(subject.link).to eq zentao_integration.url
+ end
+
+ it 'contains only open ZenTao item' do
+ expect(subject.renderable_items.map(&:item_id)).to match_array [:open_zentao]
+ end
+ end
+ end
+end