Commit cea9f01e authored by Luke Duncalfe's avatar Luke Duncalfe

Merge branch 'feature/integrate_zentao/controller2' into 'master'

Feature(integrate zentao): part of Controllers

See merge request gitlab-org/gitlab!69602
parents 6be08f08 398fd4f0
...@@ -9,6 +9,7 @@ module Integrations ...@@ -9,6 +9,7 @@ module Integrations
:add_pusher, :add_pusher,
:alert_events, :alert_events,
:api_key, :api_key,
:api_token,
:api_url, :api_url,
:bamboo_url, :bamboo_url,
:branches_to_be_notified, :branches_to_be_notified,
...@@ -74,7 +75,8 @@ module Integrations ...@@ -74,7 +75,8 @@ module Integrations
:url, :url,
:user_key, :user_key,
:username, :username,
:webhook :webhook,
:zentao_product_xid
].freeze ].freeze
def integration_params def integration_params
......
...@@ -132,6 +132,20 @@ module IntegrationsHelper ...@@ -132,6 +132,20 @@ module IntegrationsHelper
end end
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 extend self
private private
......
...@@ -14,7 +14,7 @@ class Integration < ApplicationRecord ...@@ -14,7 +14,7 @@ class Integration < ApplicationRecord
asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker datadog discord 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 drone_ci emails_on_push ewm external_wiki flowdock hangouts_chat irker jira
mattermost mattermost_slash_commands microsoft_teams packagist pipelines_email 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 ].freeze
PROJECT_SPECIFIC_INTEGRATION_NAMES = %w[ PROJECT_SPECIFIC_INTEGRATION_NAMES = %w[
......
...@@ -9,16 +9,25 @@ module Integrations ...@@ -9,16 +9,25 @@ module Integrations
validates :api_token, presence: true, if: :activated? validates :api_token, presence: true, if: :activated?
validates :zentao_product_xid, 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 def data_fields
zentao_tracker_data || self.build_zentao_tracker_data zentao_tracker_data || self.build_zentao_tracker_data
end end
def title def title
self.class.name.demodulize 'ZenTao'
end end
def description def description
s_("ZentaoIntegration|Use Zentao as this project's issue tracker.") s_("ZentaoIntegration|Use ZenTao as this project's issue tracker.")
end end
def self.to_param def self.to_param
...@@ -42,28 +51,28 @@ module Integrations ...@@ -42,28 +51,28 @@ module Integrations
{ {
type: 'text', type: 'text',
name: 'url', name: 'url',
title: s_('ZentaoIntegration|Zentao Web URL'), title: s_('ZentaoIntegration|ZenTao Web URL'),
placeholder: 'https://www.zentao.net', 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 required: true
}, },
{ {
type: 'text', type: 'text',
name: 'api_url', 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.') help: s_('ZentaoIntegration|If different from Web URL.')
}, },
{ {
type: 'password', type: 'password',
name: 'api_token', name: 'api_token',
title: s_('ZentaoIntegration|Zentao API token'), title: s_('ZentaoIntegration|ZenTao API token'),
non_empty_password_title: s_('ZentaoIntegration|Enter API token'), non_empty_password_title: s_('ZentaoIntegration|Enter API token'),
required: true required: true
}, },
{ {
type: 'text', type: 'text',
name: 'zentao_product_xid', name: 'zentao_product_xid',
title: s_('ZentaoIntegration|Zentao Product ID'), title: s_('ZentaoIntegration|ZenTao Product ID'),
required: true required: true
} }
] ]
......
...@@ -1453,7 +1453,10 @@ class Project < ApplicationRecord ...@@ -1453,7 +1453,10 @@ class Project < ApplicationRecord
end end
def disabled_integrations def disabled_integrations
[:zentao] disabled_integrations = []
disabled_integrations << :zentao unless ::Integrations::Zentao.feature_flag_enabled?(self)
disabled_integrations
end end
def find_or_initialize_integration(name) def find_or_initialize_integration(name)
......
---
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
---
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
---
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
---
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
---
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
---
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
...@@ -16578,6 +16578,7 @@ State of a Sentry error. ...@@ -16578,6 +16578,7 @@ State of a Sentry error.
| <a id="servicetypeunify_circuit_service"></a>`UNIFY_CIRCUIT_SERVICE` | UnifyCircuitService type. | | <a id="servicetypeunify_circuit_service"></a>`UNIFY_CIRCUIT_SERVICE` | UnifyCircuitService type. |
| <a id="servicetypewebex_teams_service"></a>`WEBEX_TEAMS_SERVICE` | WebexTeamsService type. | | <a id="servicetypewebex_teams_service"></a>`WEBEX_TEAMS_SERVICE` | WebexTeamsService type. |
| <a id="servicetypeyoutrack_service"></a>`YOUTRACK_SERVICE` | YoutrackService type. | | <a id="servicetypeyoutrack_service"></a>`YOUTRACK_SERVICE` | YoutrackService type. |
| <a id="servicetypezentao_service"></a>`ZENTAO_SERVICE` | ZentaoService type. |
### `SharedRunnersSetting` ### `SharedRunnersSetting`
......
# 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
...@@ -404,10 +404,6 @@ module EE ...@@ -404,10 +404,6 @@ module EE
feature_available?(:jira_issues_integration) feature_available?(:jira_issues_integration)
end end
def zentao_issues_integration_available?
feature_available?(:zentao_issues_integration)
end
def multiple_approval_rules_available? def multiple_approval_rules_available?
feature_available?(:multiple_approval_rules) feature_available?(:multiple_approval_rules)
end end
......
# 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('<br>')
sanitize(content)
end
end
end
end
# frozen_string_literal: true
module Integrations
module ZentaoSerializers
class IssueDetailSerializer < BaseSerializer
entity ::Integrations::ZentaoSerializers::IssueDetailEntity
end
end
end
# 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
# frozen_string_literal: true
module Integrations
module ZentaoSerializers
class IssueSerializer < BaseSerializer
include WithPagination
entity ::Integrations::ZentaoSerializers::IssueEntity
end
end
end
- 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') } }
- 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 }
...@@ -119,6 +119,10 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do ...@@ -119,6 +119,10 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
end end
end end
end end
namespace :zentao do
resources :issues, only: [:index, :show]
end
end end
# Added for backward compatibility with https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39543 # Added for backward compatibility with https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39543
......
# 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
# 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) { "<script>alert('XSS')</script>" }
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
# 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
...@@ -768,7 +768,33 @@ module API ...@@ -768,7 +768,33 @@ module API
desc: 'The Webex Teams webhook. For example, https://api.ciscospark.com/v1/webhooks/incoming/...' desc: 'The Webex Teams webhook. For example, https://api.ciscospark.com/v1/webhooks/incoming/...'
}, },
chat_notification_events 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 end
...@@ -805,7 +831,8 @@ module API ...@@ -805,7 +831,8 @@ module API
::Integrations::Slack, ::Integrations::Slack,
::Integrations::SlackSlashCommands, ::Integrations::SlackSlashCommands,
::Integrations::Teamcity, ::Integrations::Teamcity,
::Integrations::Youtrack ::Integrations::Youtrack,
::Integrations::Zentao
] ]
end end
......
...@@ -15,10 +15,8 @@ module Gitlab ...@@ -15,10 +15,8 @@ module Gitlab
end end
def ping def ping
response = fetch_product(zentao_product_xid) response = fetch_product(zentao_product_xid) rescue {}
active = response.fetch('deleted') == '0' rescue false active = response.fetch('deleted') == '0' rescue false
if active if active
{ success: true } { success: true }
else else
...@@ -31,25 +29,30 @@ module Gitlab ...@@ -31,25 +29,30 @@ module Gitlab
end end
def fetch_issues(params = {}) def fetch_issues(params = {})
get("products/#{zentao_product_xid}/issues", get("products/#{zentao_product_xid}/issues", params)
params.reverse_merge(page: 1, limit: 20))
end end
def fetch_issue(issue_id) def fetch_issue(issue_id)
raise Gitlab::Zentao::Client::Error unless issue_id_pattern.match(issue_id)
get("issues/#{issue_id}") get("issues/#{issue_id}")
end end
private private
def issue_id_pattern
/\A\S+-\d+\z/
end
def get(path, params = {}) def get(path, params = {})
options = { headers: headers, query: params } options = { headers: headers, query: params }
response = Gitlab::HTTP.get(url(path), options) response = Gitlab::HTTP.get(url(path), options)
return {} unless response.success? raise Gitlab::Zentao::Client::Error unless response.success?
Gitlab::Json.parse(response.body) Gitlab::Json.parse(response.body)
rescue JSON::ParserError rescue JSON::ParserError
{} raise Gitlab::Zentao::Client::Error
end end
def url(path) def url(path)
......
# 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
# 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
...@@ -23,6 +23,7 @@ module Sidebars ...@@ -23,6 +23,7 @@ module Sidebars
add_menu(Sidebars::Projects::Menus::RepositoryMenu.new(context)) add_menu(Sidebars::Projects::Menus::RepositoryMenu.new(context))
add_menu(Sidebars::Projects::Menus::IssuesMenu.new(context)) add_menu(Sidebars::Projects::Menus::IssuesMenu.new(context))
add_menu(Sidebars::Projects::Menus::ExternalIssueTrackerMenu.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::MergeRequestsMenu.new(context))
add_menu(Sidebars::Projects::Menus::CiCdMenu.new(context)) add_menu(Sidebars::Projects::Menus::CiCdMenu.new(context))
add_menu(Sidebars::Projects::Menus::SecurityComplianceMenu.new(context)) add_menu(Sidebars::Projects::Menus::SecurityComplianceMenu.new(context))
......
...@@ -39678,7 +39678,13 @@ msgstr "" ...@@ -39678,7 +39678,13 @@ msgstr ""
msgid "ZenTaoIntegration|ZenTao user" msgid "ZenTaoIntegration|ZenTao user"
msgstr "" 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 "" msgstr ""
msgid "ZentaoIntegration|Enter API token" msgid "ZentaoIntegration|Enter API token"
...@@ -39687,19 +39693,31 @@ msgstr "" ...@@ -39687,19 +39693,31 @@ msgstr ""
msgid "ZentaoIntegration|If different from Web URL." msgid "ZentaoIntegration|If different from Web URL."
msgstr "" 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 "" msgstr ""
msgid "ZentaoIntegration|Zentao API URL (optional)" msgid "ZentaoIntegration|ZenTao Product ID"
msgstr "" msgstr ""
msgid "ZentaoIntegration|Zentao API token" msgid "ZentaoIntegration|ZenTao Web URL"
msgstr "" msgstr ""
msgid "ZentaoIntegration|Zentao Product ID" msgid "ZentaoIntegration|ZenTao issues"
msgstr "" msgstr ""
msgid "ZentaoIntegration|Zentao Web URL" msgid "ZentaoIntegration|Zentao issues"
msgstr "" msgstr ""
msgid "Zoom meeting added" msgid "Zoom meeting added"
......
...@@ -6,7 +6,23 @@ RSpec.describe Gitlab::Zentao::Client do ...@@ -6,7 +6,23 @@ RSpec.describe Gitlab::Zentao::Client do
subject(:integration) { described_class.new(zentao_integration) } subject(:integration) { described_class.new(zentao_integration) }
let(:zentao_integration) { create(: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 describe '#new' do
context 'if integration is nil' do context 'if integration is nil' do
...@@ -25,15 +41,6 @@ RSpec.describe Gitlab::Zentao::Client do ...@@ -25,15 +41,6 @@ RSpec.describe Gitlab::Zentao::Client do
end end
describe '#fetch_product' do describe '#fetch_product' do
let(:mock_headers) do
{
headers: {
'Content-Type' => 'application/json',
'Token' => zentao_integration.api_token
}
}
end
context 'with valid product' do context 'with valid product' do
let(:mock_response) { { 'id' => zentao_integration.zentao_product_xid } } let(:mock_response) { { 'id' => zentao_integration.zentao_product_xid } }
...@@ -54,7 +61,9 @@ RSpec.describe Gitlab::Zentao::Client do ...@@ -54,7 +61,9 @@ RSpec.describe Gitlab::Zentao::Client do
end end
it 'fetches the empty product' do 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 end
...@@ -65,21 +74,14 @@ RSpec.describe Gitlab::Zentao::Client do ...@@ -65,21 +74,14 @@ RSpec.describe Gitlab::Zentao::Client do
end end
it 'fetches the empty product' do 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 end
end end
describe '#ping' do describe '#ping' do
let(:mock_headers) do
{
headers: {
'Content-Type' => 'application/json',
'Token' => zentao_integration.api_token
}
}
end
context 'with valid resource' do context 'with valid resource' do
before do before do
WebMock.stub_request(:get, mock_get_products_url) WebMock.stub_request(:get, mock_get_products_url)
...@@ -102,4 +104,29 @@ RSpec.describe Gitlab::Zentao::Client do ...@@ -102,4 +104,29 @@ RSpec.describe Gitlab::Zentao::Client do
end end
end 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 end
# 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
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Sidebars::Projects::Menus::ZentaoMenu do
it_behaves_like 'ZenTao menu with CE version'
end
# 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
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment