diff --git a/app/assets/images/logos/zentao.svg b/app/assets/images/logos/zentao.svg
new file mode 100644
index 0000000000000000000000000000000000000000..d2115b72aee056ed8b7f30ca0d370134979d850a
--- /dev/null
+++ b/app/assets/images/logos/zentao.svg
@@ -0,0 +1,14 @@
+
diff --git a/ee/app/assets/javascripts/integrations/zentao/issues_list/graphql/fragments/zentao_label.fragment.graphql b/ee/app/assets/javascripts/integrations/zentao/issues_list/graphql/fragments/zentao_label.fragment.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..3efbcc1f2b67d1ef7d187321412c321b09a209bf
--- /dev/null
+++ b/ee/app/assets/javascripts/integrations/zentao/issues_list/graphql/fragments/zentao_label.fragment.graphql
@@ -0,0 +1,6 @@
+fragment ZentaoLabel on Label {
+ title
+ name
+ color
+ textColor
+}
diff --git a/ee/app/assets/javascripts/integrations/zentao/issues_list/graphql/fragments/zentao_user.fragment.graphql b/ee/app/assets/javascripts/integrations/zentao/issues_list/graphql/fragments/zentao_user.fragment.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..873e5b15c564f5209730249d74154d053be539c7
--- /dev/null
+++ b/ee/app/assets/javascripts/integrations/zentao/issues_list/graphql/fragments/zentao_user.fragment.graphql
@@ -0,0 +1,5 @@
+fragment ZentaoUser on UserCore {
+ avatarUrl
+ name
+ webUrl
+}
diff --git a/ee/app/assets/javascripts/integrations/zentao/issues_list/graphql/queries/get_zentao_issues.query.graphql b/ee/app/assets/javascripts/integrations/zentao/issues_list/graphql/queries/get_zentao_issues.query.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..af9ff4a33c473064ee864c43bad0a37496bad2c6
--- /dev/null
+++ b/ee/app/assets/javascripts/integrations/zentao/issues_list/graphql/queries/get_zentao_issues.query.graphql
@@ -0,0 +1,46 @@
+#import "../fragments/zentao_label.fragment.graphql"
+#import "../fragments/zentao_user.fragment.graphql"
+
+query externalIssues(
+ $issuesFetchPath: String
+ $search: String
+ $labels: String
+ $sort: String
+ $state: String
+ $page: Integer
+) {
+ externalIssues(
+ issuesFetchPath: $issuesFetchPath
+ search: $search
+ labels: $labels
+ sort: $sort
+ state: $state
+ page: $page
+ ) @client {
+ errors
+ pageInfo {
+ total
+ page
+ }
+ nodes {
+ id
+ projectId
+ createdAt
+ updatedAt
+ closedAt
+ title
+ webUrl
+ gitlabWebUrl
+ status
+ labels {
+ ...ZentaoLabel
+ }
+ assignees {
+ ...ZentaoUser
+ }
+ author {
+ ...ZentaoUser
+ }
+ }
+ }
+}
diff --git a/ee/app/assets/javascripts/integrations/zentao/issues_list/graphql/resolvers/zentao_issues.js b/ee/app/assets/javascripts/integrations/zentao/issues_list/graphql/resolvers/zentao_issues.js
new file mode 100644
index 0000000000000000000000000000000000000000..d770d11e0ad9b653887692598b97d61de66db8ab
--- /dev/null
+++ b/ee/app/assets/javascripts/integrations/zentao/issues_list/graphql/resolvers/zentao_issues.js
@@ -0,0 +1,83 @@
+import { DEFAULT_PAGE_SIZE } from '~/issuable_list/constants';
+import { i18n } from '~/issues_list/constants';
+import axios from '~/lib/utils/axios_utils';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+
+const transformZentaoIssueAssignees = (zentaoIssue) => {
+ return zentaoIssue.assignees.map((assignee) => ({
+ __typename: 'UserCore',
+ ...assignee,
+ }));
+};
+
+const transformZentaoIssueAuthor = (zentaoIssue, authorId) => {
+ return {
+ __typename: 'UserCore',
+ ...zentaoIssue.author,
+ id: authorId,
+ };
+};
+
+const transformZentaoIssueLabels = (zentaoIssue) => {
+ return zentaoIssue.labels.map((label) => ({
+ __typename: 'Label', // eslint-disable-line @gitlab/require-i18n-strings
+ ...label,
+ }));
+};
+
+const transformZentaoIssuePageInfo = (responseHeaders = {}) => {
+ return {
+ __typename: 'ZentaoIssuesPageInfo',
+ page: parseInt(responseHeaders['x-page'], 10) ?? 1,
+ total: parseInt(responseHeaders['x-total'], 10) ?? 0,
+ };
+};
+
+export const transformZentaoIssuesREST = (response) => {
+ const { headers, data: zentaoIssues } = response;
+
+ return {
+ __typename: 'ZentaoIssues',
+ errors: [],
+ pageInfo: transformZentaoIssuePageInfo(headers),
+ nodes: zentaoIssues.map((rawIssue, index) => {
+ const zentaoIssue = convertObjectPropsToCamelCase(rawIssue, { deep: true });
+ return {
+ __typename: 'ZentaoIssue',
+ ...zentaoIssue,
+ id: rawIssue.id,
+ author: transformZentaoIssueAuthor(zentaoIssue, index),
+ labels: transformZentaoIssueLabels(zentaoIssue),
+ assignees: transformZentaoIssueAssignees(zentaoIssue),
+ };
+ }),
+ };
+};
+
+export default function zentaoIssuesResolver(
+ _,
+ { issuesFetchPath, search, page, state, sort, labels },
+) {
+ return axios
+ .get(issuesFetchPath, {
+ params: {
+ limit: DEFAULT_PAGE_SIZE,
+ page,
+ state,
+ sort,
+ labels,
+ search,
+ },
+ })
+ .then((res) => {
+ return transformZentaoIssuesREST(res);
+ })
+ .catch((error) => {
+ return {
+ __typename: 'ZentaoIssues',
+ errors: error?.response?.data?.errors || [i18n.errorFetchingIssues],
+ pageInfo: transformZentaoIssuePageInfo(),
+ nodes: [],
+ };
+ });
+}
diff --git a/ee/app/assets/javascripts/integrations/zentao/issues_list/zentao_issues_list_bundle.js b/ee/app/assets/javascripts/integrations/zentao/issues_list/zentao_issues_list_bundle.js
new file mode 100644
index 0000000000000000000000000000000000000000..1c12e95c1612d708c6cc284f300c92b42b0babd5
--- /dev/null
+++ b/ee/app/assets/javascripts/integrations/zentao/issues_list/zentao_issues_list_bundle.js
@@ -0,0 +1,23 @@
+import externalIssuesListFactory from 'ee/external_issues_list';
+import zentaoLogo from 'images/logos/zentao.svg';
+import { s__ } from '~/locale';
+import getIssuesQuery from './graphql/queries/get_zentao_issues.query.graphql';
+import zentaoIssues from './graphql/resolvers/zentao_issues';
+
+export default externalIssuesListFactory({
+ query: zentaoIssues,
+ provides: {
+ getIssuesQuery,
+ externalIssuesLogo: zentaoLogo,
+ // This like below is passed to
+ // So we don't translate it since this should be a proper noun
+ externalIssueTrackerName: 'ZenTao',
+ searchInputPlaceholderText: s__('Integrations|Search ZenTao issues'),
+ recentSearchesStorageKey: 'zentao_issues',
+ createNewIssueText: s__('Integrations|Create new issue in ZenTao'),
+ logoContainerClass: 'logo-container',
+ emptyStateNoIssueText: s__(
+ 'Integrations|ZenTao issues display here when you create issues in your project in ZenTao.',
+ ),
+ },
+});
diff --git a/ee/app/assets/javascripts/pages/projects/integrations/zentao/issues/index/index.js b/ee/app/assets/javascripts/pages/projects/integrations/zentao/issues/index/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..0ce4618e0bf9f7880e87e49c15c2d3b84b5e2e46
--- /dev/null
+++ b/ee/app/assets/javascripts/pages/projects/integrations/zentao/issues/index/index.js
@@ -0,0 +1,3 @@
+import initZentaoIssuesList from 'ee/integrations/zentao/issues_list/zentao_issues_list_bundle';
+
+initZentaoIssuesList({ mountPointSelector: '.js-zentao-issues-list' });
diff --git a/ee/spec/frontend/integrations/zentao/issues_list/mock_data.js b/ee/spec/frontend/integrations/zentao/issues_list/mock_data.js
new file mode 100644
index 0000000000000000000000000000000000000000..d704178ada37e2941009d22fb934116803f6a062
--- /dev/null
+++ b/ee/spec/frontend/integrations/zentao/issues_list/mock_data.js
@@ -0,0 +1,71 @@
+export const mockZentaoIssue1 = {
+ project_id: 1,
+ id: 1,
+ title: 'Eius fuga voluptates.',
+ created_at: '2020-03-19T14:31:51.281Z',
+ updated_at: '2020-10-20T07:01:45.865Z',
+ closed_at: null,
+ status: 'Selected for Development',
+ labels: [
+ {
+ title: 'backend',
+ name: 'backend',
+ color: '#0052CC',
+ text_color: '#FFFFFF',
+ },
+ ],
+ author: {
+ name: 'jhope',
+ web_url: 'https://gitlab-zentao.atlassian.net/people/5e32f803e127810e82875bc1',
+ avatar_url: null,
+ },
+ assignees: [
+ {
+ name: 'Kushal Pandya',
+ web_url: 'https://gitlab-zentao.atlassian.net/people/1920938475',
+ avatar_url: null,
+ },
+ ],
+ web_url: 'https://gitlab-zentao.atlassian.net/browse/IG-31596',
+ gitlab_web_url: '',
+};
+
+export const mockZentaoIssue2 = {
+ project_id: 1,
+ id: 2,
+ title: 'Hic sit sint ducimus ea et sint.',
+ created_at: '2020-03-19T14:31:50.677Z',
+ updated_at: '2020-03-19T14:31:50.677Z',
+ closed_at: null,
+ status: 'Backlog',
+ labels: [],
+ author: {
+ name: 'Gabe Weaver',
+ web_url: 'https://gitlab-zentao.atlassian.net/people/5e320a31fe03e20c9d1dccde',
+ avatar_url: null,
+ },
+ assignees: [],
+ web_url: 'https://gitlab-zentao.atlassian.net/browse/IG-31595',
+ gitlab_web_url: '',
+};
+
+export const mockZentaoIssue3 = {
+ project_id: 1,
+ id: 3,
+ title: 'Alias ut modi est labore.',
+ created_at: '2020-03-19T14:31:50.012Z',
+ updated_at: '2020-03-19T14:31:50.012Z',
+ closed_at: null,
+ status: 'Backlog',
+ labels: [],
+ author: {
+ name: 'Gabe Weaver',
+ web_url: 'https://gitlab-zentao.atlassian.net/people/5e320a31fe03e20c9d1dccde',
+ avatar_url: null,
+ },
+ assignees: [],
+ web_url: 'https://gitlab-zentao.atlassian.net/browse/IG-31594',
+ gitlab_web_url: '',
+};
+
+export const mockZentaoIssues = [mockZentaoIssue1, mockZentaoIssue2, mockZentaoIssue3];
diff --git a/ee/spec/frontend/integrations/zentao/issues_list/resolvers/zentao_issues_spec.js b/ee/spec/frontend/integrations/zentao/issues_list/resolvers/zentao_issues_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..694c84725913c76aea58df3517e29cb90b3541cc
--- /dev/null
+++ b/ee/spec/frontend/integrations/zentao/issues_list/resolvers/zentao_issues_spec.js
@@ -0,0 +1,163 @@
+import MockAdapter from 'axios-mock-adapter';
+import createApolloProvider from 'ee/external_issues_list/graphql';
+import getZentaoIssues from 'ee/integrations/zentao/issues_list/graphql/queries/get_zentao_issues.query.graphql';
+import zentaoIssuesResolver from 'ee/integrations/zentao/issues_list/graphql/resolvers/zentao_issues';
+import { DEFAULT_PAGE_SIZE } from '~/issuable_list/constants';
+import { i18n } from '~/issues_list/constants';
+import axios from '~/lib/utils/axios_utils';
+import { mockZentaoIssues } from '../mock_data';
+
+const DEFAULT_ISSUES_FETCH_PATH = '/test/issues/fetch';
+const DEFAULT_VARIABLES = {
+ issuesFetchPath: DEFAULT_ISSUES_FETCH_PATH,
+ search: '',
+ labels: '',
+ sort: '',
+ state: '',
+ page: 1,
+};
+
+const TEST_ERROR_RESPONSE = { errors: ['lorem ipsum'] };
+const TEST_PAGE_HEADERS = {
+ 'x-page': '10',
+ 'x-total': '13',
+};
+
+const TYPE_ZENTAO_ISSUES = 'ZentaoIssues';
+
+describe('ee/integrations/zentao/issues_list/graphql/resolvers/zentao_issues', () => {
+ let mock;
+ let apolloClient;
+ let issuesApiSpy;
+
+ const createPageInfo = ({ page, total }) => ({
+ __typename: 'ZentaoIssuesPageInfo',
+ page,
+ total,
+ });
+
+ const createUserCore = ({ avatar_url, web_url, ...props }) => ({
+ __typename: 'UserCore',
+ avatarUrl: avatar_url,
+ webUrl: web_url,
+ ...props,
+ });
+
+ const createLabel = ({ text_color, ...props }) => ({
+ __typename: 'Label',
+ textColor: text_color,
+ ...props,
+ });
+
+ const createZentaoIssue = ({
+ assignees,
+ author,
+ labels,
+ closed_at,
+ created_at,
+ gitlab_web_url,
+ updated_at,
+ web_url,
+ project_id,
+ ...props
+ }) => ({
+ __typename: 'ZentaoIssue',
+ assignees: assignees.map(createUserCore),
+ author: createUserCore(author),
+ labels: labels.map(createLabel),
+ closedAt: closed_at,
+ createdAt: created_at,
+ gitlabWebUrl: gitlab_web_url,
+ updatedAt: updated_at,
+ webUrl: web_url,
+ projectId: project_id,
+ ...props,
+ });
+
+ const query = (variables = {}) =>
+ apolloClient.query({
+ variables: {
+ ...DEFAULT_VARIABLES,
+ ...variables,
+ },
+ query: getZentaoIssues,
+ });
+
+ beforeEach(() => {
+ issuesApiSpy = jest.fn();
+
+ mock = new MockAdapter(axios);
+ mock.onGet(DEFAULT_ISSUES_FETCH_PATH).reply((...args) => issuesApiSpy(...args));
+
+ ({ defaultClient: apolloClient } = createApolloProvider(zentaoIssuesResolver));
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe.each`
+ desc | errorResponse | expectedErrors
+ ${'when api request fails with data.errors'} | ${TEST_ERROR_RESPONSE} | ${TEST_ERROR_RESPONSE.errors}
+ ${'when api request fails with unknown erorr'} | ${{}} | ${[i18n.errorFetchingIssues]}
+ `('$desc', ({ errorResponse, expectedErrors }) => {
+ beforeEach(() => {
+ issuesApiSpy.mockReturnValue([400, errorResponse]);
+ });
+
+ it('returns error data', async () => {
+ const response = await query();
+
+ expect(response.data).toEqual({
+ externalIssues: {
+ __typename: TYPE_ZENTAO_ISSUES,
+ errors: expectedErrors,
+ pageInfo: createPageInfo({ page: Number.NaN, total: Number.NaN }),
+ nodes: [],
+ },
+ });
+ });
+ });
+
+ describe('with successful api request', () => {
+ beforeEach(() => {
+ issuesApiSpy.mockReturnValue([200, mockZentaoIssues, TEST_PAGE_HEADERS]);
+ });
+
+ it('sends expected params', async () => {
+ const variables = {
+ search: 'test search',
+ page: 5,
+ state: 'test state',
+ sort: 'test sort',
+ labels: 'test labels',
+ };
+
+ expect(issuesApiSpy).not.toHaveBeenCalled();
+
+ await query(variables);
+
+ expect(issuesApiSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ params: {
+ limit: DEFAULT_PAGE_SIZE,
+ ...variables,
+ },
+ }),
+ );
+ });
+
+ it('returns transformed data', async () => {
+ const response = await query();
+
+ expect(response.data).toEqual({
+ externalIssues: {
+ __typename: TYPE_ZENTAO_ISSUES,
+ errors: [],
+ pageInfo: createPageInfo({ page: 10, total: 13 }),
+ nodes: mockZentaoIssues.map(createZentaoIssue),
+ },
+ });
+ });
+ });
+});
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index f6c91b6df33ab6835000f9f61e740e577dd53769..50c61497e6f3f2ae9d939aa032b0c2869fa2de05 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -18209,6 +18209,9 @@ msgstr ""
msgid "Integrations|Create new issue in Jira"
msgstr ""
+msgid "Integrations|Create new issue in ZenTao"
+msgstr ""
+
msgid "Integrations|Default settings are inherited from the group level."
msgstr ""
@@ -18302,6 +18305,9 @@ msgstr ""
msgid "Integrations|Search Jira issues"
msgstr ""
+msgid "Integrations|Search ZenTao issues"
+msgstr ""
+
msgid "Integrations|Send notifications about project events to Unify Circuit."
msgstr ""
@@ -18347,6 +18353,9 @@ msgstr ""
msgid "Integrations|You've activated every integration 🎉"
msgstr ""
+msgid "Integrations|ZenTao issues display here when you create issues in your project in ZenTao."
+msgstr ""
+
msgid "Interactive mode"
msgstr ""