Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--.gitlab/ci/rails.gitlab-ci.yml8
-rw-r--r--.gitlab/ci/rules.gitlab-ci.yml11
-rw-r--r--.gitlab/ci/setup.gitlab-ci.yml31
-rw-r--r--.haml-lint_todo.yml4
-rw-r--r--GITALY_SERVER_VERSION2
-rw-r--r--app/assets/javascripts/integrations/edit/components/override_dropdown.vue14
-rw-r--r--app/assets/javascripts/integrations/edit/constants.js17
-rw-r--r--app/assets/javascripts/integrations/edit/index.js2
-rw-r--r--app/assets/javascripts/jira_connect.js56
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js35
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue27
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_summary.vue14
-rw-r--r--app/assets/stylesheets/page_bundles/jira_connect.scss33
-rw-r--r--app/controllers/groups/settings/integrations_controller.rb6
-rw-r--r--app/controllers/jira_connect/app_descriptor_controller.rb64
-rw-r--r--app/controllers/jira_connect/application_controller.rb57
-rw-r--r--app/controllers/jira_connect/events_controller.rb30
-rw-r--r--app/controllers/jira_connect/subscriptions_controller.rb56
-rw-r--r--app/controllers/oauth/jira/authorizations_controller.rb48
-rw-r--r--app/controllers/passwords_controller.rb6
-rw-r--r--app/helpers/services_helper.rb15
-rw-r--r--app/models/ci/job_artifact.rb1
-rw-r--r--app/models/concerns/ci/artifactable.rb2
-rw-r--r--app/models/jira_connect_installation.rb22
-rw-r--r--app/models/jira_connect_subscription.rb12
-rw-r--r--app/models/project.rb14
-rw-r--r--app/models/project_feature_usage.rb31
-rw-r--r--app/policies/group_policy.rb1
-rw-r--r--app/policies/namespace_policy.rb1
-rw-r--r--app/services/ci/destroy_expired_job_artifacts_service.rb10
-rw-r--r--app/services/git/branch_hooks_service.rb12
-rw-r--r--app/services/jira_connect/sync_service.rb43
-rw-r--r--app/services/jira_connect_subscriptions/base_service.rb11
-rw-r--r--app/services/jira_connect_subscriptions/create_service.rb33
-rw-r--r--app/services/merge_requests/base_service.rb10
-rw-r--r--app/views/jira_connect/subscriptions/index.html.haml28
-rw-r--r--app/views/layouts/jira_connect.html.haml13
-rw-r--r--app/workers/all_queues.yml16
-rw-r--r--app/workers/jira_connect/sync_branch_worker.rb22
-rw-r--r--app/workers/jira_connect/sync_merge_request_worker.rb18
-rw-r--r--changelogs/unreleased/231238-adjust-badge-key-limits.yml5
-rw-r--r--changelogs/unreleased/241818-simplify-password-reset-flow-for-a-user-whose-password-has-been-ch.yml5
-rw-r--r--changelogs/unreleased/bw-surround-text-wth-char.yml5
-rw-r--r--changelogs/unreleased/mo-add-destroy-artifact-service.yml5
-rw-r--r--changelogs/unreleased/move-jira-dvcs-and-connect-app-to-core.yml5
-rw-r--r--changelogs/unreleased/swimlane_user_setting.yml5
-rw-r--r--config/application.rb10
-rw-r--r--config/initializers/0_inject_feature_flags.rb1
-rw-r--r--config/initializers/remove_active_job_execute_callback.rb27
-rw-r--r--config/routes.rb11
-rw-r--r--config/routes/jira_connect.rb15
-rw-r--r--config/routes/project.rb34
-rw-r--r--db/migrate/20200825081025_boards_epic_user_preferences.rb23
-rw-r--r--db/migrate/20200825081035_boards_epic_user_preferences_fk_board.rb19
-rw-r--r--db/migrate/20200825081045_boards_epic_user_preferences_fk_user.rb19
-rw-r--r--db/migrate/20200825081055_boards_epic_user_preferences_fk_epic.rb19
-rw-r--r--db/migrate/20200827150057_add_index_expire_at_to_pipeline_artifacts.rb18
-rw-r--r--db/schema_migrations/202008250810251
-rw-r--r--db/schema_migrations/202008250810351
-rw-r--r--db/schema_migrations/202008250810451
-rw-r--r--db/schema_migrations/202008250810551
-rw-r--r--db/schema_migrations/202008271500571
-rw-r--r--db/structure.sql41
-rw-r--r--doc/api/graphql/reference/gitlab_schema.graphql14
-rw-r--r--doc/api/graphql/reference/gitlab_schema.json40
-rw-r--r--doc/api/graphql/reference/index.md6
-rw-r--r--doc/api/groups.md69
-rw-r--r--doc/integration/jira_development_panel.md14
-rw-r--r--doc/user/project/integrations/jira_integrations.md2
-rw-r--r--lib/api/api.rb10
-rw-r--r--lib/api/github/entities.rb217
-rw-r--r--lib/api/v3/github.rb232
-rw-r--r--lib/atlassian/jira_connect.rb21
-rw-r--r--lib/atlassian/jira_connect/client.rb47
-rw-r--r--lib/atlassian/jira_connect/serializers/author_entity.rb24
-rw-r--r--lib/atlassian/jira_connect/serializers/base_entity.rb22
-rw-r--r--lib/atlassian/jira_connect/serializers/branch_entity.rb32
-rw-r--r--lib/atlassian/jira_connect/serializers/commit_entity.rb45
-rw-r--r--lib/atlassian/jira_connect/serializers/file_entity.rb38
-rw-r--r--lib/atlassian/jira_connect/serializers/pull_request_entity.rb42
-rw-r--r--lib/atlassian/jira_connect/serializers/repository_entity.rb29
-rw-r--r--lib/atlassian/jira_issue_key_extractor.rb17
-rw-r--r--lib/constraints/jira_encoded_url_constrainer.rb9
-rw-r--r--lib/feature.rb6
-rw-r--r--lib/feature/definition.rb27
-rw-r--r--lib/gitlab/background_migration/fix_pages_access_level.rb4
-rw-r--r--lib/gitlab/badge/coverage/template.rb4
-rw-r--r--lib/gitlab/badge/pipeline/template.rb4
-rw-r--r--lib/gitlab/badge/template.rb3
-rw-r--r--lib/gitlab/jira/dvcs.rb48
-rw-r--r--lib/gitlab/jira/middleware.rb23
-rw-r--r--lib/gitlab/middleware/same_site_cookies.rb2
-rw-r--r--lib/gitlab/usage_data.rb9
-rw-r--r--locale/gitlab.pot3
-rw-r--r--package.json2
-rw-r--r--rubocop/cop/migration/safer_boolean_column.rb2
-rw-r--r--rubocop/migration_helpers.rb7
-rw-r--r--spec/controllers/jira_connect/app_descriptor_controller_spec.rb23
-rw-r--r--spec/controllers/jira_connect/events_controller_spec.rb73
-rw-r--r--spec/controllers/jira_connect/subscriptions_controller_spec.rb113
-rw-r--r--spec/controllers/oauth/jira/authorizations_controller_spec.rb45
-rw-r--r--spec/controllers/passwords_controller_spec.rb57
-rw-r--r--spec/factories/diff_position.rb5
-rw-r--r--spec/factories/jira_connect_installation.rb9
-rw-r--r--spec/factories/jira_connect_subscription.rb8
-rw-r--r--spec/factories/project_feature_usage.rb15
-rw-r--r--spec/factories/projects.rb12
-rw-r--r--spec/features/issues/user_views_issue_spec.rb4
-rw-r--r--spec/features/jira_connect/subscriptions_spec.rb47
-rw-r--r--spec/features/jira_oauth_provider_authorize_spec.rb21
-rw-r--r--spec/features/projects/wiki/markdown_preview_spec.rb1
-rw-r--r--spec/features/task_lists_spec.rb12
-rw-r--r--spec/fixtures/api/schemas/entities/github/branches.json16
-rw-r--r--spec/fixtures/api/schemas/entities/github/commit.json61
-rw-r--r--spec/fixtures/api/schemas/entities/github/pull_request.json108
-rw-r--r--spec/fixtures/api/schemas/entities/github/pull_requests.json6
-rw-r--r--spec/fixtures/api/schemas/entities/github/repositories.json16
-rw-r--r--spec/fixtures/api/schemas/entities/github/repository.json16
-rw-r--r--spec/fixtures/api/schemas/entities/github/user.json13
-rw-r--r--spec/fixtures/api/schemas/jira_connect/author.json12
-rw-r--r--spec/fixtures/api/schemas/jira_connect/branch.json19
-rw-r--r--spec/fixtures/api/schemas/jira_connect/commit.json29
-rw-r--r--spec/fixtures/api/schemas/jira_connect/file.json14
-rw-r--r--spec/fixtures/api/schemas/jira_connect/pull_request.json26
-rw-r--r--spec/fixtures/api/schemas/jira_connect/repository.json34
-rw-r--r--spec/frontend/integrations/edit/components/override_dropdown_spec.js50
-rw-r--r--spec/frontend/lib/utils/text_markdown_spec.js34
-rw-r--r--spec/helpers/services_helper_spec.rb3
-rw-r--r--spec/initializers/remove_active_job_execute_callback_spec.rb9
-rw-r--r--spec/lib/atlassian/jira_connect/client_spec.rb36
-rw-r--r--spec/lib/atlassian/jira_connect/serializers/author_entity_spec.rb23
-rw-r--r--spec/lib/atlassian/jira_connect/serializers/branch_entity_spec.rb14
-rw-r--r--spec/lib/atlassian/jira_connect/serializers/repository_entity_spec.rb21
-rw-r--r--spec/lib/atlassian/jira_issue_key_extractor_spec.rb37
-rw-r--r--spec/lib/constraints/jira_encoded_url_constrainer_spec.rb36
-rw-r--r--spec/lib/gitlab/badge/coverage/template_spec.rb4
-rw-r--r--spec/lib/gitlab/badge/pipeline/template_spec.rb4
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml2
-rw-r--r--spec/lib/gitlab/jira/dvcs_spec.rb58
-rw-r--r--spec/lib/gitlab/jira/middleware_spec.rb40
-rw-r--r--spec/lib/gitlab/middleware/same_site_cookies_spec.rb10
-rw-r--r--spec/lib/gitlab/usage_data_spec.rb12
-rw-r--r--spec/models/jira_connect_installation_spec.rb45
-rw-r--r--spec/models/jira_connect_subscription_spec.rb15
-rw-r--r--spec/models/project_feature_usage_spec.rb59
-rw-r--r--spec/models/project_spec.rb42
-rw-r--r--spec/policies/group_policy_spec.rb44
-rw-r--r--spec/policies/namespace_policy_spec.rb26
-rw-r--r--spec/requests/api/v3/github_spec.rb516
-rw-r--r--spec/requests/jira_authorizations_spec.rb76
-rw-r--r--spec/requests/jira_routing_spec.rb72
-rw-r--r--spec/rubocop/cop/migration/safer_boolean_column_spec.rb8
-rw-r--r--spec/services/ci/destroy_expired_job_artifacts_service_spec.rb20
-rw-r--r--spec/services/git/branch_push_service_spec.rb64
-rw-r--r--spec/services/jira_connect/sync_service_spec.rb62
-rw-r--r--spec/services/jira_connect_subscriptions/create_service_spec.rb48
-rw-r--r--spec/services/merge_requests/base_service_spec.rb58
-rw-r--r--spec/support/helpers/usage_data_helpers.rb2
-rw-r--r--spec/workers/jira_connect/sync_branch_worker_spec.rb65
-rw-r--r--spec/workers/jira_connect/sync_merge_request_worker_spec.rb30
-rw-r--r--yarn.lock8
162 files changed, 4525 insertions, 126 deletions
diff --git a/.gitignore b/.gitignore
index 46cb0ac6c23..2a07963eecc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -94,5 +94,6 @@ webpack-dev-server.json
.solargraph.yml
apollo.config.js
/tmp/matching_foss_tests.txt
+/tmp/matching_tests.txt
ee/changelogs/unreleased-ee
diff --git a/.gitlab/ci/rails.gitlab-ci.yml b/.gitlab/ci/rails.gitlab-ci.yml
index 6acd52382cf..057c2b3a510 100644
--- a/.gitlab/ci/rails.gitlab-ci.yml
+++ b/.gitlab/ci/rails.gitlab-ci.yml
@@ -493,19 +493,17 @@ rspec-ee system pg12 geo:
rspec foss-impact:
extends:
- .rspec-base-pg11-as-if-foss
- - .rails:rules:ee-mr-only
+ - .rails:rules:rspec-foss-impact
+ needs: ["setup-test-env", "retrieve-tests-metadata", "compile-test-assets as-if-foss", "detect-tests as-if-foss"]
script:
- - install_gitlab_gem
- - install_tff_gem
- run_timed_command "scripts/gitaly-test-build"
- run_timed_command "scripts/gitaly-test-spawn"
- source scripts/rspec_helpers.sh
- - tooling/bin/find_foss_tests tmp/matching_foss_tests.txt
- rspec_matched_foss_tests tmp/matching_foss_tests.txt "--tag ~quarantine"
artifacts:
expire_in: 7d
paths:
- - tmp/matching_foss_tests.txt
- tmp/capybara/
+
# EE: Canonical MR pipelines
##################################################
diff --git a/.gitlab/ci/rules.gitlab-ci.yml b/.gitlab/ci/rules.gitlab-ci.yml
index 16193f2b1b0..71cfa856032 100644
--- a/.gitlab/ci/rules.gitlab-ci.yml
+++ b/.gitlab/ci/rules.gitlab-ci.yml
@@ -555,7 +555,16 @@
- <<: *if-master-refs
changes: *code-backstage-patterns
-.rails:rules:ee-mr-only:
+.rails:rules:detect-tests:
+ rules:
+ - <<: *if-not-ee
+ when: never
+ - <<: *if-security-merge-request
+ changes: *code-backstage-patterns
+ - <<: *if-dot-com-gitlab-org-merge-request
+ changes: *code-backstage-patterns
+
+.rails:rules:rspec-foss-impact:
rules:
- <<: *if-not-ee
when: never
diff --git a/.gitlab/ci/setup.gitlab-ci.yml b/.gitlab/ci/setup.gitlab-ci.yml
index b2b64700b18..d42c83a6d0f 100644
--- a/.gitlab/ci/setup.gitlab-ci.yml
+++ b/.gitlab/ci/setup.gitlab-ci.yml
@@ -59,3 +59,34 @@ verify-tests-yml:
- source scripts/utils.sh
- install_tff_gem
- scripts/verify-tff-mapping
+
+.detect-test-base:
+ image: ruby:2.6-alpine
+ needs: []
+ stage: prepare
+ script:
+ - source scripts/utils.sh
+ - install_gitlab_gem
+ - install_tff_gem
+ - tooling/bin/find_foss_tests ${MATCHED_TESTS_FILE}
+ artifacts:
+ expire_in: 7d
+ paths:
+ - ${MATCHED_TESTS_FILE}
+
+detect-tests:
+ extends:
+ - .detect-test-base
+ - .rails:rules:detect-tests
+ variables:
+ MATCHED_TESTS_FILE: tmp/matching_tests.txt
+
+detect-tests as-if-foss:
+ extends:
+ - .detect-test-base
+ - .rails:rules:detect-tests
+ - .as-if-foss
+ variables:
+ MATCHED_TESTS_FILE: tmp/matching_foss_tests.txt
+ before_script:
+ - '[ "$FOSS_ONLY" = "1" ] && rm -rf ee/ qa/spec/ee/ qa/qa/specs/features/ee/ qa/qa/ee/ qa/qa/ee.rb'
diff --git a/.haml-lint_todo.yml b/.haml-lint_todo.yml
index 13edc1f024e..4163c7bacd1 100644
--- a/.haml-lint_todo.yml
+++ b/.haml-lint_todo.yml
@@ -116,10 +116,12 @@ linters:
- "app/views/import/bitbucket/status.html.haml"
- "app/views/import/bitbucket_server/status.html.haml"
- "app/views/invites/show.html.haml"
+ - "app/views/jira_connect/subscriptions/index.html.haml"
- "app/views/layouts/_mailer.html.haml"
- "app/views/layouts/experiment_mailer.html.haml"
- "app/views/layouts/header/_default.html.haml"
- "app/views/layouts/header/_new_dropdown.haml"
+ - "app/views/layouts/jira_connect.html.haml"
- "app/views/layouts/notify.html.haml"
- "app/views/notify/_failed_builds.html.haml"
- "app/views/notify/_reassigned_issuable_email.html.haml"
@@ -333,8 +335,6 @@ linters:
- "ee/app/views/groups/group_members/_sync_button.html.haml"
- "ee/app/views/groups/hooks/edit.html.haml"
- "ee/app/views/groups/ldap_group_links/index.html.haml"
- - "ee/app/views/jira_connect/subscriptions/index.html.haml"
- - "ee/app/views/layouts/jira_connect.html.haml"
- "ee/app/views/layouts/nav/ee/admin/_new_monitoring_sidebar.html.haml"
- "ee/app/views/layouts/service_desk.html.haml"
- "ee/app/views/ldap_group_links/_form.html.haml"
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION
index a0b7d19d05c..71ea80cf54f 100644
--- a/GITALY_SERVER_VERSION
+++ b/GITALY_SERVER_VERSION
@@ -1 +1 @@
-e9860f7988a2c87638abf695d8613e3096312857
+851da3925944b969da7f87057ba8da8274d5c18d
diff --git a/app/assets/javascripts/integrations/edit/components/override_dropdown.vue b/app/assets/javascripts/integrations/edit/components/override_dropdown.vue
index 3c00cf84d1a..ea6d1892dfc 100644
--- a/app/assets/javascripts/integrations/edit/components/override_dropdown.vue
+++ b/app/assets/javascripts/integrations/edit/components/override_dropdown.vue
@@ -1,6 +1,8 @@
<script>
+import { mapState } from 'vuex';
import { GlNewDropdown, GlNewDropdownItem, GlLink } from '@gitlab/ui';
import { s__ } from '~/locale';
+import { defaultIntegrationLevel, overrideDropdownDescriptions } from '../constants';
const dropdownOptions = [
{
@@ -41,6 +43,16 @@ export default {
selected: dropdownOptions.find(x => x.value === this.override),
};
},
+ computed: {
+ ...mapState(['adminState']),
+ description() {
+ const level = this.adminState.integrationLevel;
+
+ return (
+ overrideDropdownDescriptions[level] || overrideDropdownDescriptions[defaultIntegrationLevel]
+ );
+ },
+ },
methods: {
onClick(option) {
this.selected = option;
@@ -55,7 +67,7 @@ export default {
class="gl-display-flex gl-justify-content-space-between gl-align-items-baseline gl-py-4 gl-mt-5 gl-mb-6 gl-border-t-1 gl-border-t-solid gl-border-b-1 gl-border-b-solid gl-border-gray-100"
>
<span
- >{{ s__('Integrations|Default settings are inherited from the instance level.') }}
+ >{{ description }}
<gl-link v-if="learnMorePath" :href="learnMorePath" target="_blank">{{
__('Learn more')
}}</gl-link>
diff --git a/app/assets/javascripts/integrations/edit/constants.js b/app/assets/javascripts/integrations/edit/constants.js
new file mode 100644
index 00000000000..b74ae209eb7
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/constants.js
@@ -0,0 +1,17 @@
+import { s__ } from '~/locale';
+
+export const integrationLevels = {
+ GROUP: 'group',
+ INSTANCE: 'instance',
+};
+
+export const defaultIntegrationLevel = integrationLevels.INSTANCE;
+
+export const overrideDropdownDescriptions = {
+ [integrationLevels.GROUP]: s__(
+ 'Integrations|Default settings are inherited from the group level.',
+ ),
+ [integrationLevels.INSTANCE]: s__(
+ 'Integrations|Default settings are inherited from the instance level.',
+ ),
+};
diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js
index 204cdad4a76..915884dabef 100644
--- a/app/assets/javascripts/integrations/edit/index.js
+++ b/app/assets/javascripts/integrations/edit/index.js
@@ -23,6 +23,7 @@ function parseDatasetToProps(data) {
triggerEvents,
fields,
inheritFromId,
+ integrationLevel,
...booleanAttributes
} = data;
const {
@@ -56,6 +57,7 @@ function parseDatasetToProps(data) {
triggerEvents: JSON.parse(triggerEvents),
fields: JSON.parse(fields),
inheritFromId: parseInt(inheritFromId, 10),
+ integrationLevel,
id: parseInt(id, 10),
};
}
diff --git a/app/assets/javascripts/jira_connect.js b/app/assets/javascripts/jira_connect.js
new file mode 100644
index 00000000000..895cdc4562c
--- /dev/null
+++ b/app/assets/javascripts/jira_connect.js
@@ -0,0 +1,56 @@
+/* eslint-disable func-names, no-var, no-alert */
+/* global $ */
+/* global AP */
+
+/**
+ * This script is not going through Webpack bundling
+ * as it is only included in `app/views/jira_connect/subscriptions/index.html.haml`
+ * which is going to be rendered within iframe on Jira app dashboard
+ * hence any code written here needs to be IE11+ compatible (no fully ES6)
+ */
+
+function onLoaded() {
+ var reqComplete = function() {
+ AP.navigator.reload();
+ };
+
+ var reqFailed = function(res) {
+ alert(res.responseJSON.error);
+ };
+
+ $('#add-subscription-form').on('submit', function(e) {
+ var actionUrl = $(this).attr('action');
+ e.preventDefault();
+
+ AP.context.getToken(function(token) {
+ // eslint-disable-next-line no-jquery/no-ajax
+ $.post(actionUrl, {
+ jwt: token,
+ namespace_path: $('#namespace-input').val(),
+ format: 'json',
+ })
+ .done(reqComplete)
+ .fail(reqFailed);
+ });
+ });
+
+ $('.remove-subscription').on('click', function(e) {
+ var href = $(this).attr('href');
+ e.preventDefault();
+
+ AP.context.getToken(function(token) {
+ // eslint-disable-next-line no-jquery/no-ajax
+ $.ajax({
+ url: href,
+ method: 'DELETE',
+ data: {
+ jwt: token,
+ format: 'json',
+ },
+ })
+ .done(reqComplete)
+ .fail(reqFailed);
+ });
+ });
+}
+document.addEventListener('DOMContentLoaded', onLoaded);
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 8d23d177410..45d06e8e975 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -303,7 +303,41 @@ function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagCo
});
}
+/* eslint-disable @gitlab/require-i18n-strings */
+export function keypressNoteText(e) {
+ if (this.selectionStart === this.selectionEnd) {
+ return;
+ }
+ const keys = {
+ '*': '**{text}**', // wraps with bold character
+ _: '_{text}_', // wraps with italic character
+ '`': '`{text}`', // wraps with inline character
+ "'": "'{text}'", // single quotes
+ '"': '"{text}"', // double quotes
+ '[': '[{text}]', // brackets
+ '{': '{{text}}', // braces
+ '(': '({text})', // parentheses
+ '<': '<{text}>', // angle brackets
+ };
+ const tag = keys[e.key];
+
+ if (tag) {
+ e.preventDefault();
+
+ updateText({
+ tag,
+ textArea: this,
+ blockTag: '',
+ wrap: true,
+ select: '',
+ tagContent: '',
+ });
+ }
+}
+/* eslint-enable @gitlab/require-i18n-strings */
+
export function addMarkdownListeners(form) {
+ $('.markdown-area', form).on('keydown', keypressNoteText);
return $('.js-md', form)
.off('click')
.on('click', function() {
@@ -342,5 +376,6 @@ export function addEditorMarkdownListeners(editor) {
}
export function removeMarkdownListeners(form) {
+ $('.markdown-area', form).off('keydown', keypressNoteText);
return $('.js-md', form).off('click');
}
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 908aaa8c158..88d513f6076 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -423,27 +423,28 @@ export default {
<div
class="btn-group gl-mr-3 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
>
- <button
+ <gl-button
:disabled="isSubmitButtonDisabled"
- class="btn btn-success js-comment-button js-comment-submit-button qa-comment-button"
+ class="js-comment-button js-comment-submit-button qa-comment-button"
type="submit"
+ category="primary"
+ variant="success"
:data-track-label="trackingLabel"
data-track-event="click_button"
@click.prevent="handleSave()"
+ >{{ commentButtonTitle }}</gl-button
>
- {{ commentButtonTitle }}
- </button>
- <button
+ <gl-button
:disabled="isSubmitButtonDisabled"
name="button"
- type="button"
- class="btn btn-success note-type-toggle js-note-new-discussion dropdown-toggle qa-note-dropdown"
+ category="primary"
+ variant="success"
+ class="note-type-toggle js-note-new-discussion dropdown-toggle qa-note-dropdown"
data-display="static"
data-toggle="dropdown"
+ icon="chevron-down"
:aria-label="__('Open comment type dropdown')"
- >
- <i aria-hidden="true" class="fa fa-caret-down toggle-icon"></i>
- </button>
+ />
<ul class="note-type-dropdown dropdown-open-top dropdown-menu">
<li :class="{ 'droplab-item-selected': noteType === 'comment' }">
@@ -467,11 +468,7 @@ export default {
</li>
<li class="divider droplab-item-ignore"></li>
<li :class="{ 'droplab-item-selected': noteType === 'discussion' }">
- <button
- type="button"
- class="btn btn-transparent qa-discussion-option"
- @click.prevent="setNoteType('discussion')"
- >
+ <button class="qa-discussion-option" @click.prevent="setNoteType('discussion')">
<i aria-hidden="true" class="fa fa-check icon"></i>
<div class="description">
<strong>{{ __('Start thread') }}</strong>
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
index c08f2c05af2..d33d4e7dfd0 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
@@ -1,14 +1,13 @@
<script>
-import { GlDeprecatedButton, GlProgressBar, GlIcon } from '@gitlab/ui';
+import { GlButton, GlProgressBar } from '@gitlab/ui';
import { __ } from '~/locale';
import { formattedTime } from '../../stores/test_reports/utils';
export default {
name: 'TestSummary',
components: {
- GlDeprecatedButton,
+ GlButton,
GlProgressBar,
- GlIcon,
},
props: {
report: {
@@ -68,14 +67,13 @@ export default {
<div>
<div class="row">
<div class="col-12 d-flex gl-mt-3 align-items-center">
- <gl-deprecated-button
+ <gl-button
v-if="showBack"
- size="sm"
+ size="small"
class="gl-mr-3 js-back-button"
+ icon="angle-left"
@click="onBackClick"
- >
- <gl-icon name="angle-left" />
- </gl-deprecated-button>
+ />
<h4>{{ heading }}</h4>
</div>
diff --git a/app/assets/stylesheets/page_bundles/jira_connect.scss b/app/assets/stylesheets/page_bundles/jira_connect.scss
new file mode 100644
index 00000000000..83d16f29d49
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/jira_connect.scss
@@ -0,0 +1,33 @@
+@import 'framework/variables';
+
+$atlaskit-border-color: #dfe1e6;
+
+.ac-content {
+ margin: 20px;
+
+ .subscription-form {
+ margin-bottom: 20px;
+
+ .field-group-input {
+ display: flex;
+ padding-top: $gl-padding-4;
+
+ .ak-button {
+ height: auto;
+ margin-left: $btn-margin-5;
+ }
+ }
+ }
+}
+
+.subscriptions {
+ tbody {
+ tr {
+ border-bottom: 1px solid $atlaskit-border-color;
+ }
+
+ td {
+ padding: $gl-padding-8;
+ }
+ }
+}
diff --git a/app/controllers/groups/settings/integrations_controller.rb b/app/controllers/groups/settings/integrations_controller.rb
index 103ab860aac..5161b9cb181 100644
--- a/app/controllers/groups/settings/integrations_controller.rb
+++ b/app/controllers/groups/settings/integrations_controller.rb
@@ -11,6 +11,12 @@ module Groups
@integrations = Service.find_or_initialize_all(Service.for_group(group)).sort_by(&:title)
end
+ def edit
+ @admin_integration = Service.instance_for(integration.type)
+
+ super
+ end
+
private
def find_or_initialize_integration(name)
diff --git a/app/controllers/jira_connect/app_descriptor_controller.rb b/app/controllers/jira_connect/app_descriptor_controller.rb
new file mode 100644
index 00000000000..bf53c61601b
--- /dev/null
+++ b/app/controllers/jira_connect/app_descriptor_controller.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+# This returns an app descriptor for use with Jira in development mode
+# For the Atlassian Marketplace, a static copy of this JSON is uploaded to the marketplace
+# https://developer.atlassian.com/cloud/jira/platform/app-descriptor/
+
+class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController
+ skip_before_action :verify_atlassian_jwt!
+
+ def show
+ render json: {
+ name: Atlassian::JiraConnect.app_name,
+ description: 'Integrate commits, branches and merge requests from GitLab into Jira',
+ key: Atlassian::JiraConnect.app_key,
+ baseUrl: jira_connect_base_url(protocol: 'https'),
+ lifecycle: {
+ installed: relative_to_base_path(jira_connect_events_installed_path),
+ uninstalled: relative_to_base_path(jira_connect_events_uninstalled_path)
+ },
+ vendor: {
+ name: 'GitLab',
+ url: 'https://gitlab.com'
+ },
+ links: {
+ documentation: help_page_url('integration/jira_development_panel', anchor: 'gitlabcom-1')
+ },
+ authentication: {
+ type: 'jwt'
+ },
+ scopes: %w(READ WRITE DELETE),
+ apiVersion: 1,
+ modules: {
+ jiraDevelopmentTool: {
+ key: 'gitlab-development-tool',
+ application: {
+ value: 'GitLab'
+ },
+ name: {
+ value: 'GitLab'
+ },
+ url: 'https://gitlab.com',
+ logoUrl: view_context.image_url('gitlab_logo.png'),
+ capabilities: %w(branch commit pull_request)
+ },
+ postInstallPage: {
+ key: 'gitlab-configuration',
+ name: {
+ value: 'GitLab Configuration'
+ },
+ url: relative_to_base_path(jira_connect_subscriptions_path)
+ }
+ },
+ apiMigrations: {
+ gdpr: true
+ }
+ }
+ end
+
+ private
+
+ def relative_to_base_path(full_path)
+ full_path.sub(/^#{jira_connect_base_path}/, '')
+ end
+end
diff --git a/app/controllers/jira_connect/application_controller.rb b/app/controllers/jira_connect/application_controller.rb
new file mode 100644
index 00000000000..a84f25998a6
--- /dev/null
+++ b/app/controllers/jira_connect/application_controller.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+class JiraConnect::ApplicationController < ApplicationController
+ include Gitlab::Utils::StrongMemoize
+
+ skip_before_action :authenticate_user!
+ skip_before_action :verify_authenticity_token
+ before_action :verify_atlassian_jwt!
+
+ attr_reader :current_jira_installation
+
+ private
+
+ def verify_atlassian_jwt!
+ return render_403 unless atlassian_jwt_valid?
+
+ @current_jira_installation = installation_from_jwt
+ end
+
+ def verify_qsh_claim!
+ payload, _ = decode_auth_token!
+
+ # Make sure `qsh` claim matches the current request
+ render_403 unless payload['qsh'] == Atlassian::Jwt.create_query_string_hash(request.url, request.method, jira_connect_base_url)
+ rescue
+ render_403
+ end
+
+ def atlassian_jwt_valid?
+ return false unless installation_from_jwt
+
+ # Verify JWT signature with our stored `shared_secret`
+ decode_auth_token!
+ rescue JWT::DecodeError
+ false
+ end
+
+ def installation_from_jwt
+ return unless auth_token
+
+ strong_memoize(:installation_from_jwt) do
+ # Decode without verification to get `client_key` in `iss`
+ payload, _ = Atlassian::Jwt.decode(auth_token, nil, false)
+ JiraConnectInstallation.find_by_client_key(payload['iss'])
+ end
+ end
+
+ def decode_auth_token!
+ Atlassian::Jwt.decode(auth_token, installation_from_jwt.shared_secret)
+ end
+
+ def auth_token
+ strong_memoize(:auth_token) do
+ params[:jwt] || request.headers['Authorization']&.split(' ', 2)&.last
+ end
+ end
+end
diff --git a/app/controllers/jira_connect/events_controller.rb b/app/controllers/jira_connect/events_controller.rb
new file mode 100644
index 00000000000..8f79c82d847
--- /dev/null
+++ b/app/controllers/jira_connect/events_controller.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class JiraConnect::EventsController < JiraConnect::ApplicationController
+ skip_before_action :verify_atlassian_jwt!, only: :installed
+ before_action :verify_qsh_claim!, only: :uninstalled
+
+ def installed
+ installation = JiraConnectInstallation.new(install_params)
+
+ if installation.save
+ head :ok
+ else
+ head :unprocessable_entity
+ end
+ end
+
+ def uninstalled
+ if current_jira_installation.destroy
+ head :ok
+ else
+ head :unprocessable_entity
+ end
+ end
+
+ private
+
+ def install_params
+ params.permit(:clientKey, :sharedSecret, :baseUrl).transform_keys(&:underscore)
+ end
+end
diff --git a/app/controllers/jira_connect/subscriptions_controller.rb b/app/controllers/jira_connect/subscriptions_controller.rb
new file mode 100644
index 00000000000..3ff12f29f10
--- /dev/null
+++ b/app/controllers/jira_connect/subscriptions_controller.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController
+ layout 'jira_connect'
+
+ content_security_policy do |p|
+ next if p.directives.blank?
+
+ # rubocop: disable Lint/PercentStringArray
+ script_src_values = Array.wrap(p.directives['script-src']) | %w('self' https://connect-cdn.atl-paas.net https://unpkg.com/jquery@3.3.1/)
+ style_src_values = Array.wrap(p.directives['style-src']) | %w('self' 'unsafe-inline' https://unpkg.com/@atlaskit/)
+ # rubocop: enable Lint/PercentStringArray
+
+ p.frame_ancestors :self, 'https://*.atlassian.net'
+ p.script_src(*script_src_values)
+ p.style_src(*style_src_values)
+ end
+
+ before_action :allow_rendering_in_iframe, only: :index
+ before_action :verify_qsh_claim!, only: :index
+ before_action :authenticate_user!, only: :create
+
+ def index
+ @subscriptions = current_jira_installation.subscriptions.preload_namespace_route
+ end
+
+ def create
+ result = create_service.execute
+
+ if result[:status] == :success
+ render json: { success: true }
+ else
+ render json: { error: result[:message] }, status: result[:http_status]
+ end
+ end
+
+ def destroy
+ subscription = current_jira_installation.subscriptions.find(params[:id])
+
+ if subscription.destroy
+ render json: { success: true }
+ else
+ render json: { error: subscription.errors.full_messages.join(', ') }, status: :unprocessable_entity
+ end
+ end
+
+ private
+
+ def create_service
+ JiraConnectSubscriptions::CreateService.new(current_jira_installation, current_user, namespace_path: params['namespace_path'])
+ end
+
+ def allow_rendering_in_iframe
+ response.headers.delete('X-Frame-Options')
+ end
+end
diff --git a/app/controllers/oauth/jira/authorizations_controller.rb b/app/controllers/oauth/jira/authorizations_controller.rb
new file mode 100644
index 00000000000..a3e30ffc993
--- /dev/null
+++ b/app/controllers/oauth/jira/authorizations_controller.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+# This controller's role is to mimic and rewire the GitLab OAuth
+# flow routes for Jira DVCS integration.
+# See https://gitlab.com/gitlab-org/gitlab/issues/2381
+#
+class Oauth::Jira::AuthorizationsController < ApplicationController
+ skip_before_action :authenticate_user!
+ skip_before_action :verify_authenticity_token
+
+ # 1. Rewire Jira OAuth initial request to our stablished OAuth authorization URL.
+ def new
+ session[:redirect_uri] = params['redirect_uri']
+
+ redirect_to oauth_authorization_path(client_id: params['client_id'],
+ response_type: 'code',
+ redirect_uri: oauth_jira_callback_url)
+ end
+
+ # 2. Handle the callback call as we were a Github Enterprise instance client.
+ def callback
+ # Handling URI query params concatenation.
+ redirect_uri = URI.parse(session['redirect_uri'])
+ new_query = URI.decode_www_form(String(redirect_uri.query)) << ['code', params[:code]]
+ redirect_uri.query = URI.encode_www_form(new_query)
+
+ redirect_to redirect_uri.to_s
+ end
+
+ # 3. Rewire and adjust access_token request accordingly.
+ def access_token
+ # We have to modify request.parameters because Doorkeeper::Server reads params from there
+ request.parameters[:redirect_uri] = oauth_jira_callback_url
+
+ strategy = Doorkeeper::Server.new(self).token_request('authorization_code')
+ response = strategy.authorize
+
+ if response.status == :ok
+ access_token, scope, token_type = response.body.values_at('access_token', 'scope', 'token_type')
+
+ render body: "access_token=#{access_token}&scope=#{scope}&token_type=#{token_type}"
+ else
+ render status: response.status, body: response.body
+ end
+ rescue Doorkeeper::Errors::DoorkeeperError => e
+ render status: :unauthorized, body: e.type
+ end
+end
diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb
index af860297358..c27226c3f3f 100644
--- a/app/controllers/passwords_controller.rb
+++ b/app/controllers/passwords_controller.rb
@@ -31,8 +31,10 @@ class PasswordsController < Devise::PasswordsController
def update
super do |resource|
- if resource.valid? && resource.password_automatically_set?
- resource.update_attribute(:password_automatically_set, false)
+ if resource.valid?
+ resource.password_automatically_set = false
+ resource.password_expires_at = nil
+ resource.save(validate: false) if resource.changed?
end
end
end
diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb
index cae61f52ba0..a62428cf602 100644
--- a/app/helpers/services_helper.rb
+++ b/app/helpers/services_helper.rb
@@ -95,7 +95,8 @@ module ServicesHelper
learn_more_path: integrations_help_page_path,
trigger_events: trigger_events_for_service(integration),
fields: fields_for_service(integration),
- inherit_from_id: integration.inherit_from_id
+ inherit_from_id: integration.inherit_from_id,
+ integration_level: integration_level(integration)
}
end
@@ -120,6 +121,18 @@ module ServicesHelper
end
extend self
+
+ private
+
+ def integration_level(integration)
+ if integration.instance
+ 'instance'
+ elsif integration.group_id
+ 'group'
+ else
+ 'project'
+ end
+ end
end
ServicesHelper.prepend_if_ee('EE::ServicesHelper')
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index ae1b5bab7fb..8bbb92e319f 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -161,7 +161,6 @@ module Ci
where(file_type: types)
end
- scope :expired, -> (limit) { where('expire_at < ?', Time.current).limit(limit) }
scope :downloadable, -> { where(file_type: DOWNLOADABLE_TYPES) }
scope :unlocked, -> { joins(job: :pipeline).merge(::Ci::Pipeline.unlocked).order(expire_at: :desc) }
diff --git a/app/models/concerns/ci/artifactable.rb b/app/models/concerns/ci/artifactable.rb
index 37cd0d954b0..24df86dbc3c 100644
--- a/app/models/concerns/ci/artifactable.rb
+++ b/app/models/concerns/ci/artifactable.rb
@@ -17,6 +17,8 @@ module Ci
zip: 2,
gzip: 3
}, _suffix: true
+
+ scope :expired, -> (limit) { where('expire_at < ?', Time.current).limit(limit) }
end
def each_blob(&blk)
diff --git a/app/models/jira_connect_installation.rb b/app/models/jira_connect_installation.rb
new file mode 100644
index 00000000000..7480800abc3
--- /dev/null
+++ b/app/models/jira_connect_installation.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class JiraConnectInstallation < ApplicationRecord
+ attr_encrypted :shared_secret,
+ mode: :per_attribute_iv,
+ algorithm: 'aes-256-gcm',
+ key: Settings.attr_encrypted_db_key_base_32
+
+ has_many :subscriptions, class_name: 'JiraConnectSubscription'
+
+ validates :client_key, presence: true, uniqueness: true
+ validates :shared_secret, presence: true
+ validates :base_url, presence: true, public_url: true
+
+ scope :for_project, -> (project) {
+ distinct
+ .joins(:subscriptions)
+ .where(jira_connect_subscriptions: {
+ id: JiraConnectSubscription.for_project(project)
+ })
+ }
+end
diff --git a/app/models/jira_connect_subscription.rb b/app/models/jira_connect_subscription.rb
new file mode 100644
index 00000000000..c74f75b2d8e
--- /dev/null
+++ b/app/models/jira_connect_subscription.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class JiraConnectSubscription < ApplicationRecord
+ belongs_to :installation, class_name: 'JiraConnectInstallation', foreign_key: 'jira_connect_installation_id'
+ belongs_to :namespace
+
+ validates :installation, presence: true
+ validates :namespace, presence: true, uniqueness: { scope: :jira_connect_installation_id, message: 'has already been added' }
+
+ scope :preload_namespace_route, -> { preload(namespace: :route) }
+ scope :for_project, -> (project) { where(namespace_id: project.namespace.self_and_ancestors) }
+end
diff --git a/app/models/project.rb b/app/models/project.rb
index 8073e376c0f..a3de3c06de6 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -254,6 +254,7 @@ class Project < ApplicationRecord
has_one :import_data, class_name: 'ProjectImportData', inverse_of: :project, autosave: true
has_one :project_feature, inverse_of: :project
has_one :statistics, class_name: 'ProjectStatistics'
+ has_one :feature_usage, class_name: 'ProjectFeatureUsage'
has_one :cluster_project, class_name: 'Clusters::Project'
has_many :clusters, through: :cluster_project, class_name: 'Clusters::Cluster'
@@ -393,6 +394,8 @@ class Project < ApplicationRecord
to: :project_setting
delegate :active?, to: :prometheus_service, allow_nil: true, prefix: true
+ delegate :log_jira_dvcs_integration_usage, :jira_dvcs_server_last_sync_at, :jira_dvcs_cloud_last_sync_at, to: :feature_usage
+
# Validations
validates :creator, presence: true, on: :create
validates :description, length: { maximum: 2000 }, allow_blank: true
@@ -476,6 +479,9 @@ class Project < ApplicationRecord
scope :for_milestones, ->(ids) { joins(:milestones).where('milestones.id' => ids).distinct }
scope :with_push, -> { joins(:events).merge(Event.pushed_action) }
scope :with_project_feature, -> { joins('LEFT JOIN project_features ON projects.id = project_features.project_id') }
+ scope :with_active_jira_services, -> { joins(:services).merge(::JiraService.active) } # rubocop:disable CodeReuse/ServiceClass
+ scope :with_jira_dvcs_cloud, -> { joins(:feature_usage).merge(ProjectFeatureUsage.with_jira_dvcs_integration_enabled(cloud: true)) }
+ scope :with_jira_dvcs_server, -> { joins(:feature_usage).merge(ProjectFeatureUsage.with_jira_dvcs_integration_enabled(cloud: false)) }
scope :inc_routes, -> { includes(:route, namespace: :route) }
scope :with_statistics, -> { includes(:statistics) }
scope :with_namespace, -> { includes(:namespace) }
@@ -1444,6 +1450,10 @@ class Project < ApplicationRecord
http_url_to_repo
end
+ def feature_usage
+ super.presence || build_feature_usage
+ end
+
def forked?
fork_network && fork_network.root_project != self
end
@@ -2426,6 +2436,10 @@ class Project < ApplicationRecord
false
end
+ def jira_subscription_exists?
+ JiraConnectSubscription.for_project(self).exists?
+ end
+
def uses_default_ci_config?
ci_config_path.blank? || ci_config_path == Gitlab::FileDetector::PATTERNS[:gitlab_ci]
end
diff --git a/app/models/project_feature_usage.rb b/app/models/project_feature_usage.rb
new file mode 100644
index 00000000000..b167c2e371b
--- /dev/null
+++ b/app/models/project_feature_usage.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+class ProjectFeatureUsage < ApplicationRecord
+ self.primary_key = :project_id
+
+ JIRA_DVCS_CLOUD_FIELD = 'jira_dvcs_cloud_last_sync_at'.freeze
+ JIRA_DVCS_SERVER_FIELD = 'jira_dvcs_server_last_sync_at'.freeze
+
+ belongs_to :project
+ validates :project, presence: true
+
+ scope :with_jira_dvcs_integration_enabled, -> (cloud: true) do
+ where.not(jira_dvcs_integration_field(cloud: cloud) => nil)
+ end
+
+ class << self
+ def jira_dvcs_integration_field(cloud: true)
+ cloud ? JIRA_DVCS_CLOUD_FIELD : JIRA_DVCS_SERVER_FIELD
+ end
+ end
+
+ def log_jira_dvcs_integration_usage(cloud: true)
+ transaction(requires_new: true) do
+ save unless persisted?
+ touch(self.class.jira_dvcs_integration_field(cloud: cloud))
+ end
+ rescue ActiveRecord::RecordNotUnique
+ reset
+ retry
+ end
+end
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index 3cc1be9dfb7..4da752fe474 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -116,6 +116,7 @@ class GroupPolicy < BasePolicy
enable :update_cluster
enable :admin_cluster
enable :read_deploy_token
+ enable :create_jira_connect_subscription
end
rule { owner }.policy do
diff --git a/app/policies/namespace_policy.rb b/app/policies/namespace_policy.rb
index 350dd208499..aa87442cadd 100644
--- a/app/policies/namespace_policy.rb
+++ b/app/policies/namespace_policy.rb
@@ -12,6 +12,7 @@ class NamespacePolicy < BasePolicy
enable :admin_namespace
enable :read_namespace
enable :read_statistics
+ enable :create_jira_connect_subscription
end
rule { personal_project & ~can_create_personal_project }.prevent :create_projects
diff --git a/app/services/ci/destroy_expired_job_artifacts_service.rb b/app/services/ci/destroy_expired_job_artifacts_service.rb
index 1fa8926faa1..5694d031a0f 100644
--- a/app/services/ci/destroy_expired_job_artifacts_service.rb
+++ b/app/services/ci/destroy_expired_job_artifacts_service.rb
@@ -20,18 +20,18 @@ module Ci
def execute
in_lock(EXCLUSIVE_LOCK_KEY, ttl: LOCK_TIMEOUT, retries: 1) do
loop_until(timeout: LOOP_TIMEOUT, limit: LOOP_LIMIT) do
- destroy_batch
+ destroy_batch(Ci::JobArtifact) || destroy_batch(Ci::PipelineArtifact)
end
end
end
private
- def destroy_batch
- artifact_batch = if Gitlab::Ci::Features.destroy_only_unlocked_expired_artifacts_enabled?
- Ci::JobArtifact.expired(BATCH_SIZE).unlocked
+ def destroy_batch(klass)
+ artifact_batch = if klass == Ci::JobArtifact && Gitlab::Ci::Features.destroy_only_unlocked_expired_artifacts_enabled?
+ klass.expired(BATCH_SIZE).unlocked
else
- Ci::JobArtifact.expired(BATCH_SIZE)
+ klass.expired(BATCH_SIZE)
end
artifacts = artifact_batch.to_a
diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb
index 92e7702727c..dcb32b4c84b 100644
--- a/app/services/git/branch_hooks_service.rb
+++ b/app/services/git/branch_hooks_service.rb
@@ -75,6 +75,7 @@ module Git
def branch_change_hooks
enqueue_process_commit_messages
+ enqueue_jira_connect_sync_messages
end
def branch_remove_hooks
@@ -103,6 +104,17 @@ module Git
end
end
+ def enqueue_jira_connect_sync_messages
+ return unless project.jira_subscription_exists?
+
+ branch_to_sync = branch_name if Atlassian::JiraIssueKeyExtractor.has_keys?(branch_name)
+ commits_to_sync = limited_commits.select { |commit| Atlassian::JiraIssueKeyExtractor.has_keys?(commit.safe_message) }.map(&:sha)
+
+ if branch_to_sync || commits_to_sync.any?
+ JiraConnect::SyncBranchWorker.perform_async(project.id, branch_to_sync, commits_to_sync)
+ end
+ end
+
def unsigned_x509_shas(commits)
X509CommitSignature.unsigned_commit_shas(commits.map(&:sha))
end
diff --git a/app/services/jira_connect/sync_service.rb b/app/services/jira_connect/sync_service.rb
new file mode 100644
index 00000000000..07a648bb8c9
--- /dev/null
+++ b/app/services/jira_connect/sync_service.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module JiraConnect
+ class SyncService
+ def initialize(project)
+ self.project = project
+ end
+
+ def execute(commits: nil, branches: nil, merge_requests: nil)
+ JiraConnectInstallation.for_project(project).each do |installation|
+ client = Atlassian::JiraConnect::Client.new(installation.base_url, installation.shared_secret)
+
+ response = client.store_dev_info(project: project, commits: commits, branches: branches, merge_requests: merge_requests)
+
+ log_response(response)
+ end
+ end
+
+ private
+
+ attr_accessor :project
+
+ def log_response(response)
+ message = {
+ message: 'response from jira dev_info api',
+ integration: 'JiraConnect',
+ project_id: project.id,
+ project_path: project.full_path,
+ jira_response: response&.to_json
+ }
+
+ if response && response['errorMessages']
+ logger.error(message)
+ else
+ logger.info(message)
+ end
+ end
+
+ def logger
+ Gitlab::ProjectServiceLogger
+ end
+ end
+end
diff --git a/app/services/jira_connect_subscriptions/base_service.rb b/app/services/jira_connect_subscriptions/base_service.rb
new file mode 100644
index 00000000000..0e5bb91660e
--- /dev/null
+++ b/app/services/jira_connect_subscriptions/base_service.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module JiraConnectSubscriptions
+ class BaseService < ::BaseService
+ attr_accessor :jira_connect_installation, :current_user, :params
+
+ def initialize(jira_connect_installation, user = nil, params = {})
+ @jira_connect_installation, @current_user, @params = jira_connect_installation, user, params.dup
+ end
+ end
+end
diff --git a/app/services/jira_connect_subscriptions/create_service.rb b/app/services/jira_connect_subscriptions/create_service.rb
new file mode 100644
index 00000000000..8e794d3acf7
--- /dev/null
+++ b/app/services/jira_connect_subscriptions/create_service.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module JiraConnectSubscriptions
+ class CreateService < ::JiraConnectSubscriptions::BaseService
+ include Gitlab::Utils::StrongMemoize
+
+ def execute
+ unless namespace && can?(current_user, :create_jira_connect_subscription, namespace)
+ return error('Invalid namespace. Please make sure you have sufficient permissions', 401)
+ end
+
+ create_subscription
+ end
+
+ private
+
+ def create_subscription
+ subscription = JiraConnectSubscription.new(installation: jira_connect_installation, namespace: namespace)
+
+ if subscription.save
+ success
+ else
+ error(subscription.errors.full_messages.join(', '), 422)
+ end
+ end
+
+ def namespace
+ strong_memoize(:namespace) do
+ Namespace.find_by_full_path(params[:namespace_path])
+ end
+ end
+ end
+end
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index 7e301f311e9..3dc9a5cd227 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -23,6 +23,8 @@ module MergeRequests
merge_data = hook_data(merge_request, action, old_rev: old_rev, old_associations: old_associations)
merge_request.project.execute_hooks(merge_data, :merge_request_hooks)
merge_request.project.execute_services(merge_data, :merge_request_hooks)
+
+ enqueue_jira_connect_messages_for(merge_request)
end
def cleanup_environments(merge_request)
@@ -52,6 +54,14 @@ module MergeRequests
private
+ def enqueue_jira_connect_messages_for(merge_request)
+ return unless project.jira_subscription_exists?
+
+ if Atlassian::JiraIssueKeyExtractor.has_keys?(merge_request.title, merge_request.description)
+ JiraConnect::SyncMergeRequestWorker.perform_async(merge_request.id)
+ end
+ end
+
def create(merge_request)
self.params = assign_allowed_merge_params(merge_request, params)
diff --git a/app/views/jira_connect/subscriptions/index.html.haml b/app/views/jira_connect/subscriptions/index.html.haml
new file mode 100644
index 00000000000..f7ecfd09209
--- /dev/null
+++ b/app/views/jira_connect/subscriptions/index.html.haml
@@ -0,0 +1,28 @@
+%h1
+ GitLab for Jira Configuration
+
+%form#add-subscription-form.subscription-form{ action: jira_connect_subscriptions_path }
+ .ak-field-group
+ %label
+ Namespace
+
+ .ak-field-group.field-group-input
+ %input#namespace-input.ak-field-text{ type: 'text', required: true }
+ %button.ak-button.ak-button__appearance-primary{ type: 'submit' }
+ Link namespace to Jira
+
+%table.subscriptions
+ %thead
+ %tr
+ %th Namespace
+ %th Added
+ %th
+ %tbody
+ - @subscriptions.each do |subscription|
+ %tr
+ %td= subscription.namespace.full_path
+ %td= subscription.created_at
+ %td= link_to 'Remove', jira_connect_subscription_path(subscription), class: 'remove-subscription'
+
+= page_specific_javascript_tag('jira_connect.js')
+= stylesheet_link_tag 'page_bundles/jira_connect'
diff --git a/app/views/layouts/jira_connect.html.haml b/app/views/layouts/jira_connect.html.haml
new file mode 100644
index 00000000000..fdeb3d3c9ac
--- /dev/null
+++ b/app/views/layouts/jira_connect.html.haml
@@ -0,0 +1,13 @@
+%html{ lang: "en" }
+ %head
+ %meta{ content: "text/html; charset=utf-8", "http-equiv" => "Content-Type" }
+ %title
+ GitLab
+ = stylesheet_link_tag 'https://unpkg.com/@atlaskit/css-reset@3.0.6/dist/bundle.css'
+ = stylesheet_link_tag 'https://unpkg.com/@atlaskit/reduced-ui-pack@10.5.5/dist/bundle.css'
+ = javascript_include_tag 'https://connect-cdn.atl-paas.net/all.js'
+ = javascript_include_tag 'https://unpkg.com/jquery@3.3.1/dist/jquery.min.js'
+ = yield :head
+ %body
+ .ac-content
+ = yield
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 79730c6a967..dd743bd6ac4 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -723,6 +723,22 @@
:weight: 2
:idempotent:
:tags: []
+- :name: jira_connect:jira_connect_sync_branch
+ :feature_category: :integrations
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent:
+ :tags: []
+- :name: jira_connect:jira_connect_sync_merge_request
+ :feature_category: :integrations
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent:
+ :tags: []
- :name: jira_importer:jira_import_advance_stage
:feature_category: :importers
:has_external_dependencies:
diff --git a/app/workers/jira_connect/sync_branch_worker.rb b/app/workers/jira_connect/sync_branch_worker.rb
new file mode 100644
index 00000000000..8c3416478fd
--- /dev/null
+++ b/app/workers/jira_connect/sync_branch_worker.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module JiraConnect
+ class SyncBranchWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+
+ queue_namespace :jira_connect
+ feature_category :integrations
+ loggable_arguments 1, 2
+
+ def perform(project_id, branch_name, commit_shas)
+ project = Project.find_by_id(project_id)
+
+ return unless project
+
+ branches = [project.repository.find_branch(branch_name)] if branch_name.present?
+ commits = project.commits_by(oids: commit_shas) if commit_shas.present?
+
+ JiraConnect::SyncService.new(project).execute(commits: commits, branches: branches)
+ end
+ end
+end
diff --git a/app/workers/jira_connect/sync_merge_request_worker.rb b/app/workers/jira_connect/sync_merge_request_worker.rb
new file mode 100644
index 00000000000..b78bb8dfe16
--- /dev/null
+++ b/app/workers/jira_connect/sync_merge_request_worker.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module JiraConnect
+ class SyncMergeRequestWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+
+ queue_namespace :jira_connect
+ feature_category :integrations
+
+ def perform(merge_request_id)
+ merge_request = MergeRequest.find_by_id(merge_request_id)
+
+ return unless merge_request && merge_request.project
+
+ JiraConnect::SyncService.new(merge_request.project).execute(merge_requests: [merge_request])
+ end
+ end
+end
diff --git a/changelogs/unreleased/231238-adjust-badge-key-limits.yml b/changelogs/unreleased/231238-adjust-badge-key-limits.yml
new file mode 100644
index 00000000000..94149f49287
--- /dev/null
+++ b/changelogs/unreleased/231238-adjust-badge-key-limits.yml
@@ -0,0 +1,5 @@
+---
+title: Adjust badge key text and width limits
+merge_request: 40199
+author: Fabian Schneider @fabsrc
+type: changed
diff --git a/changelogs/unreleased/241818-simplify-password-reset-flow-for-a-user-whose-password-has-been-ch.yml b/changelogs/unreleased/241818-simplify-password-reset-flow-for-a-user-whose-password-has-been-ch.yml
new file mode 100644
index 00000000000..9e606269b1d
--- /dev/null
+++ b/changelogs/unreleased/241818-simplify-password-reset-flow-for-a-user-whose-password-has-been-ch.yml
@@ -0,0 +1,5 @@
+---
+title: Remove the expiry on user passwords after a user resets their password
+merge_request: 40712
+author:
+type: fixed
diff --git a/changelogs/unreleased/bw-surround-text-wth-char.yml b/changelogs/unreleased/bw-surround-text-wth-char.yml
new file mode 100644
index 00000000000..506271f13c1
--- /dev/null
+++ b/changelogs/unreleased/bw-surround-text-wth-char.yml
@@ -0,0 +1,5 @@
+---
+title: Surround selected text in markdown fields on certain key presses
+merge_request: 37151
+author:
+type: added
diff --git a/changelogs/unreleased/mo-add-destroy-artifact-service.yml b/changelogs/unreleased/mo-add-destroy-artifact-service.yml
new file mode 100644
index 00000000000..224e3d57dac
--- /dev/null
+++ b/changelogs/unreleased/mo-add-destroy-artifact-service.yml
@@ -0,0 +1,5 @@
+---
+title: Add index for expire_at to ci_pipeline_artifacts
+merge_request: 39882
+author:
+type: added
diff --git a/changelogs/unreleased/move-jira-dvcs-and-connect-app-to-core.yml b/changelogs/unreleased/move-jira-dvcs-and-connect-app-to-core.yml
new file mode 100644
index 00000000000..1e1e713c1ab
--- /dev/null
+++ b/changelogs/unreleased/move-jira-dvcs-and-connect-app-to-core.yml
@@ -0,0 +1,5 @@
+---
+title: Move Jira Development Panel integration to Core
+merge_request: 40485
+author:
+type: changed
diff --git a/changelogs/unreleased/swimlane_user_setting.yml b/changelogs/unreleased/swimlane_user_setting.yml
new file mode 100644
index 00000000000..e271edcb71b
--- /dev/null
+++ b/changelogs/unreleased/swimlane_user_setting.yml
@@ -0,0 +1,5 @@
+---
+title: Add table for storing user settings for board epic swimlanes
+merge_request: 40360
+author:
+type: added
diff --git a/config/application.rb b/config/application.rb
index 7b14da08056..dbf7e5cecfd 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -178,6 +178,7 @@ module Gitlab
config.assets.precompile << "mailers/*.css"
config.assets.precompile << "page_bundles/_mixins_and_variables_and_functions.css"
config.assets.precompile << "page_bundles/ide.css"
+ config.assets.precompile << "page_bundles/jira_connect.css"
config.assets.precompile << "page_bundles/todos.css"
config.assets.precompile << "page_bundles/xterm.css"
config.assets.precompile << "performance_bar.css"
@@ -187,6 +188,7 @@ module Gitlab
config.assets.precompile << "locale/**/app.js"
config.assets.precompile << "emoji_sprites.css"
config.assets.precompile << "errors.css"
+ config.assets.precompile << "jira_connect.js"
config.assets.precompile << "highlight/themes/*.css"
@@ -205,14 +207,6 @@ module Gitlab
config.assets.paths << "#{config.root}/node_modules/xterm/src/"
config.assets.precompile << "xterm.css"
- if Gitlab.ee?
- %w[images javascripts stylesheets].each do |path|
- config.assets.paths << "#{config.root}/ee/app/assets/#{path}"
- config.assets.precompile << "jira_connect.js"
- config.assets.precompile << "pages/jira_connect.css"
- end
- end
-
# Import path for EE specific SCSS entry point
# In CE it will import a noop file, in EE a functioning file
# Order is important, so that the ee file takes precedence:
diff --git a/config/initializers/0_inject_feature_flags.rb b/config/initializers/0_inject_feature_flags.rb
index 45e6546e294..5b33b3bb4ea 100644
--- a/config/initializers/0_inject_feature_flags.rb
+++ b/config/initializers/0_inject_feature_flags.rb
@@ -3,3 +3,4 @@
Feature.register_feature_groups
Feature.register_definitions
+Feature.register_hot_reloader unless Rails.configuration.cache_classes
diff --git a/config/initializers/remove_active_job_execute_callback.rb b/config/initializers/remove_active_job_execute_callback.rb
new file mode 100644
index 00000000000..c8efcb11202
--- /dev/null
+++ b/config/initializers/remove_active_job_execute_callback.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+return unless Rails.env.test?
+
+Rails.application.configure do
+ config.after_initialize do
+ # We don't care about ActiveJob reloading the code in test env as we run
+ # jobs inline in test env.
+ # So in test, we remove this callback, which calls app.reloader.wrap, and
+ # ultimately calls FileUpdateChecker#updated? which is slow on macOS
+ #
+ # https://github.com/rails/rails/blob/6-0-stable/activejob/lib/active_job/railtie.rb#L39-L46
+ def active_job_railtie_callback?
+ callbacks = ActiveJob::Callbacks.singleton_class.__callbacks[:execute]
+
+ callbacks &&
+ callbacks.send(:chain).size == 1 &&
+ callbacks.first.kind == :around &&
+ callbacks.first.raw_filter.is_a?(Proc) &&
+ callbacks.first.raw_filter.source_location.first.ends_with?('lib/active_job/railtie.rb')
+ end
+
+ if active_job_railtie_callback?
+ ActiveJob::Callbacks.singleton_class.reset_callbacks(:execute)
+ end
+ end
+end
diff --git a/config/routes.rb b/config/routes.rb
index 6fd9a969907..481189c97c0 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -32,13 +32,10 @@ Rails.application.routes.draw do
# This prefixless path is required because Jira gets confused if we set it up with a path
# More information: https://gitlab.com/gitlab-org/gitlab/issues/6752
scope path: '/login/oauth', controller: 'oauth/jira/authorizations', as: :oauth_jira do
- Gitlab.ee do
- get :authorize, action: :new
- get :callback
- post :access_token
- end
+ get :authorize, action: :new
+ get :callback
+ post :access_token
- # This helps minimize merge conflicts with CE for this scope block
match '*all', via: [:get, :post], to: proc { [404, {}, ['']] }
end
@@ -127,11 +124,11 @@ Rails.application.routes.draw do
get 'ide/*vueroute' => 'ide#index', format: false
draw :operations
+ draw :jira_connect
Gitlab.ee do
draw :security
draw :smartcard
- draw :jira_connect
draw :username
draw :trial
draw :trial_registration
diff --git a/config/routes/jira_connect.rb b/config/routes/jira_connect.rb
new file mode 100644
index 00000000000..a3b786b60f0
--- /dev/null
+++ b/config/routes/jira_connect.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+namespace :jira_connect do
+ # This is so we can have a named route helper for the base URL
+ root to: proc { [404, {}, ['']] }, as: 'base'
+
+ get 'app_descriptor' => 'app_descriptor#show'
+
+ namespace :events do
+ post 'installed'
+ post 'uninstalled'
+ end
+
+ resources :subscriptions, only: [:index, :create, :destroy]
+end
diff --git a/config/routes/project.rb b/config/routes/project.rb
index d02dc974434..8c9b1f7f5cd 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -564,3 +564,37 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
# rubocop: enable Cop/PutProjectRoutesUnderScope
end
end
+
+# It's under /-/jira scope but cop is only checking /-/
+# rubocop: disable Cop/PutProjectRoutesUnderScope
+scope path: '(/-/jira)', constraints: ::Constraints::JiraEncodedUrlConstrainer.new, as: :jira do
+ scope path: '*namespace_id/:project_id',
+ namespace_id: Gitlab::Jira::Dvcs::ENCODED_ROUTE_REGEX,
+ project_id: Gitlab::Jira::Dvcs::ENCODED_ROUTE_REGEX do
+ get '/', to: redirect { |params, req|
+ ::Gitlab::Jira::Dvcs.restore_full_path(
+ namespace: params[:namespace_id],
+ project: params[:project_id]
+ )
+ }
+
+ get 'commit/:id', constraints: { id: /\h{7,40}/ }, to: redirect { |params, req|
+ project_full_path = ::Gitlab::Jira::Dvcs.restore_full_path(
+ namespace: params[:namespace_id],
+ project: params[:project_id]
+ )
+
+ "/#{project_full_path}/commit/#{params[:id]}"
+ }
+
+ get 'tree/*id', as: nil, to: redirect { |params, req|
+ project_full_path = ::Gitlab::Jira::Dvcs.restore_full_path(
+ namespace: params[:namespace_id],
+ project: params[:project_id]
+ )
+
+ "/#{project_full_path}/-/tree/#{params[:id]}"
+ }
+ end
+end
+# rubocop: enable Cop/PutProjectRoutesUnderScope
diff --git a/db/migrate/20200825081025_boards_epic_user_preferences.rb b/db/migrate/20200825081025_boards_epic_user_preferences.rb
new file mode 100644
index 00000000000..fc7454a6a9a
--- /dev/null
+++ b/db/migrate/20200825081025_boards_epic_user_preferences.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class BoardsEpicUserPreferences < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ def up
+ create_table :boards_epic_user_preferences do |t|
+ t.bigint :board_id, null: false
+ t.bigint :user_id, null: false
+ t.bigint :epic_id, null: false
+ t.boolean :collapsed, default: false, null: false
+ end
+
+ add_index :boards_epic_user_preferences, :board_id
+ add_index :boards_epic_user_preferences, :user_id
+ add_index :boards_epic_user_preferences, :epic_id
+ add_index :boards_epic_user_preferences, [:board_id, :user_id, :epic_id], unique: true, name: 'index_boards_epic_user_preferences_on_board_user_epic_unique'
+ end
+
+ def down
+ drop_table :boards_epic_user_preferences
+ end
+end
diff --git a/db/migrate/20200825081035_boards_epic_user_preferences_fk_board.rb b/db/migrate/20200825081035_boards_epic_user_preferences_fk_board.rb
new file mode 100644
index 00000000000..eb52cadaecf
--- /dev/null
+++ b/db/migrate/20200825081035_boards_epic_user_preferences_fk_board.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class BoardsEpicUserPreferencesFkBoard < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def up
+ with_lock_retries do
+ add_foreign_key :boards_epic_user_preferences, :boards, column: :board_id, on_delete: :cascade # rubocop: disable Migration/AddConcurrentForeignKey
+ end
+ end
+
+ def down
+ with_lock_retries do
+ remove_foreign_key :boards_epic_user_preferences, column: :board_id
+ end
+ end
+end
diff --git a/db/migrate/20200825081045_boards_epic_user_preferences_fk_user.rb b/db/migrate/20200825081045_boards_epic_user_preferences_fk_user.rb
new file mode 100644
index 00000000000..98d0a5b64f6
--- /dev/null
+++ b/db/migrate/20200825081045_boards_epic_user_preferences_fk_user.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class BoardsEpicUserPreferencesFkUser < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def up
+ with_lock_retries do
+ add_foreign_key :boards_epic_user_preferences, :users, column: :user_id, on_delete: :cascade # rubocop: disable Migration/AddConcurrentForeignKey
+ end
+ end
+
+ def down
+ with_lock_retries do
+ remove_foreign_key :boards_epic_user_preferences, column: :user_id
+ end
+ end
+end
diff --git a/db/migrate/20200825081055_boards_epic_user_preferences_fk_epic.rb b/db/migrate/20200825081055_boards_epic_user_preferences_fk_epic.rb
new file mode 100644
index 00000000000..46498f186c4
--- /dev/null
+++ b/db/migrate/20200825081055_boards_epic_user_preferences_fk_epic.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class BoardsEpicUserPreferencesFkEpic < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def up
+ with_lock_retries do
+ add_foreign_key :boards_epic_user_preferences, :epics, column: :epic_id, on_delete: :cascade # rubocop: disable Migration/AddConcurrentForeignKey
+ end
+ end
+
+ def down
+ with_lock_retries do
+ remove_foreign_key :boards_epic_user_preferences, column: :epic_id
+ end
+ end
+end
diff --git a/db/migrate/20200827150057_add_index_expire_at_to_pipeline_artifacts.rb b/db/migrate/20200827150057_add_index_expire_at_to_pipeline_artifacts.rb
new file mode 100644
index 00000000000..0a1943aa58b
--- /dev/null
+++ b/db/migrate/20200827150057_add_index_expire_at_to_pipeline_artifacts.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class AddIndexExpireAtToPipelineArtifacts < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ INDEX_NAME = 'index_ci_pipeline_artifacts_on_expire_at'
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :ci_pipeline_artifacts, :expire_at, name: INDEX_NAME
+ end
+
+ def down
+ remove_concurrent_index_by_name(:ci_pipeline_artifacts, INDEX_NAME)
+ end
+end
diff --git a/db/schema_migrations/20200825081025 b/db/schema_migrations/20200825081025
new file mode 100644
index 00000000000..db869574f35
--- /dev/null
+++ b/db/schema_migrations/20200825081025
@@ -0,0 +1 @@
+1ee7ae93dde7099f78cd6218b5419a34b2cfebe196521bcbee1583e31f19ffda \ No newline at end of file
diff --git a/db/schema_migrations/20200825081035 b/db/schema_migrations/20200825081035
new file mode 100644
index 00000000000..45bdfd00bf0
--- /dev/null
+++ b/db/schema_migrations/20200825081035
@@ -0,0 +1 @@
+26fe286e565f776f64ae8b6b0ad91ef1d3bf2195384f44f8b093a1b66ee0d05d \ No newline at end of file
diff --git a/db/schema_migrations/20200825081045 b/db/schema_migrations/20200825081045
new file mode 100644
index 00000000000..67273493881
--- /dev/null
+++ b/db/schema_migrations/20200825081045
@@ -0,0 +1 @@
+deb88efebc989a014b6ecaca4a91624d1b21f34c85cbf6d3460363f1b498b427 \ No newline at end of file
diff --git a/db/schema_migrations/20200825081055 b/db/schema_migrations/20200825081055
new file mode 100644
index 00000000000..7694c97bf9d
--- /dev/null
+++ b/db/schema_migrations/20200825081055
@@ -0,0 +1 @@
+8fc437f09321cfe29262075009bce6f7b0047c2291df4a29bcc304c6dd54d27d \ No newline at end of file
diff --git a/db/schema_migrations/20200827150057 b/db/schema_migrations/20200827150057
new file mode 100644
index 00000000000..31a7d2e3f2e
--- /dev/null
+++ b/db/schema_migrations/20200827150057
@@ -0,0 +1 @@
+85b7ffba53c9cec30e9778dd806277ca8e9877c9a18dc1d6004402c0e66b8ef1 \ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index dbf4f29184e..58d309bf79e 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -9672,6 +9672,23 @@ CREATE TABLE public.boards (
hide_closed_list boolean DEFAULT false NOT NULL
);
+CREATE TABLE public.boards_epic_user_preferences (
+ id bigint NOT NULL,
+ board_id bigint NOT NULL,
+ user_id bigint NOT NULL,
+ epic_id bigint NOT NULL,
+ collapsed boolean DEFAULT false NOT NULL
+);
+
+CREATE SEQUENCE public.boards_epic_user_preferences_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+ALTER SEQUENCE public.boards_epic_user_preferences_id_seq OWNED BY public.boards_epic_user_preferences.id;
+
CREATE SEQUENCE public.boards_id_seq
START WITH 1
INCREMENT BY 1
@@ -16845,6 +16862,8 @@ ALTER TABLE ONLY public.board_user_preferences ALTER COLUMN id SET DEFAULT nextv
ALTER TABLE ONLY public.boards ALTER COLUMN id SET DEFAULT nextval('public.boards_id_seq'::regclass);
+ALTER TABLE ONLY public.boards_epic_user_preferences ALTER COLUMN id SET DEFAULT nextval('public.boards_epic_user_preferences_id_seq'::regclass);
+
ALTER TABLE ONLY public.broadcast_messages ALTER COLUMN id SET DEFAULT nextval('public.broadcast_messages_id_seq'::regclass);
ALTER TABLE ONLY public.chat_names ALTER COLUMN id SET DEFAULT nextval('public.chat_names_id_seq'::regclass);
@@ -17774,6 +17793,9 @@ ALTER TABLE ONLY public.board_project_recent_visits
ALTER TABLE ONLY public.board_user_preferences
ADD CONSTRAINT board_user_preferences_pkey PRIMARY KEY (id);
+ALTER TABLE ONLY public.boards_epic_user_preferences
+ ADD CONSTRAINT boards_epic_user_preferences_pkey PRIMARY KEY (id);
+
ALTER TABLE ONLY public.boards
ADD CONSTRAINT boards_pkey PRIMARY KEY (id);
@@ -19219,6 +19241,14 @@ CREATE INDEX index_board_user_preferences_on_user_id ON public.board_user_prefer
CREATE UNIQUE INDEX index_board_user_preferences_on_user_id_and_board_id ON public.board_user_preferences USING btree (user_id, board_id);
+CREATE INDEX index_boards_epic_user_preferences_on_board_id ON public.boards_epic_user_preferences USING btree (board_id);
+
+CREATE UNIQUE INDEX index_boards_epic_user_preferences_on_board_user_epic_unique ON public.boards_epic_user_preferences USING btree (board_id, user_id, epic_id);
+
+CREATE INDEX index_boards_epic_user_preferences_on_epic_id ON public.boards_epic_user_preferences USING btree (epic_id);
+
+CREATE INDEX index_boards_epic_user_preferences_on_user_id ON public.boards_epic_user_preferences USING btree (user_id);
+
CREATE INDEX index_boards_on_group_id ON public.boards USING btree (group_id);
CREATE INDEX index_boards_on_milestone_id ON public.boards USING btree (milestone_id);
@@ -19329,6 +19359,8 @@ CREATE INDEX index_ci_job_variables_on_job_id ON public.ci_job_variables USING b
CREATE UNIQUE INDEX index_ci_job_variables_on_key_and_job_id ON public.ci_job_variables USING btree (key, job_id);
+CREATE INDEX index_ci_pipeline_artifacts_on_expire_at ON public.ci_pipeline_artifacts USING btree (expire_at);
+
CREATE INDEX index_ci_pipeline_artifacts_on_pipeline_id ON public.ci_pipeline_artifacts USING btree (pipeline_id);
CREATE UNIQUE INDEX index_ci_pipeline_artifacts_on_pipeline_id_and_file_type ON public.ci_pipeline_artifacts USING btree (pipeline_id, file_type);
@@ -22243,6 +22275,9 @@ ALTER TABLE ONLY public.group_custom_attributes
ALTER TABLE ONLY public.cluster_agents
ADD CONSTRAINT fk_rails_25e9fc2d5d FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE;
+ALTER TABLE ONLY public.boards_epic_user_preferences
+ ADD CONSTRAINT fk_rails_268c57d62d FOREIGN KEY (board_id) REFERENCES public.boards(id) ON DELETE CASCADE;
+
ALTER TABLE ONLY public.group_wiki_repositories
ADD CONSTRAINT fk_rails_26f867598c FOREIGN KEY (group_id) REFERENCES public.namespaces(id) ON DELETE CASCADE;
@@ -22678,6 +22713,9 @@ ALTER TABLE ONLY public.x509_certificates
ALTER TABLE ONLY public.pages_domain_acme_orders
ADD CONSTRAINT fk_rails_76581b1c16 FOREIGN KEY (pages_domain_id) REFERENCES public.pages_domains(id) ON DELETE CASCADE;
+ALTER TABLE ONLY public.boards_epic_user_preferences
+ ADD CONSTRAINT fk_rails_76c4e9732d FOREIGN KEY (epic_id) REFERENCES public.epics(id) ON DELETE CASCADE;
+
ALTER TABLE ONLY public.ci_subscriptions_projects
ADD CONSTRAINT fk_rails_7871f9a97b FOREIGN KEY (upstream_project_id) REFERENCES public.projects(id) ON DELETE CASCADE;
@@ -22711,6 +22749,9 @@ ALTER TABLE ONLY public.approval_merge_request_rules_users
ALTER TABLE ONLY public.dast_site_profiles
ADD CONSTRAINT fk_rails_83e309d69e FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE;
+ALTER TABLE ONLY public.boards_epic_user_preferences
+ ADD CONSTRAINT fk_rails_851fe1510a FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
+
ALTER TABLE ONLY public.deployment_merge_requests
ADD CONSTRAINT fk_rails_86a6d8bf12 FOREIGN KEY (merge_request_id) REFERENCES public.merge_requests(id) ON DELETE CASCADE;
diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql
index 0523e95535d..e97ce8b99e3 100644
--- a/doc/api/graphql/reference/gitlab_schema.graphql
+++ b/doc/api/graphql/reference/gitlab_schema.graphql
@@ -2908,7 +2908,12 @@ type DastScannerProfile {
"""
ID of the DAST scanner profile
"""
- id: ID!
+ globalId: DastScannerProfileID!
+
+ """
+ ID of the DAST scanner profile. Deprecated in 13.4: Use `global_id`
+ """
+ id: ID! @deprecated(reason: "Use `global_id`. Deprecated in 13.4")
"""
Name of the DAST scanner profile
@@ -2993,7 +2998,12 @@ type DastScannerProfileCreatePayload {
"""
ID of the scanner profile.
"""
- id: ID
+ globalId: DastScannerProfileID
+
+ """
+ ID of the scanner profile.. Deprecated in 13.4: Use `global_id`
+ """
+ id: ID @deprecated(reason: "Use `global_id`. Deprecated in 13.4")
}
"""
diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json
index a2c9a369e29..f7ddefa46d9 100644
--- a/doc/api/graphql/reference/gitlab_schema.json
+++ b/doc/api/graphql/reference/gitlab_schema.json
@@ -7866,7 +7866,7 @@
"description": "Represents a DAST scanner profile.",
"fields": [
{
- "name": "id",
+ "name": "globalId",
"description": "ID of the DAST scanner profile",
"args": [
@@ -7876,7 +7876,7 @@
"name": null,
"ofType": {
"kind": "SCALAR",
- "name": "ID",
+ "name": "DastScannerProfileID",
"ofType": null
}
},
@@ -7884,6 +7884,24 @@
"deprecationReason": null
},
{
+ "name": "id",
+ "description": "ID of the DAST scanner profile. Deprecated in 13.4: Use `global_id`",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "ID",
+ "ofType": null
+ }
+ },
+ "isDeprecated": true,
+ "deprecationReason": "Use `global_id`. Deprecated in 13.4"
+ },
+ {
"name": "profileName",
"description": "Name of the DAST scanner profile",
"args": [
@@ -8115,18 +8133,32 @@
"deprecationReason": null
},
{
- "name": "id",
+ "name": "globalId",
"description": "ID of the scanner profile.",
"args": [
],
"type": {
"kind": "SCALAR",
- "name": "ID",
+ "name": "DastScannerProfileID",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
+ },
+ {
+ "name": "id",
+ "description": "ID of the scanner profile.. Deprecated in 13.4: Use `global_id`",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "ID",
+ "ofType": null
+ },
+ "isDeprecated": true,
+ "deprecationReason": "Use `global_id`. Deprecated in 13.4"
}
],
"inputFields": null,
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 0c75917db10..9b4fd3daf67 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -506,7 +506,8 @@ Represents a DAST scanner profile.
| Name | Type | Description |
| --- | ---- | ---------- |
-| `id` | ID! | ID of the DAST scanner profile |
+| `globalId` | DastScannerProfileID! | ID of the DAST scanner profile |
+| `id` **{warning-solid}** | ID! | **Deprecated:** Use `global_id`. Deprecated in 13.4 |
| `profileName` | String | Name of the DAST scanner profile |
| `spiderTimeout` | Int | The maximum number of seconds allowed for the spider to traverse the site |
| `targetTimeout` | Int | The maximum number of seconds allowed for the site under test to respond to a request |
@@ -519,7 +520,8 @@ Autogenerated return type of DastScannerProfileCreate
| --- | ---- | ---------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
-| `id` | ID | ID of the scanner profile. |
+| `globalId` | DastScannerProfileID | ID of the scanner profile. |
+| `id` **{warning-solid}** | ID | **Deprecated:** Use `global_id`. Deprecated in 13.4 |
## DastScannerProfileUpdatePayload
diff --git a/doc/api/groups.md b/doc/api/groups.md
index 45c1814a4c2..f2dd9ab81b6 100644
--- a/doc/api/groups.md
+++ b/doc/api/groups.md
@@ -1170,7 +1170,7 @@ DELETE /groups/:id/share/:group_id
## Push Rules **(STARTER)**
-### Get group push rules
+### Get group push rules **(STARTER)**
Get the [push rules](../user/group/index.md#group-push-rules-starter) of a group.
@@ -1233,3 +1233,70 @@ POST /groups/:id/push_rule
| `max_file_size` **(STARTER)** | integer | no | Maximum file size (MB) allowed |
| `commit_committer_check` **(PREMIUM)** | boolean | no | Only commits pushed using verified emails will be allowed |
| `reject_unsigned_commits` **(PREMIUM)** | boolean | no | Only commits signed through GPG will be allowed |
+
+```shell
+curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/19/push_rule"
+```
+
+Response:
+
+```json
+{
+ "id": 19,
+ "created_at": "2020-08-31T15:53:00.073Z",
+ "commit_message_regex": "[a-zA-Z]",
+ "commit_message_negative_regex": "[x+]",
+ "branch_name_regex": null,
+ "deny_delete_tag": false,
+ "member_check": false,
+ "prevent_secrets": false,
+ "author_email_regex": "^[A-Za-z0-9.]+@gitlab.com$",
+ "file_name_regex": null,
+ "max_file_size": 100
+}
+```
+
+### Edit group push rule **(STARTER)**
+
+Edit push rules for a specified group.
+
+```plaintext
+PUT /groups/:id/push_rule
+```
+
+| Attribute | Type | Required | Description |
+| --------------------------------------------- | -------------- | -------- | ----------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) |
+| `deny_delete_tag` **(STARTER)** | boolean | no | Deny deleting a tag |
+| `member_check` **(STARTER)** | boolean | no | Restricts commits to be authored by existing GitLab users only |
+| `prevent_secrets` **(STARTER)** | boolean | no | [Files that are likely to contain secrets](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/lib/gitlab/checks/files_denylist.yml) will be rejected |
+| `commit_message_regex` **(STARTER)** | string | no | All commit messages must match the regular expression provided in this attribute, e.g. `Fixed \d+\..*` |
+| `commit_message_negative_regex` **(STARTER)** | string | no | Commit messages matching the regular expression provided in this attribute will not be allowed, e.g. `ssh\:\/\/` |
+| `branch_name_regex` **(STARTER)** | string | no | All branch names must match the regular expression provided in this attribute, e.g. `(feature|hotfix)\/*` |
+| `author_email_regex` **(STARTER)** | string | no | All commit author emails must match the regular expression provided in this attribute, e.g. `@my-company.com$` |
+| `file_name_regex` **(STARTER)** | string | no | Filenames matching the regular expression provided in this attribute will **not** be allowed, e.g. `(jar|exe)$` |
+| `max_file_size` **(STARTER)** | integer | no | Maximum file size (MB) allowed |
+| `commit_committer_check` **(PREMIUM)** | boolean | no | Only commits pushed using verified emails will be allowed |
+| `reject_unsigned_commits` **(PREMIUM)** | boolean | no | Only commits signed through GPG will be allowed |
+
+```shell
+curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/19/push_rule"
+```
+
+Response:
+
+```json
+{
+ "id": 19,
+ "created_at": "2020-08-31T15:53:00.073Z",
+ "commit_message_regex": "[a-zA-Z]",
+ "commit_message_negative_regex": "[x+]",
+ "branch_name_regex": null,
+ "deny_delete_tag": false,
+ "member_check": false,
+ "prevent_secrets": false,
+ "author_email_regex": "^[A-Za-z0-9.]+@staging.gitlab.com$",
+ "file_name_regex": null,
+ "max_file_size": 100
+}
+```
diff --git a/doc/integration/jira_development_panel.md b/doc/integration/jira_development_panel.md
index 5a4296f67a1..9b7aa5829c1 100644
--- a/doc/integration/jira_development_panel.md
+++ b/doc/integration/jira_development_panel.md
@@ -4,9 +4,10 @@ group: Ecosystem
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
---
-# GitLab Jira Development Panel integration **(PREMIUM)**
+# GitLab Jira Development Panel integration **(CORE)**
-> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/2381) in [GitLab Premium](https://about.gitlab.com/pricing/) 10.0.
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/2381) in [GitLab Premium](https://about.gitlab.com/pricing/) 10.0.
+> - [Moved](https://gitlab.com/gitlab-org/gitlab/-/issues/233149) to [GitLab Core](https://about.gitlab.com/pricing/) in 13.4.
The Jira Development Panel integration allows you to reference Jira issues within GitLab, displaying activity in the [Development panel](https://support.atlassian.com/jira-software-cloud/docs/view-development-information-for-an-issue/) in the issue. It complements the [GitLab Jira integration](../user/project/integrations/jira.md). You may choose to configure both integrations to take advantage of both sets of features. (See a [feature comparison](../user/project/integrations/jira_integrations.md#feature-comparison)).
@@ -199,9 +200,8 @@ Potential resolutions:
- If you're using GitLab versions 11.10-12.7, upgrade to GitLab 12.8.10 or later
to resolve an identified [issue](https://gitlab.com/gitlab-org/gitlab/-/issues/37012).
-- The Jira Development Panel integration requires GitLab Premium, GitLab.com Silver,
- or a higher tier. If you're using a lower tier of GitLab, you'll need to upgrade
- to use this feature.
+- If you're using GitLab Core or GitLab Starter, be sure you're using
+ GitLab 13.4 or later.
[Contact GitLab Support](https://about.gitlab.com/support) if none of these reasons apply.
@@ -234,7 +234,9 @@ For a walkthrough of the integration with GitLab for Jira, watch [Configure GitL
1. After installing, click **Get started** to go to the configurations page. This page is always available under **Jira Settings > Apps > Manage apps**.
![Start GitLab App configuration on Jira](img/jira_dev_panel_setup_com_2.png)
-1. Enter the group or personal namespace in the **Namespace** field and click **Link namespace to Jira**. Make sure you are logged in on GitLab.com and the namespace has a Silver or above license. The user setting up _GitLab for Jira_ must have **Maintainer** access to the GitLab namespace.
+1. In **Namespace**, enter the group or personal namespace, and then click
+ **Link namespace to Jira**. The user setting up *GitLab for Jira* must have
+ *Maintainer* access to the GitLab namespace.
NOTE: **Note:**
The GitLab user only needs access when adding a new namespace. For syncing with Jira, we do not depend on the user's token.
diff --git a/doc/user/project/integrations/jira_integrations.md b/doc/user/project/integrations/jira_integrations.md
index 90cd9bf3acb..dd22c26be36 100644
--- a/doc/user/project/integrations/jira_integrations.md
+++ b/doc/user/project/integrations/jira_integrations.md
@@ -18,7 +18,7 @@ Although you can [migrate](../../../user/project/import/jira.md) your Jira issue
The following Jira integrations allow different types of cross-referencing between GitLab activity and Jira issues, with additional features:
- [**Jira integration**](jira.md) - This is built in to GitLab. In a given GitLab project, it can be configured to connect to any Jira instance, self-managed or Cloud.
-- [**Jira development panel integration**](../../../integration/jira_development_panel.md) **(PREMIUM)** - This connects all GitLab projects under a specified group or personal namespace.
+- [**Jira development panel integration**](../../../integration/jira_development_panel.md) - This connects all GitLab projects under a specified group or personal namespace.
- If you're using Jira Cloud and GitLab.com, install the [GitLab for Jira](https://marketplace.atlassian.com/apps/1221011/gitlab-for-jira) app in the Atlassian Marketplace and see its [documentation](../../../integration/jira_development_panel.md#gitlab-for-jira-app).
- For all other environments, use the [Jira DVCS Connector configuration instructions](../../../integration/jira_development_panel.md#configuration).
diff --git a/lib/api/api.rb b/lib/api/api.rb
index 308c9d68d7b..7f03b9622b0 100644
--- a/lib/api/api.rb
+++ b/lib/api/api.rb
@@ -246,6 +246,16 @@ module API
mount ::API::Internal::Pages
mount ::API::Internal::Kubernetes
+ version 'v3', using: :path do
+ # Although the following endpoints are kept behind V3 namespace,
+ # they're not deprecated neither should be removed when V3 get
+ # removed. They're needed as a layer to integrate with Jira
+ # Development Panel.
+ namespace '/', requirements: ::API::V3::Github::ENDPOINT_REQUIREMENTS do
+ mount ::API::V3::Github
+ end
+ end
+
route :any, '*path' do
error!('404 Not Found', 404)
end
diff --git a/lib/api/github/entities.rb b/lib/api/github/entities.rb
new file mode 100644
index 00000000000..c28a0b8eb7e
--- /dev/null
+++ b/lib/api/github/entities.rb
@@ -0,0 +1,217 @@
+# frozen_string_literal: true
+
+# Simplified version of Github API entities.
+# It's mainly used to mimic Github API and integrate with Jira Development Panel.
+#
+module API
+ module Github
+ module Entities
+ class Repository < Grape::Entity
+ expose :id
+ expose :owner do |project, options|
+ root_namespace = options[:root_namespace] || project.root_namespace
+
+ { login: root_namespace.path }
+ end
+ expose :name do |project, options|
+ ::Gitlab::Jira::Dvcs.encode_project_name(project)
+ end
+ end
+
+ class BranchCommit < Grape::Entity
+ expose :id, as: :sha
+ expose :type do |_|
+ 'commit'
+ end
+ end
+
+ class RepoCommit < Grape::Entity
+ expose :id, as: :sha
+ expose :author do |commit|
+ {
+ login: commit.author&.username,
+ email: commit.author_email
+ }
+ end
+ expose :committer do |commit|
+ {
+ login: commit.author&.username,
+ email: commit.committer_email
+ }
+ end
+ expose :commit do |commit|
+ {
+ author: {
+ name: commit.author_name,
+ email: commit.author_email,
+ date: commit.authored_date.iso8601,
+ type: 'User'
+ },
+ committer: {
+ name: commit.committer_name,
+ email: commit.committer_email,
+ date: commit.committed_date.iso8601,
+ type: 'User'
+ },
+ message: commit.safe_message
+ }
+ end
+ expose :parents do |commit|
+ commit.parent_ids.map { |id| { sha: id } }
+ end
+ expose :files do |commit|
+ commit.diffs.diff_files.flat_map do |diff|
+ additions = diff.added_lines
+ deletions = diff.removed_lines
+
+ if diff.new_file?
+ {
+ status: 'added',
+ filename: diff.new_path,
+ additions: additions,
+ changes: additions
+ }
+ elsif diff.deleted_file?
+ {
+ status: 'removed',
+ filename: diff.old_path,
+ deletions: deletions,
+ changes: deletions
+ }
+ elsif diff.renamed_file?
+ [
+ {
+ status: 'removed',
+ filename: diff.old_path,
+ deletions: deletions,
+ changes: deletions
+ },
+ {
+ status: 'added',
+ filename: diff.new_path,
+ additions: additions,
+ changes: additions
+ }
+ ]
+ else
+ {
+ status: 'modified',
+ filename: diff.new_path,
+ additions: additions,
+ deletions: deletions,
+ changes: (additions + deletions)
+ }
+ end
+ end
+ end
+ end
+
+ class Branch < Grape::Entity
+ expose :name
+
+ expose :commit, using: BranchCommit do |repo_branch, options|
+ options[:project].repository.commit(repo_branch.dereferenced_target)
+ end
+ end
+
+ class User < Grape::Entity
+ expose :id
+ expose :username, as: :login
+ expose :user_url, as: :url
+ expose :user_url, as: :html_url
+ expose :avatar_url
+
+ private
+
+ def user_url
+ Gitlab::Routing.url_helpers.user_url(object)
+ end
+ end
+
+ class NoteableComment < Grape::Entity
+ expose :id
+ expose :author, as: :user, using: User
+ expose :note, as: :body
+ expose :created_at
+ end
+
+ class PullRequest < Grape::Entity
+ expose :title
+ expose :assignee, using: User do |merge_request|
+ merge_request.assignee
+ end
+ expose :author, as: :user, using: User
+ expose :created_at
+ expose :description, as: :body
+ # Since Jira service requests `/repos/-/jira/pulls` (without project
+ # scope), we need to make it work with ID instead IID.
+ expose :id, as: :number
+ # GitHub doesn't have a "merged" or "closed" state. It's just "open" or
+ # "closed".
+ expose :state do |merge_request|
+ case merge_request.state
+ when 'opened', 'locked'
+ 'open'
+ when 'merged'
+ 'closed'
+ else
+ merge_request.state
+ end
+ end
+ expose :merged?, as: :merged
+ expose :merged_at do |merge_request|
+ merge_request.metrics&.merged_at
+ end
+ expose :closed_at do |merge_request|
+ merge_request.metrics&.latest_closed_at
+ end
+ expose :updated_at
+ expose :html_url do |merge_request|
+ Gitlab::UrlBuilder.build(merge_request)
+ end
+ expose :head do
+ expose :source_branch, as: :label
+ expose :source_branch, as: :ref
+ expose :source_project, as: :repo, using: Repository
+ end
+ expose :base do
+ expose :target_branch, as: :label
+ expose :target_branch, as: :ref
+ expose :target_project, as: :repo, using: Repository
+ end
+ end
+
+ class PullRequestPayload < Grape::Entity
+ expose :action do |merge_request|
+ case merge_request.state
+ when 'merged', 'closed'
+ 'closed'
+ else
+ 'opened'
+ end
+ end
+
+ expose :id
+ expose :pull_request, using: PullRequest do |merge_request|
+ merge_request
+ end
+ end
+
+ class PullRequestEvent < Grape::Entity
+ expose :id do |merge_request|
+ updated_at = merge_request.updated_at.to_i
+ "#{merge_request.id}-#{updated_at}"
+ end
+ expose :type do |_merge_request|
+ 'PullRequestEvent'
+ end
+ expose :updated_at, as: :created_at
+ expose :payload, using: PullRequestPayload do |merge_request|
+ # The merge request data is used by PullRequestPayload and PullRequest, so we just provide it
+ # here. Otherwise Grape::Entity would try to access a field "payload" on Merge Request.
+ merge_request
+ end
+ end
+ end
+ end
+end
diff --git a/lib/api/v3/github.rb b/lib/api/v3/github.rb
new file mode 100644
index 00000000000..593f90460ac
--- /dev/null
+++ b/lib/api/v3/github.rb
@@ -0,0 +1,232 @@
+# frozen_string_literal: true
+
+# These endpoints partially mimic Github API behavior in order to successfully
+# integrate with Jira Development Panel.
+# Endpoints returning an empty list were temporarily added to avoid 404's
+# during Jira's DVCS integration.
+#
+module API
+ module V3
+ class Github < Grape::API::Instance
+ NO_SLASH_URL_PART_REGEX = %r{[^/]+}.freeze
+ ENDPOINT_REQUIREMENTS = {
+ namespace: NO_SLASH_URL_PART_REGEX,
+ project: NO_SLASH_URL_PART_REGEX,
+ username: NO_SLASH_URL_PART_REGEX
+ }.freeze
+
+ # Used to differentiate Jira Cloud requests from Jira Server requests
+ # Jira Cloud user agent format: Jira DVCS Connector Vertigo/version
+ # Jira Server user agent format: Jira DVCS Connector/version
+ JIRA_DVCS_CLOUD_USER_AGENT = 'Jira DVCS Connector Vertigo'.freeze
+
+ include PaginationParams
+
+ before do
+ authorize_jira_user_agent!(request)
+ authenticate!
+ end
+
+ helpers do
+ params :project_full_path do
+ requires :namespace, type: String
+ requires :project, type: String
+ end
+
+ def authorize_jira_user_agent!(request)
+ not_found! unless Gitlab::Jira::Middleware.jira_dvcs_connector?(request.env)
+ end
+
+ def update_project_feature_usage_for(project)
+ # Prevent errors on GitLab Geo not allowing
+ # UPDATE statements to happen in GET requests.
+ return if Gitlab::Database.read_only?
+
+ project.log_jira_dvcs_integration_usage(cloud: jira_cloud?)
+ end
+
+ def jira_cloud?
+ request.env['HTTP_USER_AGENT'].include?(JIRA_DVCS_CLOUD_USER_AGENT)
+ end
+
+ def find_project_with_access(params)
+ project = find_project!(
+ ::Gitlab::Jira::Dvcs.restore_full_path(params.slice(:namespace, :project).symbolize_keys)
+ )
+ not_found! unless can?(current_user, :download_code, project)
+ project
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def find_merge_requests
+ merge_requests = authorized_merge_requests.reorder(updated_at: :desc)
+ paginate(merge_requests)
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def find_merge_request_with_access(id, access_level = :read_merge_request)
+ merge_request = authorized_merge_requests.find_by(id: id)
+ not_found! unless can?(current_user, access_level, merge_request)
+ merge_request
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def authorized_merge_requests
+ MergeRequestsFinder.new(current_user, authorized_only: !current_user.admin?).execute
+ end
+
+ def authorized_merge_requests_for_project(project)
+ MergeRequestsFinder.new(current_user, authorized_only: !current_user.admin?, project_id: project.id).execute
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def find_notes(noteable)
+ # They're not presented on Jira Dev Panel ATM. A comments count with a
+ # redirect link is presented.
+ notes = paginate(noteable.notes.user.reorder(nil))
+ notes.select { |n| n.readable_by?(current_user) }
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+
+ resource :orgs do
+ get ':namespace/repos' do
+ present []
+ end
+ end
+
+ resource :user do
+ get :repos do
+ present []
+ end
+ end
+
+ resource :users do
+ params do
+ use :pagination
+ end
+
+ get ':namespace/repos' do
+ namespace = Namespace.find_by_full_path(params[:namespace])
+ not_found!('Namespace') unless namespace
+
+ projects = current_user.can_read_all_resources? ? Project.all : current_user.authorized_projects
+ projects = projects.in_namespace(namespace.self_and_descendants)
+
+ projects_cte = Project.wrap_with_cte(projects)
+ .eager_load_namespace_and_owner
+ .with_route
+
+ present paginate(projects_cte),
+ with: ::API::Github::Entities::Repository,
+ root_namespace: namespace.root_ancestor
+ end
+
+ get ':username' do
+ forbidden! unless can?(current_user, :read_users_list)
+ user = UsersFinder.new(current_user, { username: params[:username] }).execute.first
+ not_found! unless user
+ present user, with: ::API::Github::Entities::User
+ end
+ end
+
+ # Jira dev panel integration weirdly requests for "/-/jira/pulls" instead
+ # "/api/v3/repos/<namespace>/<project>/pulls". This forces us into
+ # returning _all_ Merge Requests from authorized projects (user is a member),
+ # instead just the authorized MRs from a project.
+ # Jira handles the filtering, presenting just MRs mentioning the Jira
+ # issue ID on the MR title / description.
+ resource :repos do
+ # Keeping for backwards compatibility with old Jira integration instructions
+ # so that users that do not change it will not suddenly have a broken integration
+ get '/-/jira/pulls' do
+ present find_merge_requests, with: ::API::Github::Entities::PullRequest
+ end
+
+ get '/-/jira/events' do
+ present []
+ end
+
+ params do
+ use :project_full_path
+ end
+ get ':namespace/:project/pulls' do
+ user_project = find_project_with_access(params)
+
+ merge_requests = authorized_merge_requests_for_project(user_project)
+
+ present paginate(merge_requests), with: ::API::Github::Entities::PullRequest
+ end
+
+ params do
+ use :project_full_path
+ end
+ get ':namespace/:project/pulls/:id' do
+ merge_request = find_merge_request_with_access(params[:id])
+
+ present merge_request, with: ::API::Github::Entities::PullRequest
+ end
+
+ # In Github, each Merge Request is automatically also an issue.
+ # Therefore we return its comments here.
+ # It'll present _just_ the comments counting with a link to GitLab on
+ # Jira dev panel, not the actual note content.
+ get ':namespace/:project/issues/:id/comments' do
+ merge_request = find_merge_request_with_access(params[:id])
+
+ present find_notes(merge_request), with: ::API::Github::Entities::NoteableComment
+ end
+
+ # This refer to "review" comments but Jira dev panel doesn't seem to
+ # present it accordingly.
+ get ':namespace/:project/pulls/:id/comments' do
+ present []
+ end
+
+ # Commits are not presented within "Pull Requests" modal on Jira dev
+ # panel.
+ get ':namespace/:project/pulls/:id/commits' do
+ present []
+ end
+
+ # Self-hosted Jira (tested on 7.11.1) requests this endpoint right
+ # after fetching branches.
+ get ':namespace/:project/events' do
+ user_project = find_project_with_access(params)
+
+ merge_requests = authorized_merge_requests_for_project(user_project)
+
+ present paginate(merge_requests), with: ::API::Github::Entities::PullRequestEvent
+ end
+
+ params do
+ use :project_full_path
+ use :pagination
+ end
+ get ':namespace/:project/branches' do
+ user_project = find_project_with_access(params)
+
+ update_project_feature_usage_for(user_project)
+
+ branches = ::Kaminari.paginate_array(user_project.repository.branches.sort_by(&:name))
+
+ present paginate(branches), with: ::API::Github::Entities::Branch, project: user_project
+ end
+
+ params do
+ use :project_full_path
+ end
+ get ':namespace/:project/commits/:sha' do
+ user_project = find_project_with_access(params)
+
+ commit = user_project.commit(params[:sha])
+
+ not_found! 'Commit' unless commit
+
+ present commit, with: ::API::Github::Entities::RepoCommit
+ end
+ end
+ end
+ end
+end
diff --git a/lib/atlassian/jira_connect.rb b/lib/atlassian/jira_connect.rb
new file mode 100644
index 00000000000..7f693eff59b
--- /dev/null
+++ b/lib/atlassian/jira_connect.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Atlassian
+ module JiraConnect
+ class << self
+ def app_name
+ "GitLab for Jira (#{gitlab_host})"
+ end
+
+ def app_key
+ "gitlab-jira-connect-#{gitlab_host}"
+ end
+
+ private
+
+ def gitlab_host
+ Gitlab.config.gitlab.host
+ end
+ end
+ end
+end
diff --git a/lib/atlassian/jira_connect/client.rb b/lib/atlassian/jira_connect/client.rb
new file mode 100644
index 00000000000..0b578c03782
--- /dev/null
+++ b/lib/atlassian/jira_connect/client.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module Atlassian
+ module JiraConnect
+ class Client < Gitlab::HTTP
+ def initialize(base_uri, shared_secret)
+ @base_uri = base_uri
+ @shared_secret = shared_secret
+ end
+
+ def store_dev_info(project:, commits: nil, branches: nil, merge_requests: nil)
+ dev_info_json = {
+ repositories: [
+ Serializers::RepositoryEntity.represent(
+ project,
+ commits: commits,
+ branches: branches,
+ merge_requests: merge_requests
+ )
+ ]
+ }.to_json
+
+ uri = URI.join(@base_uri, '/rest/devinfo/0.10/bulk')
+
+ headers = {
+ 'Authorization' => "JWT #{jwt_token('POST', uri)}",
+ 'Content-Type' => 'application/json'
+ }
+
+ self.class.post(uri, headers: headers, body: dev_info_json)
+ end
+
+ private
+
+ def jwt_token(http_method, uri)
+ claims = Atlassian::Jwt.build_claims(
+ Atlassian::JiraConnect.app_key,
+ uri,
+ http_method,
+ @base_uri
+ )
+
+ Atlassian::Jwt.encode(claims, @shared_secret)
+ end
+ end
+ end
+end
diff --git a/lib/atlassian/jira_connect/serializers/author_entity.rb b/lib/atlassian/jira_connect/serializers/author_entity.rb
new file mode 100644
index 00000000000..9ab8e34c14b
--- /dev/null
+++ b/lib/atlassian/jira_connect/serializers/author_entity.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Atlassian
+ module JiraConnect
+ module Serializers
+ class AuthorEntity < Grape::Entity
+ include Gitlab::Routing
+
+ expose :name
+ expose :email
+
+ with_options(unless: -> (user) { user.is_a?(CommitEntity::CommitAuthor) }) do
+ expose :username
+ expose :url do |user|
+ user_url(user)
+ end
+ expose :avatar do |user|
+ user.avatar_url(only_path: false)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/atlassian/jira_connect/serializers/base_entity.rb b/lib/atlassian/jira_connect/serializers/base_entity.rb
new file mode 100644
index 00000000000..c5490aa3f54
--- /dev/null
+++ b/lib/atlassian/jira_connect/serializers/base_entity.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Atlassian
+ module JiraConnect
+ module Serializers
+ class BaseEntity < Grape::Entity
+ include Gitlab::Routing
+ include GitlabRoutingHelper
+
+ format_with(:string) { |value| value.to_s }
+
+ expose :monotonic_time, as: :updateSequenceId
+
+ private
+
+ def monotonic_time
+ Gitlab::Metrics::System.monotonic_time.to_i
+ end
+ end
+ end
+ end
+end
diff --git a/lib/atlassian/jira_connect/serializers/branch_entity.rb b/lib/atlassian/jira_connect/serializers/branch_entity.rb
new file mode 100644
index 00000000000..c663575b7a8
--- /dev/null
+++ b/lib/atlassian/jira_connect/serializers/branch_entity.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Atlassian
+ module JiraConnect
+ module Serializers
+ class BranchEntity < BaseEntity
+ expose :id do |branch|
+ Digest::SHA256.hexdigest(branch.name)
+ end
+ expose :issueKeys do |branch|
+ JiraIssueKeyExtractor.new(branch.name).issue_keys
+ end
+ expose :name
+ expose :lastCommit, using: JiraConnect::Serializers::CommitEntity do |branch, options|
+ options[:project].commit(branch.dereferenced_target)
+ end
+
+ expose :url do |branch, options|
+ project_commits_url(options[:project], branch.name)
+ end
+ expose :createPullRequestUrl do |branch, options|
+ project_new_merge_request_url(
+ options[:project],
+ merge_request: {
+ source_branch: branch.name
+ }
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/lib/atlassian/jira_connect/serializers/commit_entity.rb b/lib/atlassian/jira_connect/serializers/commit_entity.rb
new file mode 100644
index 00000000000..12eb1ed15ea
--- /dev/null
+++ b/lib/atlassian/jira_connect/serializers/commit_entity.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module Atlassian
+ module JiraConnect
+ module Serializers
+ class CommitEntity < BaseEntity
+ CommitAuthor = Struct.new(:name, :email)
+
+ expose :id
+ expose :issueKeys do |commit|
+ JiraIssueKeyExtractor.new(commit.safe_message).issue_keys
+ end
+ expose :id, as: :hash
+ expose :short_id, as: :displayId
+ expose :safe_message, as: :message
+ expose :flags do |commit|
+ if commit.merge_commit?
+ ['MERGE_COMMIT']
+ else
+ []
+ end
+ end
+ expose :author, using: JiraConnect::Serializers::AuthorEntity
+ expose :fileCount do |commit|
+ commit.stats.total
+ end
+ expose :files do |commit, options|
+ files = commit.diffs(max_files: 10).diff_files
+ JiraConnect::Serializers::FileEntity.represent files, options.merge(commit: commit)
+ end
+ expose :created_at, as: :authorTimestamp
+
+ expose :url do |commit, options|
+ project_commit_url(options[:project], commit.id)
+ end
+
+ private
+
+ def author
+ object.author || CommitAuthor.new(object.author_name, object.author_email)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/atlassian/jira_connect/serializers/file_entity.rb b/lib/atlassian/jira_connect/serializers/file_entity.rb
new file mode 100644
index 00000000000..50d31965f93
--- /dev/null
+++ b/lib/atlassian/jira_connect/serializers/file_entity.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Atlassian
+ module JiraConnect
+ module Serializers
+ class FileEntity < Grape::Entity
+ include Gitlab::Routing
+
+ expose :path do |file|
+ file.deleted_file? ? file.old_path : file.new_path
+ end
+ expose :changeType do |file|
+ if file.new_file?
+ 'ADDED'
+ elsif file.deleted_file?
+ 'DELETED'
+ elsif file.renamed_file?
+ 'MOVED'
+ else
+ 'MODIFIED'
+ end
+ end
+ expose :added_lines, as: :linesAdded
+ expose :removed_lines, as: :linesRemoved
+
+ expose :url do |file, options|
+ file_path = if file.deleted_file?
+ File.join(options[:commit].parent_id, file.old_path)
+ else
+ File.join(options[:commit].id, file.new_path)
+ end
+
+ project_blob_url(options[:project], file_path)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/atlassian/jira_connect/serializers/pull_request_entity.rb b/lib/atlassian/jira_connect/serializers/pull_request_entity.rb
new file mode 100644
index 00000000000..0ddfcbf52ea
--- /dev/null
+++ b/lib/atlassian/jira_connect/serializers/pull_request_entity.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module Atlassian
+ module JiraConnect
+ module Serializers
+ class PullRequestEntity < BaseEntity
+ STATUS_MAPPING = {
+ 'opened' => 'OPEN',
+ 'locked' => 'OPEN',
+ 'merged' => 'MERGED',
+ 'closed' => 'DECLINED'
+ }.freeze
+
+ expose :id, format_with: :string
+ expose :issueKeys do |mr|
+ JiraIssueKeyExtractor.new(mr.title, mr.description).issue_keys
+ end
+ expose :displayId do |mr|
+ mr.to_reference(full: true)
+ end
+ expose :title
+ expose :author, using: JiraConnect::Serializers::AuthorEntity
+ expose :user_notes_count, as: :commentCount
+ expose :source_branch, as: :sourceBranch
+ expose :target_branch, as: :destinationBranch
+ expose :lastUpdate do |mr|
+ mr.last_edited_at || mr.created_at
+ end
+ expose :status do |mr|
+ STATUS_MAPPING[mr.state] || 'UNKNOWN'
+ end
+
+ expose :sourceBranchUrl do |mr|
+ project_commits_url(mr.project, mr.source_branch)
+ end
+ expose :url do |mr|
+ merge_request_url(mr)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/atlassian/jira_connect/serializers/repository_entity.rb b/lib/atlassian/jira_connect/serializers/repository_entity.rb
new file mode 100644
index 00000000000..819ca2b62e0
--- /dev/null
+++ b/lib/atlassian/jira_connect/serializers/repository_entity.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Atlassian
+ module JiraConnect
+ module Serializers
+ class RepositoryEntity < BaseEntity
+ expose :id, format_with: :string
+ expose :name
+ expose :description
+ expose :url do |project|
+ project_url(project)
+ end
+ expose :avatar do |project|
+ project.avatar_url(only_path: false)
+ end
+
+ expose :commits do |project, options|
+ JiraConnect::Serializers::CommitEntity.represent options[:commits], project: project
+ end
+ expose :branches do |project, options|
+ JiraConnect::Serializers::BranchEntity.represent options[:branches], project: project
+ end
+ expose :pullRequests do |project, options|
+ JiraConnect::Serializers::PullRequestEntity.represent options[:merge_requests], project: project
+ end
+ end
+ end
+ end
+end
diff --git a/lib/atlassian/jira_issue_key_extractor.rb b/lib/atlassian/jira_issue_key_extractor.rb
new file mode 100644
index 00000000000..f1b432787ac
--- /dev/null
+++ b/lib/atlassian/jira_issue_key_extractor.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Atlassian
+ class JiraIssueKeyExtractor
+ def self.has_keys?(*text)
+ new(*text).issue_keys.any?
+ end
+
+ def initialize(*text)
+ @text = text.join(' ')
+ end
+
+ def issue_keys
+ @text.scan(Gitlab::Regex.jira_issue_key_regex).uniq
+ end
+ end
+end
diff --git a/lib/constraints/jira_encoded_url_constrainer.rb b/lib/constraints/jira_encoded_url_constrainer.rb
new file mode 100644
index 00000000000..92e2fff346b
--- /dev/null
+++ b/lib/constraints/jira_encoded_url_constrainer.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Constraints
+ class JiraEncodedUrlConstrainer
+ def matches?(request)
+ request.path.starts_with?('/-/jira') || request.params[:project_id].include?(Gitlab::Jira::Dvcs::ENCODED_SLASH)
+ end
+ end
+end
diff --git a/lib/feature.rb b/lib/feature.rb
index 7cf40b63fdf..b4b327966c7 100644
--- a/lib/feature.rb
+++ b/lib/feature.rb
@@ -137,6 +137,12 @@ class Feature
Feature::Definition.load_all!
end
+ def register_hot_reloader
+ return unless check_feature_flags_definition?
+
+ Feature::Definition.register_hot_reloader!
+ end
+
private
def flipper
diff --git a/lib/feature/definition.rb b/lib/feature/definition.rb
index b0ea55c5805..ee779a86952 100644
--- a/lib/feature/definition.rb
+++ b/lib/feature/definition.rb
@@ -107,6 +107,20 @@ class Feature
end
end
+ def register_hot_reloader!
+ # Reload feature flags on change of this file or any `.yml`
+ file_watcher = Rails.configuration.file_watcher.new(reload_files, reload_directories) do
+ # We use `Feature::Definition` as on Ruby code-reload
+ # a new class definition is created
+ Feature::Definition.load_all!
+ end
+
+ Rails.application.reloaders << file_watcher
+ Rails.application.reloader.to_run { file_watcher.execute_if_updated }
+
+ file_watcher
+ end
+
private
def load_from_file(path)
@@ -130,6 +144,19 @@ class Feature
definitions[definition.key] = definition
end
end
+
+ def reload_files
+ [File.expand_path(__FILE__)]
+ end
+
+ def reload_directories
+ paths.each_with_object({}) do |path, result|
+ path = File.dirname(path)
+ Dir.glob(path).each do |matching_dir|
+ result[matching_dir] = 'yml'
+ end
+ end
+ end
end
end
end
diff --git a/lib/gitlab/background_migration/fix_pages_access_level.rb b/lib/gitlab/background_migration/fix_pages_access_level.rb
index 31d2e78b2d2..8e46021bd93 100644
--- a/lib/gitlab/background_migration/fix_pages_access_level.rb
+++ b/lib/gitlab/background_migration/fix_pages_access_level.rb
@@ -103,8 +103,8 @@ module Gitlab
end
# Private projects are not allowed to have enabled access level, only `private` and `public`
- # If access control is enabled, these projects currently behave as if the have `private` pages_access_level
- # if access control is disabled, these projects currently behave as if the have `public` pages_access_level
+ # If access control is enabled, these projects currently behave as if they have `private` pages_access_level
+ # if access control is disabled, these projects currently behave as if they have `public` pages_access_level
# so we preserve this behaviour for projects with pages already deployed
# for project without pages we always set `private` access_level
def fix_private_access_level(start_id, stop_id)
diff --git a/lib/gitlab/badge/coverage/template.rb b/lib/gitlab/badge/coverage/template.rb
index 6b78825aefd..1b985f83b22 100644
--- a/lib/gitlab/badge/coverage/template.rb
+++ b/lib/gitlab/badge/coverage/template.rb
@@ -25,7 +25,7 @@ module Gitlab
end
def key_text
- if @key_text && @key_text.size <= MAX_KEY_SIZE
+ if @key_text && @key_text.size <= MAX_KEY_TEXT_SIZE
@key_text
else
@entity.to_s
@@ -37,7 +37,7 @@ module Gitlab
end
def key_width
- if @key_width && @key_width.between?(1, MAX_KEY_SIZE)
+ if @key_width && @key_width.between?(1, MAX_KEY_WIDTH)
@key_width
else
62
diff --git a/lib/gitlab/badge/pipeline/template.rb b/lib/gitlab/badge/pipeline/template.rb
index 781897fab4b..af8e318395b 100644
--- a/lib/gitlab/badge/pipeline/template.rb
+++ b/lib/gitlab/badge/pipeline/template.rb
@@ -29,7 +29,7 @@ module Gitlab
end
def key_text
- if @key_text && @key_text.size <= MAX_KEY_SIZE
+ if @key_text && @key_text.size <= MAX_KEY_TEXT_SIZE
@key_text
else
@entity.to_s
@@ -41,7 +41,7 @@ module Gitlab
end
def key_width
- if @key_width && @key_width.between?(1, MAX_KEY_SIZE)
+ if @key_width && @key_width.between?(1, MAX_KEY_WIDTH)
@key_width
else
62
diff --git a/lib/gitlab/badge/template.rb b/lib/gitlab/badge/template.rb
index 97103e3f42c..9ac8f1c17f2 100644
--- a/lib/gitlab/badge/template.rb
+++ b/lib/gitlab/badge/template.rb
@@ -6,7 +6,8 @@ module Gitlab
# Abstract template class for badges
#
class Template
- MAX_KEY_SIZE = 128
+ MAX_KEY_TEXT_SIZE = 64
+ MAX_KEY_WIDTH = 512
def initialize(badge)
@entity = badge.entity
diff --git a/lib/gitlab/jira/dvcs.rb b/lib/gitlab/jira/dvcs.rb
new file mode 100644
index 00000000000..4415f98fc7f
--- /dev/null
+++ b/lib/gitlab/jira/dvcs.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Jira
+ module Dvcs
+ ENCODED_SLASH = '@'.freeze
+ SLASH = '/'.freeze
+ ENCODED_ROUTE_REGEX = /[a-zA-Z0-9_\-\.#{ENCODED_SLASH}]+/.freeze
+
+ def self.encode_slash(path)
+ path.gsub(SLASH, ENCODED_SLASH)
+ end
+
+ def self.decode_slash(path)
+ path.gsub(ENCODED_SLASH, SLASH)
+ end
+
+ # To present two types of projects stored by Jira,
+ # Type 1 are projects imported prior to nested group support,
+ # those project names are not full_path, so they are presented differently
+ # to maintain backwards compatibility.
+ # Type 2 are projects imported after nested group support,
+ # those project names are encoded full path
+ #
+ # @param [Project] project
+ def self.encode_project_name(project)
+ if project.namespace.has_parent?
+ encode_slash(project.full_path)
+ else
+ project.path
+ end
+ end
+
+ # To interpret two types of project names stored by Jira (see `encode_project_name`)
+ #
+ # @param [String] project
+ # Either an encoded full path, or just project name
+ # @param [String] namespace
+ def self.restore_full_path(namespace:, project:)
+ if project.include?(ENCODED_SLASH)
+ project.gsub(ENCODED_SLASH, SLASH)
+ else
+ "#{namespace}/#{project}"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/jira/middleware.rb b/lib/gitlab/jira/middleware.rb
new file mode 100644
index 00000000000..8a74729da49
--- /dev/null
+++ b/lib/gitlab/jira/middleware.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Jira
+ class Middleware
+ def self.jira_dvcs_connector?(env)
+ env['HTTP_USER_AGENT']&.downcase&.start_with?('jira dvcs connector')
+ end
+
+ def initialize(app)
+ @app = app
+ end
+
+ def call(env)
+ if self.class.jira_dvcs_connector?(env)
+ env['HTTP_AUTHORIZATION'] = env['HTTP_AUTHORIZATION']&.sub('token', 'Bearer')
+ end
+
+ @app.call(env)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/middleware/same_site_cookies.rb b/lib/gitlab/middleware/same_site_cookies.rb
index a3fd6234373..37ccc5abb10 100644
--- a/lib/gitlab/middleware/same_site_cookies.rb
+++ b/lib/gitlab/middleware/same_site_cookies.rb
@@ -30,7 +30,7 @@ module Gitlab
set_cookie = headers['Set-Cookie']&.strip
return result if set_cookie.blank? || !ssl?
- return result if same_site_none_incompatible?(headers['User-Agent'])
+ return result if same_site_none_incompatible?(env['HTTP_USER_AGENT'])
cookies = set_cookie.split(COOKIE_SEPARATOR)
diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb
index f959a4accac..504ffe0a2ad 100644
--- a/lib/gitlab/usage_data.rb
+++ b/lib/gitlab/usage_data.rb
@@ -376,7 +376,9 @@ module Gitlab
# so we can just check for subdomains of atlassian.net
results = {
projects_jira_server_active: 0,
- projects_jira_cloud_active: 0
+ projects_jira_cloud_active: 0,
+ projects_jira_dvcs_cloud_active: count(ProjectFeatureUsage.with_jira_dvcs_integration_enabled),
+ projects_jira_dvcs_server_active: count(ProjectFeatureUsage.with_jira_dvcs_integration_enabled(cloud: false))
}
# rubocop: disable UsageData/LargeTable:
@@ -566,7 +568,10 @@ module Gitlab
projects: distinct_count(::Project.where(time_period), :creator_id),
todos: distinct_count(::Todo.where(time_period), :author_id),
service_desk_enabled_projects: distinct_count_service_desk_enabled_projects(time_period),
- service_desk_issues: count(::Issue.service_desk.where(time_period))
+ service_desk_issues: count(::Issue.service_desk.where(time_period)),
+ projects_jira_active: distinct_count(::Project.with_active_jira_services.where(time_period), :creator_id),
+ projects_jira_dvcs_cloud_active: distinct_count(::Project.with_active_jira_services.with_jira_dvcs_cloud.where(time_period), :creator_id),
+ projects_jira_dvcs_server_active: distinct_count(::Project.with_active_jira_services.with_jira_dvcs_server.where(time_period), :creator_id)
}
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 04f38ee91fe..063df4871cf 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -13279,6 +13279,9 @@ msgstr ""
msgid "Integrations|Comment settings:"
msgstr ""
+msgid "Integrations|Default settings are inherited from the group level."
+msgstr ""
+
msgid "Integrations|Default settings are inherited from the instance level."
msgstr ""
diff --git a/package.json b/package.json
index 9a802bc1bca..5d19e5160a9 100644
--- a/package.json
+++ b/package.json
@@ -43,7 +43,7 @@
"@babel/preset-env": "^7.10.1",
"@gitlab/at.js": "1.5.5",
"@gitlab/svgs": "1.161.0",
- "@gitlab/ui": "20.12.1",
+ "@gitlab/ui": "20.13.0",
"@gitlab/visual-review-tools": "1.6.1",
"@rails/actioncable": "^6.0.3-1",
"@sentry/browser": "^5.10.2",
diff --git a/rubocop/cop/migration/safer_boolean_column.rb b/rubocop/cop/migration/safer_boolean_column.rb
index 25aaf42d00e..22d5d37a83d 100644
--- a/rubocop/cop/migration/safer_boolean_column.rb
+++ b/rubocop/cop/migration/safer_boolean_column.rb
@@ -37,7 +37,7 @@ module RuboCop
table, _, type = matched.to_a.take(3).map(&:children).map(&:first)
opts = matched[3]
- return unless WHITELISTED_TABLES.include?(table) && type == :boolean
+ return unless SMALL_TABLES.include?(table) && type == :boolean
no_default = no_default?(opts)
nulls_allowed = nulls_allowed?(opts)
diff --git a/rubocop/migration_helpers.rb b/rubocop/migration_helpers.rb
index 355450bbf57..7c0ab441c28 100644
--- a/rubocop/migration_helpers.rb
+++ b/rubocop/migration_helpers.rb
@@ -1,14 +1,13 @@
module RuboCop
# Module containing helper methods for writing migration cops.
module MigrationHelpers
- WHITELISTED_TABLES = %i[
+ # Tables with permanently small number of records
+ SMALL_TABLES = %i[
application_settings
plan_limits
].freeze
- # Blacklisted tables due to:
- # - number of columns (> 50 on GitLab.com as of 03/2020)
- # - number of records
+ # Tables with large number of columns (> 50 on GitLab.com as of 03/2020)
WIDE_TABLES = %i[
users
projects
diff --git a/spec/controllers/jira_connect/app_descriptor_controller_spec.rb b/spec/controllers/jira_connect/app_descriptor_controller_spec.rb
new file mode 100644
index 00000000000..55bafa938a7
--- /dev/null
+++ b/spec/controllers/jira_connect/app_descriptor_controller_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe JiraConnect::AppDescriptorController do
+ describe '#show' do
+ it 'returns JSON app descriptor' do
+ get :show
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to include(
+ 'baseUrl' => 'https://test.host/-/jira_connect',
+ 'lifecycle' => {
+ 'installed' => '/events/installed',
+ 'uninstalled' => '/events/uninstalled'
+ },
+ 'links' => {
+ 'documentation' => 'http://test.host/help/integration/jira_development_panel#gitlabcom-1'
+ }
+ )
+ end
+ end
+end
diff --git a/spec/controllers/jira_connect/events_controller_spec.rb b/spec/controllers/jira_connect/events_controller_spec.rb
new file mode 100644
index 00000000000..d1a2dd6e7af
--- /dev/null
+++ b/spec/controllers/jira_connect/events_controller_spec.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe JiraConnect::EventsController do
+ describe '#installed' do
+ subject do
+ post :installed, params: {
+ clientKey: '1234',
+ sharedSecret: 'secret',
+ baseUrl: 'https://test.atlassian.net'
+ }
+ end
+
+ it 'saves the jira installation data' do
+ expect { subject }.to change { JiraConnectInstallation.count }.by(1)
+ end
+
+ it 'saves the correct values' do
+ subject
+
+ installation = JiraConnectInstallation.find_by_client_key('1234')
+
+ expect(installation.shared_secret).to eq('secret')
+ expect(installation.base_url).to eq('https://test.atlassian.net')
+ end
+
+ context 'client key already exists' do
+ it 'returns 422' do
+ create(:jira_connect_installation, client_key: '1234')
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:unprocessable_entity)
+ end
+ end
+
+ describe '#uninstalled' do
+ let!(:installation) { create(:jira_connect_installation) }
+ let(:qsh) { Atlassian::Jwt.create_query_string_hash('https://gitlab.test/events/uninstalled', 'POST', 'https://gitlab.test') }
+
+ before do
+ request.headers['Authorization'] = "JWT #{auth_token}"
+ end
+
+ subject { post :uninstalled }
+
+ context 'when JWT is invalid' do
+ let(:auth_token) { 'invalid_token' }
+
+ it 'returns 403' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+
+ it 'does not delete the installation' do
+ expect { subject }.not_to change { JiraConnectInstallation.count }
+ end
+ end
+
+ context 'when JWT is valid' do
+ let(:auth_token) do
+ Atlassian::Jwt.encode({ iss: installation.client_key, qsh: qsh }, installation.shared_secret)
+ end
+
+ it 'deletes the installation' do
+ expect { subject }.to change { JiraConnectInstallation.count }.by(-1)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/controllers/jira_connect/subscriptions_controller_spec.rb b/spec/controllers/jira_connect/subscriptions_controller_spec.rb
new file mode 100644
index 00000000000..95b359a989a
--- /dev/null
+++ b/spec/controllers/jira_connect/subscriptions_controller_spec.rb
@@ -0,0 +1,113 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe JiraConnect::SubscriptionsController do
+ let_it_be(:installation) { create(:jira_connect_installation) }
+
+ describe '#index' do
+ before do
+ get :index, params: { jwt: jwt }
+ end
+
+ context 'without JWT' do
+ let(:jwt) { nil }
+
+ it 'returns 403' do
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'with valid JWT' do
+ let(:qsh) { Atlassian::Jwt.create_query_string_hash('https://gitlab.test/subscriptions', 'GET', 'https://gitlab.test') }
+ let(:jwt) { Atlassian::Jwt.encode({ iss: installation.client_key, qsh: qsh }, installation.shared_secret) }
+
+ it 'returns 200' do
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'removes X-Frame-Options to allow rendering in iframe' do
+ expect(response.headers['X-Frame-Options']).to be_nil
+ end
+ end
+ end
+
+ describe '#create' do
+ let(:group) { create(:group) }
+ let(:user) { create(:user) }
+ let(:current_user) { user }
+
+ before do
+ group.add_maintainer(user)
+ end
+
+ subject { post :create, params: { jwt: jwt, namespace_path: group.path, format: :json } }
+
+ context 'without JWT' do
+ let(:jwt) { nil }
+
+ it 'returns 403' do
+ sign_in(user)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'with valid JWT' do
+ let(:jwt) { Atlassian::Jwt.encode({ iss: installation.client_key }, installation.shared_secret) }
+
+ context 'signed in to GitLab' do
+ before do
+ sign_in(user)
+ end
+
+ context 'dev panel integration is available' do
+ it 'creates a subscription' do
+ expect { subject }.to change { installation.subscriptions.count }.from(0).to(1)
+ end
+
+ it 'returns 200' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+
+ context 'not signed in to GitLab' do
+ it 'returns 401' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+ end
+ end
+
+ describe '#destroy' do
+ let(:subscription) { create(:jira_connect_subscription, installation: installation) }
+
+ before do
+ delete :destroy, params: { jwt: jwt, id: subscription.id }
+ end
+
+ context 'without JWT' do
+ let(:jwt) { nil }
+
+ it 'returns 403' do
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'with valid JWT' do
+ let(:jwt) { Atlassian::Jwt.encode({ iss: installation.client_key }, installation.shared_secret) }
+
+ it 'deletes the subscription' do
+ expect { subscription.reload }.to raise_error ActiveRecord::RecordNotFound
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/oauth/jira/authorizations_controller_spec.rb b/spec/controllers/oauth/jira/authorizations_controller_spec.rb
new file mode 100644
index 00000000000..0b4a691d7ec
--- /dev/null
+++ b/spec/controllers/oauth/jira/authorizations_controller_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Oauth::Jira::AuthorizationsController do
+ describe 'GET new' do
+ it 'redirects to OAuth authorization with correct params' do
+ get :new, params: { client_id: 'client-123', redirect_uri: 'http://example.com/' }
+
+ expect(response).to redirect_to(oauth_authorization_url(client_id: 'client-123',
+ response_type: 'code',
+ redirect_uri: oauth_jira_callback_url))
+ end
+ end
+
+ describe 'GET callback' do
+ it 'redirects to redirect_uri on session with code param' do
+ session['redirect_uri'] = 'http://example.com'
+
+ get :callback, params: { code: 'hash-123' }
+
+ expect(response).to redirect_to('http://example.com?code=hash-123')
+ end
+
+ it 'redirects to redirect_uri on session with code param preserving existing query' do
+ session['redirect_uri'] = 'http://example.com?foo=bar'
+
+ get :callback, params: { code: 'hash-123' }
+
+ expect(response).to redirect_to('http://example.com?foo=bar&code=hash-123')
+ end
+ end
+
+ describe 'POST access_token' do
+ it 'returns oauth params in a format Jira expects' do
+ expect_any_instance_of(Doorkeeper::Request::AuthorizationCode).to receive(:authorize) do
+ double(status: :ok, body: { 'access_token' => 'fake-123', 'scope' => 'foo', 'token_type' => 'bar' })
+ end
+
+ post :access_token, params: { code: 'code-123', client_id: 'client-123', client_secret: 'secret-123' }
+
+ expect(response.body).to eq('access_token=fake-123&scope=foo&token_type=bar')
+ end
+ end
+end
diff --git a/spec/controllers/passwords_controller_spec.rb b/spec/controllers/passwords_controller_spec.rb
index ba2c0c0455d..e9883107456 100644
--- a/spec/controllers/passwords_controller_spec.rb
+++ b/spec/controllers/passwords_controller_spec.rb
@@ -3,11 +3,13 @@
require 'spec_helper'
RSpec.describe PasswordsController do
- describe '#check_password_authentication_available' do
- before do
- @request.env["devise.mapping"] = Devise.mappings[:user]
- end
+ include DeviseHelpers
+ before do
+ set_devise_mapping(context: @request)
+ end
+
+ describe '#check_password_authentication_available' do
context 'when password authentication is disabled for the web interface and Git' do
it 'prevents a password reset' do
stub_application_setting(password_authentication_enabled_for_web: false)
@@ -30,4 +32,51 @@ RSpec.describe PasswordsController do
end
end
end
+
+ describe '#update' do
+ render_views
+
+ context 'updating the password' do
+ subject do
+ put :update, params: {
+ user: {
+ password: password,
+ password_confirmation: password_confirmation,
+ reset_password_token: reset_password_token
+ }
+ }
+ end
+
+ let(:password) { User.random_password }
+ let(:password_confirmation) { password }
+ let(:reset_password_token) { user.send_reset_password_instructions }
+ let(:user) { create(:user, password_automatically_set: true, password_expires_at: 10.minutes.ago) }
+
+ context 'password update is successful' do
+ it 'updates the password-related flags' do
+ subject
+ user.reload
+
+ expect(response).to redirect_to(new_user_session_path)
+ expect(flash[:notice]).to include('password has been changed successfully')
+ expect(user.password_automatically_set).to eq(false)
+ expect(user.password_expires_at).to be_nil
+ end
+ end
+
+ context 'password update is unsuccessful' do
+ let(:password_confirmation) { 'not_the_same_as_password' }
+
+ it 'does not update the password-related flags' do
+ subject
+ user.reload
+
+ expect(response).to render_template(:edit)
+ expect(response.body).to have_content("Password confirmation doesn't match Password")
+ expect(user.password_automatically_set).to eq(true)
+ expect(user.password_expires_at).not_to be_nil
+ end
+ end
+ end
+ end
end
diff --git a/spec/factories/diff_position.rb b/spec/factories/diff_position.rb
index 685272acf5c..0185c4ce156 100644
--- a/spec/factories/diff_position.rb
+++ b/spec/factories/diff_position.rb
@@ -53,7 +53,10 @@ FactoryBot.define do
factory :image_diff_position do
position_type { 'image' }
x { 1 }
- y { 1 }
+ # Fix:
+ # NoMethodError: undefined method `end_line=' for nil:NilClass
+ # from /usr/lib/ruby/2.6.0/psych/tree_builder.rb:133:in `set_end_location'
+ add_attribute(:y) { 1 }
width { 10 }
height { 10 }
end
diff --git a/spec/factories/jira_connect_installation.rb b/spec/factories/jira_connect_installation.rb
new file mode 100644
index 00000000000..2e3202c662c
--- /dev/null
+++ b/spec/factories/jira_connect_installation.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :jira_connect_installation do
+ sequence(:client_key) { |n| "atlassian-client-key-#{n}" }
+ shared_secret { 'jrNarHaRYaumMvfV3UnYpwt8' }
+ base_url { 'https://sample.atlassian.net' }
+ end
+end
diff --git a/spec/factories/jira_connect_subscription.rb b/spec/factories/jira_connect_subscription.rb
new file mode 100644
index 00000000000..e22b277f190
--- /dev/null
+++ b/spec/factories/jira_connect_subscription.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :jira_connect_subscription do
+ association :installation, factory: :jira_connect_installation
+ association :namespace, factory: :group
+ end
+end
diff --git a/spec/factories/project_feature_usage.rb b/spec/factories/project_feature_usage.rb
new file mode 100644
index 00000000000..8265ea04392
--- /dev/null
+++ b/spec/factories/project_feature_usage.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :project_feature_usage do
+ project
+
+ trait :dvcs_cloud do
+ jira_dvcs_cloud_last_sync_at { Time.current }
+ end
+
+ trait :dvcs_server do
+ jira_dvcs_server_last_sync_at { Time.current }
+ end
+ end
+end
diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb
index 58205bb63c4..31821f734bb 100644
--- a/spec/factories/projects.rb
+++ b/spec/factories/projects.rb
@@ -109,6 +109,18 @@ FactoryBot.define do
import_status { :failed }
end
+ trait :jira_dvcs_cloud do
+ before(:create) do |project|
+ create(:project_feature_usage, :dvcs_cloud, project: project)
+ end
+ end
+
+ trait :jira_dvcs_server do
+ before(:create) do |project|
+ create(:project_feature_usage, :dvcs_server, project: project)
+ end
+ end
+
trait :archived do
archived { true }
end
diff --git a/spec/features/issues/user_views_issue_spec.rb b/spec/features/issues/user_views_issue_spec.rb
index b5d7e2691e5..3f18764aa58 100644
--- a/spec/features/issues/user_views_issue_spec.rb
+++ b/spec/features/issues/user_views_issue_spec.rb
@@ -57,10 +57,14 @@ RSpec.describe "User views issue" do
let_it_be(:status) { create(:user_status, user: user, emoji: 'smirk', message: "#{message} :#{message_emoji}:") }
it 'correctly renders the emoji' do
+ wait_for_requests
+
tooltip_span = page.first(".user-status-emoji[title^='#{message}']")
tooltip_span.hover
+ wait_for_requests
+
tooltip = page.find('.tooltip .tooltip-inner')
page.within(tooltip) do
diff --git a/spec/features/jira_connect/subscriptions_spec.rb b/spec/features/jira_connect/subscriptions_spec.rb
new file mode 100644
index 00000000000..9be6b7c67ee
--- /dev/null
+++ b/spec/features/jira_connect/subscriptions_spec.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Subscriptions Content Security Policy' do
+ let(:installation) { create(:jira_connect_installation) }
+ let(:qsh) { Atlassian::Jwt.create_query_string_hash('https://gitlab.test/subscriptions', 'GET', 'https://gitlab.test') }
+ let(:jwt) { Atlassian::Jwt.encode({ iss: installation.client_key, qsh: qsh }, installation.shared_secret) }
+
+ subject { response_headers['Content-Security-Policy'] }
+
+ context 'when there is no global config' do
+ before do
+ expect_next_instance_of(JiraConnect::SubscriptionsController) do |controller|
+ expect(controller).to receive(:current_content_security_policy)
+ .and_return(ActionDispatch::ContentSecurityPolicy.new)
+ end
+ end
+
+ it 'does not add CSP directives' do
+ visit jira_connect_subscriptions_path(jwt: jwt)
+
+ is_expected.to be_blank
+ end
+ end
+
+ context 'when a global CSP config exists' do
+ before do
+ csp = ActionDispatch::ContentSecurityPolicy.new do |p|
+ p.script_src :self, 'https://some-cdn.test'
+ p.style_src :self, 'https://some-cdn.test'
+ end
+
+ expect_next_instance_of(JiraConnect::SubscriptionsController) do |controller|
+ expect(controller).to receive(:current_content_security_policy).and_return(csp)
+ end
+ end
+
+ it 'appends to CSP directives' do
+ visit jira_connect_subscriptions_path(jwt: jwt)
+
+ is_expected.to include("frame-ancestors 'self' https://*.atlassian.net")
+ is_expected.to include("script-src 'self' https://some-cdn.test https://connect-cdn.atl-paas.net https://unpkg.com/jquery@3.3.1/")
+ is_expected.to include("style-src 'self' https://some-cdn.test 'unsafe-inline' https://unpkg.com/@atlaskit/")
+ end
+ end
+end
diff --git a/spec/features/jira_oauth_provider_authorize_spec.rb b/spec/features/jira_oauth_provider_authorize_spec.rb
new file mode 100644
index 00000000000..daecae56101
--- /dev/null
+++ b/spec/features/jira_oauth_provider_authorize_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'JIRA OAuth Provider' do
+ describe 'JIRA DVCS OAuth Authorization' do
+ let(:application) { create(:oauth_application, redirect_uri: oauth_jira_callback_url, scopes: 'read_user') }
+
+ before do
+ sign_in(user)
+
+ visit oauth_jira_authorize_path(client_id: application.uid,
+ redirect_uri: oauth_jira_callback_url,
+ response_type: 'code',
+ state: 'my_state',
+ scope: 'read_user')
+ end
+
+ it_behaves_like 'Secure OAuth Authorizations'
+ end
+end
diff --git a/spec/features/projects/wiki/markdown_preview_spec.rb b/spec/features/projects/wiki/markdown_preview_spec.rb
index 8eba2c98595..8f2fb9e827c 100644
--- a/spec/features/projects/wiki/markdown_preview_spec.rb
+++ b/spec/features/projects/wiki/markdown_preview_spec.rb
@@ -8,6 +8,7 @@ RSpec.describe 'Projects > Wiki > User previews markdown changes', :js do
let(:wiki_page) { create(:wiki_page, wiki: project.wiki, title: 'home', content: '[some link](other-page)') }
let(:wiki_content) do
<<-HEREDOC
+Some text so key event for [ does not trigger an incorrect replacement.
[regular link](regular)
[relative link 1](../relative)
[relative link 2](./relative)
diff --git a/spec/features/task_lists_spec.rb b/spec/features/task_lists_spec.rb
index fc3f8a94318..a9cfe794177 100644
--- a/spec/features/task_lists_spec.rb
+++ b/spec/features/task_lists_spec.rb
@@ -5,9 +5,9 @@ require 'spec_helper'
RSpec.describe 'Task Lists' do
include Warden::Test::Helpers
- let(:project) { create(:project, :public, :repository) }
- let(:user) { create(:user) }
- let(:user2) { create(:user) }
+ let_it_be(:project) { create(:project, :public, :repository) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:user2) { create(:user) }
let(:markdown) do
<<-MARKDOWN.strip_heredoc
@@ -72,12 +72,12 @@ RSpec.describe 'Task Lists' do
EOT
end
- before do
- Warden.test_mode!
-
+ before(:all) do
project.add_maintainer(user)
project.add_guest(user2)
+ end
+ before do
login_as(user)
end
diff --git a/spec/fixtures/api/schemas/entities/github/branches.json b/spec/fixtures/api/schemas/entities/github/branches.json
new file mode 100644
index 00000000000..ee3da3704f3
--- /dev/null
+++ b/spec/fixtures/api/schemas/entities/github/branches.json
@@ -0,0 +1,16 @@
+{
+ "type": "array",
+ "properties" : {
+ "name": { "type": "string" },
+ "commit": {
+ "type": "object",
+ "required": ["sha", "type"],
+ "properties" : {
+ "sha": { "type": "string" },
+ "type": { "type": "string" }
+ },
+ "additionalProperties": false
+ },
+ "additionalProperties": false
+ }
+}
diff --git a/spec/fixtures/api/schemas/entities/github/commit.json b/spec/fixtures/api/schemas/entities/github/commit.json
new file mode 100644
index 00000000000..698d933be07
--- /dev/null
+++ b/spec/fixtures/api/schemas/entities/github/commit.json
@@ -0,0 +1,61 @@
+{
+ "type": "object",
+ "properties" : {
+ "sha": { "type": "string" },
+ "parents": {
+ "type": "array",
+ "properties": {
+ "sha": { "type": "string" }
+ },
+ "additionalProperties": false
+ },
+ "author": {
+ "type": "object",
+ "required": ["login", "email"],
+ "properties" : {
+ "login": { "type": ["string", "null"] },
+ "email": { "type": "string" }
+ },
+ "additionalProperties": false
+ },
+ "committer": {
+ "type": "object",
+ "required": ["login", "email"],
+ "properties" : {
+ "login": { "type": ["string", "null"] },
+ "email": { "type": "string" }
+ },
+ "additionalProperties": false
+ },
+ "commit": {
+ "type": "object",
+ "properties": {
+ "message": { "type": "string" },
+ "author": {
+ "type": "object",
+ "required": ["name", "email", "date", "type"],
+ "properties" : {
+ "name": { "type": "string" },
+ "email": { "type": "string" },
+ "date": { "type": "date" },
+ "type": { "type": "string" }
+ },
+ "additionalProperties": false
+ },
+ "committer": {
+ "type": "object",
+ "required": ["name", "email", "date", "type"],
+ "properties" : {
+ "name": { "type": "string" },
+ "email": { "type": "string" },
+ "date": { "type": "date" },
+ "type": { "type": "string" }
+ },
+ "additionalProperties": false
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false
+ }
+}
diff --git a/spec/fixtures/api/schemas/entities/github/pull_request.json b/spec/fixtures/api/schemas/entities/github/pull_request.json
new file mode 100644
index 00000000000..6c24879b800
--- /dev/null
+++ b/spec/fixtures/api/schemas/entities/github/pull_request.json
@@ -0,0 +1,108 @@
+{
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string"
+ },
+ "created_at": {
+ "type": "string"
+ },
+ "body": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "id": {
+ "type": "integer"
+ },
+ "number": {
+ "type": "integer"
+ },
+ "state": {
+ "type": "string"
+ },
+ "html_url": {
+ "type": "string"
+ },
+ "merged": {
+ "type": "boolean"
+ },
+ "merged_at": {
+ "type": [
+ "date",
+ "null"
+ ]
+ },
+ "closed_at": {
+ "type": [
+ "date",
+ "null"
+ ]
+ },
+ "updated_at": {
+ "type": "date"
+ },
+ "assignee": {
+ "$ref": "user.json"
+ },
+ "author": {
+ "$ref": "user.json"
+ },
+ "head": {
+ "type": "object",
+ "required": [
+ "label",
+ "ref",
+ "repo"
+ ],
+ "properties": {
+ "label": {
+ "type": "string"
+ },
+ "ref": {
+ "type": "string"
+ },
+ "repo": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "repository.json"
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ "base": {
+ "type": "object",
+ "required": [
+ "label",
+ "ref",
+ "repo"
+ ],
+ "properties": {
+ "label": {
+ "type": "string"
+ },
+ "ref": {
+ "type": "string"
+ },
+ "repo": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "repository.json"
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ "additionalProperties": false
+ }
+} \ No newline at end of file
diff --git a/spec/fixtures/api/schemas/entities/github/pull_requests.json b/spec/fixtures/api/schemas/entities/github/pull_requests.json
new file mode 100644
index 00000000000..4dddeb5fa20
--- /dev/null
+++ b/spec/fixtures/api/schemas/entities/github/pull_requests.json
@@ -0,0 +1,6 @@
+{
+ "type": "array",
+ "items": {
+ "$ref": "pull_request.json"
+ }
+}
diff --git a/spec/fixtures/api/schemas/entities/github/repositories.json b/spec/fixtures/api/schemas/entities/github/repositories.json
new file mode 100644
index 00000000000..26457901ef2
--- /dev/null
+++ b/spec/fixtures/api/schemas/entities/github/repositories.json
@@ -0,0 +1,16 @@
+{
+ "type": "array",
+ "properties" : {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "owner": {
+ "type": "object",
+ "required": ["login"],
+ "properties" : {
+ "login": { "type": "string" }
+ },
+ "additionalProperties": false
+ },
+ "additionalProperties": false
+ }
+}
diff --git a/spec/fixtures/api/schemas/entities/github/repository.json b/spec/fixtures/api/schemas/entities/github/repository.json
new file mode 100644
index 00000000000..44d7d059140
--- /dev/null
+++ b/spec/fixtures/api/schemas/entities/github/repository.json
@@ -0,0 +1,16 @@
+{
+ "type": "object",
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "owner": {
+ "type": "object",
+ "required": ["login"],
+ "properties" : {
+ "login": { "type": "string" }
+ },
+ "additionalProperties": false
+ },
+ "additionalProperties": false
+ }
+}
diff --git a/spec/fixtures/api/schemas/entities/github/user.json b/spec/fixtures/api/schemas/entities/github/user.json
new file mode 100644
index 00000000000..3d772a0c648
--- /dev/null
+++ b/spec/fixtures/api/schemas/entities/github/user.json
@@ -0,0 +1,13 @@
+{
+ "type": "object",
+ "required": ["id", "login", "url", "avatar_url"],
+ "properties" : {
+ "id": { "type": "integer" },
+ "login": { "type": "string" },
+ "url": { "type": "string" },
+ "avatar_url": { "type": "string" },
+ "html_url": { "type": "string" }
+ },
+ "additionalProperties": false
+}
+
diff --git a/spec/fixtures/api/schemas/jira_connect/author.json b/spec/fixtures/api/schemas/jira_connect/author.json
new file mode 100644
index 00000000000..bd2cff96d99
--- /dev/null
+++ b/spec/fixtures/api/schemas/jira_connect/author.json
@@ -0,0 +1,12 @@
+{
+ "type": "object",
+ "properties": {
+ "name": { "type": "string" },
+ "email": { "type": "string" },
+ "username": { "type": "string" },
+ "url": { "type": "uri" },
+ "avatar": { "type": "uri" }
+ },
+ "required": [ "name", "email" ],
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/jira_connect/branch.json b/spec/fixtures/api/schemas/jira_connect/branch.json
new file mode 100644
index 00000000000..c397d88fa91
--- /dev/null
+++ b/spec/fixtures/api/schemas/jira_connect/branch.json
@@ -0,0 +1,19 @@
+{
+ "type": "object",
+ "properties": {
+ "id": { "type": "string" },
+ "issueKeys": { "type": "array" },
+ "name": { "type": "string" },
+ "lastCommit": {
+ "$ref": "./commit.json"
+ },
+ "url": { "type": "uri" },
+ "createPullRequestUrl": { "type": "uri" },
+ "updateSequenceId": { "type": "integer" }
+ },
+ "required": [
+ "id", "issueKeys", "name", "lastCommit",
+ "url", "createPullRequestUrl", "updateSequenceId"
+ ],
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/jira_connect/commit.json b/spec/fixtures/api/schemas/jira_connect/commit.json
new file mode 100644
index 00000000000..794cf9ef365
--- /dev/null
+++ b/spec/fixtures/api/schemas/jira_connect/commit.json
@@ -0,0 +1,29 @@
+{
+ "type": "object",
+ "properties": {
+ "id": { "type": "string" },
+ "issueKeys": { "type": "array" },
+ "hash": { "type": "string" },
+ "displayId": { "type": "string" },
+ "message": { "type": "string" },
+ "flags": { "type": "array" },
+ "author": {
+ "$ref": "./author.json"
+ },
+ "fileCount": { "type": "integer" },
+ "files": {
+ "type": "array",
+ "items": {
+ "$ref": "./file.json"
+ }
+ },
+ "authorTimestamp": { "type": "timestamp" },
+ "url": { "type": "uri" },
+ "updateSequenceId": { "type": "integer" }
+ },
+ "required": [
+ "id", "issueKeys", "hash", "displayId", "message", "flags", "author",
+ "fileCount", "files", "authorTimestamp", "url", "updateSequenceId"
+ ],
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/jira_connect/file.json b/spec/fixtures/api/schemas/jira_connect/file.json
new file mode 100644
index 00000000000..34718991237
--- /dev/null
+++ b/spec/fixtures/api/schemas/jira_connect/file.json
@@ -0,0 +1,14 @@
+{
+ "type": "object",
+ "properties": {
+ "path": { "type": "string" },
+ "changeType": { "type": "string" },
+ "linesAdded": { "type": "integer" },
+ "linesRemoved": { "type": "integer" },
+ "url": { "type": "uri" }
+ },
+ "required": [
+ "path", "changeType", "linesAdded", "linesRemoved", "url"
+ ],
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/jira_connect/pull_request.json b/spec/fixtures/api/schemas/jira_connect/pull_request.json
new file mode 100644
index 00000000000..56ce6faf498
--- /dev/null
+++ b/spec/fixtures/api/schemas/jira_connect/pull_request.json
@@ -0,0 +1,26 @@
+{
+ "type": "object",
+ "properties": {
+ "id": { "type": "string" },
+ "issueKeys": { "type": "array" },
+ "displayId": { "type": "string" },
+ "title": { "type": "string" },
+ "author": {
+ "$ref": "./author.json"
+ },
+ "commentCount": { "type": "integer" },
+ "sourceBranch": { "type": "string" },
+ "destinationBranch": { "type": "string" },
+ "lastUpdate": { "type": "timestamp" },
+ "status": { "type": "string" },
+ "sourceBranchUrl": { "type": "uri" },
+ "url": { "type": "uri" },
+ "updateSequenceId": { "type": "integer" }
+ },
+ "required": [
+ "id", "issueKeys", "displayId", "title", "author", "commentCount",
+ "sourceBranch", "destinationBranch", "lastUpdate", "status",
+ "sourceBranchUrl", "url", "updateSequenceId"
+ ],
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/jira_connect/repository.json b/spec/fixtures/api/schemas/jira_connect/repository.json
new file mode 100644
index 00000000000..9e81d77bc6a
--- /dev/null
+++ b/spec/fixtures/api/schemas/jira_connect/repository.json
@@ -0,0 +1,34 @@
+{
+ "type": "object",
+ "properties": {
+ "id": { "type": "string" },
+ "name": { "type": "string" },
+ "description": { "type": ["string", "null"] },
+ "url": { "type": "uri" },
+ "avatar": { "type": "uri" },
+ "commits": {
+ "type": "array",
+ "items": {
+ "$ref": "./commit.json"
+ }
+ },
+ "branches": {
+ "type": "array",
+ "items": {
+ "$ref": "./branch.json"
+ }
+ },
+ "pullRequests": {
+ "type": "array",
+ "items": {
+ "$ref": "./pull_request.json"
+ }
+ },
+ "updateSequenceId": { "type": "integer" }
+ },
+ "required": [
+ "id", "name", "description", "url", "avatar",
+ "commits", "branches", "pullRequests", "updateSequenceId"
+ ],
+ "additionalProperties": false
+}
diff --git a/spec/frontend/integrations/edit/components/override_dropdown_spec.js b/spec/frontend/integrations/edit/components/override_dropdown_spec.js
index 32f3f8be08d..00ec8fc36cd 100644
--- a/spec/frontend/integrations/edit/components/override_dropdown_spec.js
+++ b/spec/frontend/integrations/edit/components/override_dropdown_spec.js
@@ -1,6 +1,8 @@
import { shallowMount } from '@vue/test-utils';
import { GlNewDropdown, GlLink } from '@gitlab/ui';
+import { createStore } from '~/integrations/edit/store';
+import { integrationLevels, overrideDropdownDescriptions } from '~/integrations/edit/constants';
import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue';
describe('OverrideDropdown', () => {
@@ -11,9 +13,16 @@ describe('OverrideDropdown', () => {
override: true,
};
- const createComponent = (props = {}) => {
+ const defaultAdminStateProps = {
+ integrationLevel: 'group',
+ };
+
+ const createComponent = (props = {}, adminStateProps = {}) => {
wrapper = shallowMount(OverrideDropdown, {
propsData: { ...defaultProps, ...props },
+ store: createStore({
+ adminState: { ...defaultAdminStateProps, ...adminStateProps },
+ }),
});
};
@@ -44,6 +53,45 @@ describe('OverrideDropdown', () => {
});
});
+ describe('integrationLevel is "project"', () => {
+ it('renders copy mentioning instance (as default fallback)', () => {
+ createComponent(
+ {},
+ {
+ integrationLevel: 'project',
+ },
+ );
+
+ expect(wrapper.text()).toContain(overrideDropdownDescriptions[integrationLevels.INSTANCE]);
+ });
+ });
+
+ describe('integrationLevel is "group"', () => {
+ it('renders copy mentioning group', () => {
+ createComponent(
+ {},
+ {
+ integrationLevel: 'group',
+ },
+ );
+
+ expect(wrapper.text()).toContain(overrideDropdownDescriptions[integrationLevels.GROUP]);
+ });
+ });
+
+ describe('integrationLevel is "instance"', () => {
+ it('renders copy mentioning instance', () => {
+ createComponent(
+ {},
+ {
+ integrationLevel: 'instance',
+ },
+ );
+
+ expect(wrapper.text()).toContain(overrideDropdownDescriptions[integrationLevels.INSTANCE]);
+ });
+ });
+
describe('learnMorePath is present', () => {
it('renders GlLink with correct link', () => {
createComponent({
diff --git a/spec/frontend/lib/utils/text_markdown_spec.js b/spec/frontend/lib/utils/text_markdown_spec.js
index 2e52958a828..1aaae80dcdf 100644
--- a/spec/frontend/lib/utils/text_markdown_spec.js
+++ b/spec/frontend/lib/utils/text_markdown_spec.js
@@ -1,4 +1,4 @@
-import { insertMarkdownText } from '~/lib/utils/text_markdown';
+import { insertMarkdownText, keypressNoteText } from '~/lib/utils/text_markdown';
describe('init markdown', () => {
let textArea;
@@ -115,14 +115,15 @@ describe('init markdown', () => {
describe('with selection', () => {
const text = 'initial selected value';
const selected = 'selected';
+ let selectedIndex;
+
beforeEach(() => {
textArea.value = text;
- const selectedIndex = text.indexOf(selected);
+ selectedIndex = text.indexOf(selected);
textArea.setSelectionRange(selectedIndex, selectedIndex + selected.length);
});
it('applies the tag to the selected value', () => {
- const selectedIndex = text.indexOf(selected);
const tag = '*';
insertMarkdownText({
@@ -153,6 +154,29 @@ describe('init markdown', () => {
expect(textArea.value).toEqual(text.replace(selected, `[${selected}](url)`));
});
+ it.each`
+ key | expected
+ ${'['} | ${`[${selected}]`}
+ ${'*'} | ${`**${selected}**`}
+ ${"'"} | ${`'${selected}'`}
+ ${'_'} | ${`_${selected}_`}
+ ${'`'} | ${`\`${selected}\``}
+ ${'"'} | ${`"${selected}"`}
+ ${'{'} | ${`{${selected}}`}
+ ${'('} | ${`(${selected})`}
+ ${'<'} | ${`<${selected}>`}
+ `('generates $expected when $key is pressed', ({ key, expected }) => {
+ const event = new KeyboardEvent('keydown', { key });
+
+ textArea.addEventListener('keydown', keypressNoteText);
+ textArea.dispatchEvent(event);
+
+ expect(textArea.value).toEqual(text.replace(selected, expected));
+
+ // cursor placement should be after selection + 2 tag lengths
+ expect(textArea.selectionStart).toBe(selectedIndex + expected.length);
+ });
+
describe('and text to be selected', () => {
const tag = '[{text}](url)';
const select = 'url';
@@ -178,7 +202,7 @@ describe('init markdown', () => {
it('selects the right text when multiple tags are present', () => {
const initialValue = `${tag} ${tag} ${selected}`;
textArea.value = initialValue;
- const selectedIndex = initialValue.indexOf(selected);
+ selectedIndex = initialValue.indexOf(selected);
textArea.setSelectionRange(selectedIndex, selectedIndex + selected.length);
insertMarkdownText({
textArea,
@@ -204,7 +228,7 @@ describe('init markdown', () => {
const initialValue = `text ${expectedUrl} text`;
textArea.value = initialValue;
- const selectedIndex = initialValue.indexOf(expectedUrl);
+ selectedIndex = initialValue.indexOf(expectedUrl);
textArea.setSelectionRange(selectedIndex, selectedIndex + expectedUrl.length);
insertMarkdownText({
diff --git a/spec/helpers/services_helper_spec.rb b/spec/helpers/services_helper_spec.rb
index f851e30f38e..d6b48b3d565 100644
--- a/spec/helpers/services_helper_spec.rb
+++ b/spec/helpers/services_helper_spec.rb
@@ -21,7 +21,8 @@ RSpec.describe ServicesHelper do
:comment_detail,
:trigger_events,
:fields,
- :inherit_from_id
+ :inherit_from_id,
+ :integration_level
)
end
end
diff --git a/spec/initializers/remove_active_job_execute_callback_spec.rb b/spec/initializers/remove_active_job_execute_callback_spec.rb
new file mode 100644
index 00000000000..e88b859aa77
--- /dev/null
+++ b/spec/initializers/remove_active_job_execute_callback_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'ActiveJob execute callback' do
+ it 'is removed in test environment' do
+ expect(ActiveJob::Callbacks.singleton_class.__callbacks[:execute].send(:chain).size).to eq(0)
+ end
+end
diff --git a/spec/lib/atlassian/jira_connect/client_spec.rb b/spec/lib/atlassian/jira_connect/client_spec.rb
new file mode 100644
index 00000000000..40ffec21b26
--- /dev/null
+++ b/spec/lib/atlassian/jira_connect/client_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Atlassian::JiraConnect::Client do
+ include StubRequests
+
+ subject { described_class.new('https://gitlab-test.atlassian.net', 'sample_secret') }
+
+ around do |example|
+ Timecop.freeze { example.run }
+ end
+
+ describe '#store_dev_info' do
+ it "calls the API with auth headers" do
+ expected_jwt = Atlassian::Jwt.encode(
+ Atlassian::Jwt.build_claims(
+ Atlassian::JiraConnect.app_key,
+ '/rest/devinfo/0.10/bulk',
+ 'POST'
+ ),
+ 'sample_secret'
+ )
+
+ stub_full_request('https://gitlab-test.atlassian.net/rest/devinfo/0.10/bulk', method: :post)
+ .with(
+ headers: {
+ 'Authorization' => "JWT #{expected_jwt}",
+ 'Content-Type' => 'application/json'
+ }
+ )
+
+ subject.store_dev_info(project: create(:project))
+ end
+ end
+end
diff --git a/spec/lib/atlassian/jira_connect/serializers/author_entity_spec.rb b/spec/lib/atlassian/jira_connect/serializers/author_entity_spec.rb
new file mode 100644
index 00000000000..f31cf929244
--- /dev/null
+++ b/spec/lib/atlassian/jira_connect/serializers/author_entity_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Atlassian::JiraConnect::Serializers::AuthorEntity do
+ subject { described_class.represent(user).as_json }
+
+ context 'when object is a User model' do
+ let(:user) { build_stubbed(:user) }
+
+ it 'exposes all fields' do
+ expect(subject.keys).to contain_exactly(:name, :email, :username, :url, :avatar)
+ end
+ end
+
+ context 'when object is a CommitAuthor struct from a commit' do
+ let(:user) { Atlassian::JiraConnect::Serializers::CommitEntity::CommitAuthor.new('Full Name', 'user@example.com') }
+
+ it 'exposes name and email only' do
+ expect(subject.keys).to contain_exactly(:name, :email)
+ end
+ end
+end
diff --git a/spec/lib/atlassian/jira_connect/serializers/branch_entity_spec.rb b/spec/lib/atlassian/jira_connect/serializers/branch_entity_spec.rb
new file mode 100644
index 00000000000..e69e2aae94c
--- /dev/null
+++ b/spec/lib/atlassian/jira_connect/serializers/branch_entity_spec.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Atlassian::JiraConnect::Serializers::BranchEntity do
+ let(:project) { create(:project, :repository) }
+ let(:branch) { project.repository.find_branch('improve/awesome') }
+
+ subject { described_class.represent(branch, project: project).as_json }
+
+ it 'sets the hash of the branch name as the id' do
+ expect(subject[:id]).to eq('bbfba9b197ace5da93d03382a7ce50081ae89d99faac1f2326566941288871ce')
+ end
+end
diff --git a/spec/lib/atlassian/jira_connect/serializers/repository_entity_spec.rb b/spec/lib/atlassian/jira_connect/serializers/repository_entity_spec.rb
new file mode 100644
index 00000000000..23ba1770827
--- /dev/null
+++ b/spec/lib/atlassian/jira_connect/serializers/repository_entity_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Atlassian::JiraConnect::Serializers::RepositoryEntity do
+ subject do
+ project = create(:project, :repository)
+ commits = [project.commit]
+ branches = [project.repository.find_branch('master')]
+ merge_requests = [create(:merge_request, source_project: project, target_project: project)]
+
+ described_class.represent(
+ project,
+ commits: commits,
+ branches: branches,
+ merge_requests: merge_requests
+ ).to_json
+ end
+
+ it { is_expected.to match_schema('jira_connect/repository') }
+end
diff --git a/spec/lib/atlassian/jira_issue_key_extractor_spec.rb b/spec/lib/atlassian/jira_issue_key_extractor_spec.rb
new file mode 100644
index 00000000000..ce29e03f818
--- /dev/null
+++ b/spec/lib/atlassian/jira_issue_key_extractor_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+RSpec.describe Atlassian::JiraIssueKeyExtractor do
+ describe '.has_keys?' do
+ subject { described_class.has_keys?(string) }
+
+ context 'when string contains Jira issue keys' do
+ let(:string) { 'Test some string TEST-01 with keys' }
+
+ it { is_expected.to eq(true) }
+ end
+
+ context 'when string does not contain Jira issue keys' do
+ let(:string) { 'string with no jira issue keys' }
+
+ it { is_expected.to eq(false) }
+ end
+ end
+
+ describe '#issue_keys' do
+ subject { described_class.new('TEST-01 Some A-100 issue title OTHER-02 ABC!-1 that mentions Jira issue').issue_keys }
+
+ it 'returns all valid Jira issue keys' do
+ is_expected.to contain_exactly('TEST-01', 'OTHER-02')
+ end
+
+ context 'when multiple strings are passed in' do
+ subject { described_class.new('TEST-01 Some A-100', 'issue title OTHER', '-02 ABC!-1 that mentions Jira issue').issue_keys }
+
+ it 'returns all valid Jira issue keys in any of those string' do
+ is_expected.to contain_exactly('TEST-01')
+ end
+ end
+ end
+end
diff --git a/spec/lib/constraints/jira_encoded_url_constrainer_spec.rb b/spec/lib/constraints/jira_encoded_url_constrainer_spec.rb
new file mode 100644
index 00000000000..70e649d35da
--- /dev/null
+++ b/spec/lib/constraints/jira_encoded_url_constrainer_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Constraints::JiraEncodedUrlConstrainer do
+ let(:namespace_id) { 'group' }
+ let(:project_id) { 'project' }
+ let(:path) { "/#{namespace_id}/#{project_id}" }
+ let(:request) { double(:request, path: path, params: { namespace_id: namespace_id, project_id: project_id }) }
+
+ describe '#matches?' do
+ subject { described_class.new.matches?(request) }
+
+ context 'when there is no /-/jira prefix and no encoded slash' do
+ it { is_expected.to eq(false) }
+ end
+
+ context 'when tree path contains encoded slash' do
+ let(:path) { "/#{namespace_id}/#{project_id}/tree/folder-with-#{Gitlab::Jira::Dvcs::ENCODED_SLASH}" }
+
+ it { is_expected.to eq(false) }
+ end
+
+ context 'when path has /-/jira prefix' do
+ let(:path) { "/-/jira/#{namespace_id}/#{project_id}" }
+
+ it { is_expected.to eq(true) }
+ end
+
+ context 'when project_id has encoded slash' do
+ let(:project_id) { "sub_group#{Gitlab::Jira::Dvcs::ENCODED_SLASH}sub_project" }
+
+ it { is_expected.to eq(true) }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/badge/coverage/template_spec.rb b/spec/lib/gitlab/badge/coverage/template_spec.rb
index 5a0adfd8e59..ba5c1b2ce6e 100644
--- a/spec/lib/gitlab/badge/coverage/template_spec.rb
+++ b/spec/lib/gitlab/badge/coverage/template_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe Gitlab::Badge::Coverage::Template do
context 'when its size is larger than the max allowed value' do
before do
- allow(badge).to receive(:customization).and_return({ key_text: 't' * 129 })
+ allow(badge).to receive(:customization).and_return({ key_text: 't' * 65 })
end
it 'returns default value' do
@@ -76,7 +76,7 @@ RSpec.describe Gitlab::Badge::Coverage::Template do
context 'when it is larger than the max allowed value' do
before do
- allow(badge).to receive(:customization).and_return({ key_width: 129 })
+ allow(badge).to receive(:customization).and_return({ key_width: 513 })
end
it 'returns default value' do
diff --git a/spec/lib/gitlab/badge/pipeline/template_spec.rb b/spec/lib/gitlab/badge/pipeline/template_spec.rb
index 2f0d0782369..c78e95852f3 100644
--- a/spec/lib/gitlab/badge/pipeline/template_spec.rb
+++ b/spec/lib/gitlab/badge/pipeline/template_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe Gitlab::Badge::Pipeline::Template do
context 'when its size is larger than the max allowed value' do
before do
- allow(badge).to receive(:customization).and_return({ key_text: 't' * 129 })
+ allow(badge).to receive(:customization).and_return({ key_text: 't' * 65 })
end
it 'returns default value' do
@@ -54,7 +54,7 @@ RSpec.describe Gitlab::Badge::Pipeline::Template do
context 'when it is larger than the max allowed value' do
before do
- allow(badge).to receive(:customization).and_return({ key_width: 129 })
+ allow(badge).to receive(:customization).and_return({ key_width: 513 })
end
it 'returns default value' do
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index 37b18506735..69a86225219 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -615,6 +615,7 @@ boards:
- assignee
- labels
- user_preferences
+- boards_epic_user_preferences
lists:
- user
- milestone
@@ -677,6 +678,7 @@ epic:
- resource_label_events
- user_mentions
- note_authors
+- boards_epic_user_preferences
epic_issue:
- epic
- issue
diff --git a/spec/lib/gitlab/jira/dvcs_spec.rb b/spec/lib/gitlab/jira/dvcs_spec.rb
new file mode 100644
index 00000000000..09e777b38ea
--- /dev/null
+++ b/spec/lib/gitlab/jira/dvcs_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Jira::Dvcs do
+ describe '.encode_slash' do
+ it 'replaces slash character' do
+ expect(described_class.encode_slash('a/b/c')).to eq('a@b@c')
+ end
+
+ it 'ignores path without slash' do
+ expect(described_class.encode_slash('foo')).to eq('foo')
+ end
+ end
+
+ describe '.decode_slash' do
+ it 'replaces slash character' do
+ expect(described_class.decode_slash('a@b@c')).to eq('a/b/c')
+ end
+
+ it 'ignores path without slash' do
+ expect(described_class.decode_slash('foo')).to eq('foo')
+ end
+ end
+
+ describe '.encode_project_name' do
+ let(:group) { create(:group)}
+ let(:project) { create(:project, group: group)}
+
+ context 'root group' do
+ it 'returns project path' do
+ expect(described_class.encode_project_name(project)).to eq(project.path)
+ end
+ end
+
+ context 'nested group' do
+ let(:group) { create(:group, :nested)}
+
+ it 'returns encoded project full path' do
+ expect(described_class.encode_project_name(project)).to eq(described_class.encode_slash(project.full_path))
+ end
+ end
+ end
+
+ describe '.restore_full_path' do
+ context 'project name is an encoded full path' do
+ it 'returns decoded project path' do
+ expect(described_class.restore_full_path(namespace: 'group1', project: 'group1@group2@project1')).to eq('group1/group2/project1')
+ end
+ end
+
+ context 'project name is not an encoded full path' do
+ it 'assumes project belongs to root namespace and returns full project path based on passed in namespace' do
+ expect(described_class.restore_full_path(namespace: 'group1', project: 'project1')).to eq('group1/project1')
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/jira/middleware_spec.rb b/spec/lib/gitlab/jira/middleware_spec.rb
new file mode 100644
index 00000000000..1fe22b145a6
--- /dev/null
+++ b/spec/lib/gitlab/jira/middleware_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Jira::Middleware do
+ let(:app) { double(:app) }
+ let(:middleware) { described_class.new(app) }
+ let(:jira_user_agent) { 'Jira DVCS Connector Vertigo/5.0.0-D20170810T012915' }
+
+ describe '.jira_dvcs_connector?' do
+ it 'returns true when DVCS connector' do
+ expect(described_class.jira_dvcs_connector?('HTTP_USER_AGENT' => jira_user_agent)).to eq(true)
+ end
+
+ it 'returns true if user agent starts with "Jira DVCS Connector"' do
+ expect(described_class.jira_dvcs_connector?('HTTP_USER_AGENT' => 'Jira DVCS Connector')).to eq(true)
+ end
+
+ it 'returns false when not DVCS connector' do
+ expect(described_class.jira_dvcs_connector?('HTTP_USER_AGENT' => 'pokemon')).to eq(false)
+ end
+ end
+
+ describe '#call' do
+ it 'adjusts HTTP_AUTHORIZATION env when request from Jira DVCS user agent' do
+ expect(app).to receive(:call).with('HTTP_USER_AGENT' => jira_user_agent,
+ 'HTTP_AUTHORIZATION' => 'Bearer hash-123')
+
+ middleware.call('HTTP_USER_AGENT' => jira_user_agent, 'HTTP_AUTHORIZATION' => 'token hash-123')
+ end
+
+ it 'does not change HTTP_AUTHORIZATION env when request is not from Jira DVCS user agent' do
+ env = { 'HTTP_USER_AGENT' => 'Mozilla/5.0', 'HTTP_AUTHORIZATION' => 'token hash-123' }
+
+ expect(app).to receive(:call).with(env)
+
+ middleware.call(env)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/middleware/same_site_cookies_spec.rb b/spec/lib/gitlab/middleware/same_site_cookies_spec.rb
index ad4d74132ab..2d1a9b2eee2 100644
--- a/spec/lib/gitlab/middleware/same_site_cookies_spec.rb
+++ b/spec/lib/gitlab/middleware/same_site_cookies_spec.rb
@@ -11,22 +11,21 @@ RSpec.describe Gitlab::Middleware::SameSiteCookies do
Class.new do
attr_reader :cookies, :user_agent
- def initialize(cookies, user_agent)
+ def initialize(cookies)
@cookies = cookies
- @user_agent = user_agent
end
def call(env)
[
200,
- { 'Set-Cookie' => cookies, 'User-Agent' => user_agent }.compact,
+ { 'Set-Cookie' => cookies },
['OK']
]
end
end
end
- let(:app) { mock_app.new(cookies, user_agent) }
+ let(:app) { mock_app.new(cookies) }
subject do
described_class.new(app)
@@ -36,7 +35,7 @@ RSpec.describe Gitlab::Middleware::SameSiteCookies do
let(:request) { Rack::MockRequest.new(subject) }
def do_request
- request.post('/some/path')
+ request.post('/some/path', { 'HTTP_USER_AGENT' => user_agent }.compact )
end
context 'without SSL enabled' do
@@ -79,6 +78,7 @@ RSpec.describe Gitlab::Middleware::SameSiteCookies do
"Chrome v41" | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2227.1 Safari/537.36" | true
"Chrome v50" | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/50.0.2348.1 Safari/537.36" | true
"Chrome v51" | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2718.15 Safari/537.36" | false
+ "Chrome v62" | "Mozilla/5.0 (Macintosh; Intel NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.62 Safari/537.36" | false
"Chrome v66" | "Mozilla/5.0 (Linux; Android 4.4.2; Avvio_793 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.126 Mobile Safari/537.36" | false
"Chrome v67" | "Mozilla/5.0 (Linux; Android 7.1.1; SM-J510F Build/NMF26X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3371.0 Mobile Safari/537.36" | true
"Chrome v85" | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36" | true
diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb
index 3444fe54cd6..69af6c523f5 100644
--- a/spec/lib/gitlab/usage_data_spec.rb
+++ b/spec/lib/gitlab/usage_data_spec.rb
@@ -286,6 +286,8 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
create(:issue, project: project, author: User.support_bot)
create(:note, project: project, noteable: issue, author: user)
create(:todo, project: project, target: issue, author: user)
+ create(:jira_service, :jira_cloud_service, active: true, project: create(:project, :jira_dvcs_cloud, creator: user))
+ create(:jira_service, active: true, project: create(:project, :jira_dvcs_server, creator: user))
end
expect(described_class.usage_activity_by_stage_plan({})).to include(
@@ -294,7 +296,10 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
projects: 2,
todos: 2,
service_desk_enabled_projects: 2,
- service_desk_issues: 2
+ service_desk_issues: 2,
+ projects_jira_active: 2,
+ projects_jira_dvcs_cloud_active: 2,
+ projects_jira_dvcs_server_active: 2
)
expect(described_class.usage_activity_by_stage_plan(described_class.last_28_days_time_period)).to include(
issues: 2,
@@ -302,7 +307,10 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
projects: 1,
todos: 1,
service_desk_enabled_projects: 1,
- service_desk_issues: 1
+ service_desk_issues: 1,
+ projects_jira_active: 1,
+ projects_jira_dvcs_cloud_active: 1,
+ projects_jira_dvcs_server_active: 1
)
end
end
diff --git a/spec/models/jira_connect_installation_spec.rb b/spec/models/jira_connect_installation_spec.rb
new file mode 100644
index 00000000000..8ef96114c45
--- /dev/null
+++ b/spec/models/jira_connect_installation_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe JiraConnectInstallation do
+ describe 'associations' do
+ it { is_expected.to have_many(:subscriptions).class_name('JiraConnectSubscription') }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:client_key) }
+ it { is_expected.to validate_uniqueness_of(:client_key) }
+ it { is_expected.to validate_presence_of(:shared_secret) }
+ it { is_expected.to validate_presence_of(:base_url) }
+
+ it { is_expected.to allow_value('https://test.atlassian.net').for(:base_url) }
+ it { is_expected.not_to allow_value('not/a/url').for(:base_url) }
+ end
+
+ describe '.for_project' do
+ let(:other_group) { create(:group) }
+ let(:parent_group) { create(:group) }
+ let(:group) { create(:group, parent: parent_group) }
+ let(:project) { create(:project, group: group) }
+
+ subject { described_class.for_project(project) }
+
+ it 'returns installations with subscriptions for project' do
+ sub_on_project_namespace = create(:jira_connect_subscription, namespace: group)
+ sub_on_ancestor_namespace = create(:jira_connect_subscription, namespace: parent_group)
+
+ # Subscription on other group that shouldn't be returned
+ create(:jira_connect_subscription, namespace: other_group)
+
+ expect(subject).to contain_exactly(sub_on_project_namespace.installation, sub_on_ancestor_namespace.installation)
+ end
+
+ it 'returns distinct installations' do
+ subscription = create(:jira_connect_subscription, namespace: group)
+ create(:jira_connect_subscription, namespace: parent_group, installation: subscription.installation)
+
+ expect(subject).to contain_exactly(subscription.installation)
+ end
+ end
+end
diff --git a/spec/models/jira_connect_subscription_spec.rb b/spec/models/jira_connect_subscription_spec.rb
new file mode 100644
index 00000000000..548c030f4c4
--- /dev/null
+++ b/spec/models/jira_connect_subscription_spec.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe JiraConnectSubscription do
+ describe 'associations' do
+ it { is_expected.to belong_to(:installation).class_name('JiraConnectInstallation') }
+ it { is_expected.to belong_to(:namespace).class_name('Namespace') }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:installation) }
+ it { is_expected.to validate_presence_of(:namespace) }
+ end
+end
diff --git a/spec/models/project_feature_usage_spec.rb b/spec/models/project_feature_usage_spec.rb
new file mode 100644
index 00000000000..908b98ee9c2
--- /dev/null
+++ b/spec/models/project_feature_usage_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ProjectFeatureUsage, type: :model do
+ describe '.jira_dvcs_integrations_enabled_count' do
+ it 'returns count of projects with Jira DVCS Cloud enabled' do
+ create(:project).feature_usage.log_jira_dvcs_integration_usage
+ create(:project).feature_usage.log_jira_dvcs_integration_usage
+
+ expect(described_class.with_jira_dvcs_integration_enabled.count).to eq(2)
+ end
+
+ it 'returns count of projects with Jira DVCS Server enabled' do
+ create(:project).feature_usage.log_jira_dvcs_integration_usage(cloud: false)
+ create(:project).feature_usage.log_jira_dvcs_integration_usage(cloud: false)
+
+ expect(described_class.with_jira_dvcs_integration_enabled(cloud: false).count).to eq(2)
+ end
+ end
+
+ describe '#log_jira_dvcs_integration_usage' do
+ let(:project) { create(:project) }
+
+ subject { project.feature_usage }
+
+ it 'logs Jira DVCS Cloud last sync' do
+ Timecop.freeze do
+ subject.log_jira_dvcs_integration_usage
+
+ expect(subject.jira_dvcs_server_last_sync_at).to be_nil
+ expect(subject.jira_dvcs_cloud_last_sync_at).to be_like_time(Time.current)
+ end
+ end
+
+ it 'logs Jira DVCS Server last sync' do
+ Timecop.freeze do
+ subject.log_jira_dvcs_integration_usage(cloud: false)
+
+ expect(subject.jira_dvcs_server_last_sync_at).to be_like_time(Time.current)
+ expect(subject.jira_dvcs_cloud_last_sync_at).to be_nil
+ end
+ end
+
+ context 'when log_jira_dvcs_integration_usage is called simultaneously for the same project' do
+ it 'logs the latest call' do
+ feature_usage = project.feature_usage
+ feature_usage.log_jira_dvcs_integration_usage
+ first_logged_at = feature_usage.jira_dvcs_cloud_last_sync_at
+
+ Timecop.freeze(1.hour.from_now) do
+ ProjectFeatureUsage.new(project_id: project.id).log_jira_dvcs_integration_usage
+ end
+
+ expect(feature_usage.reload.jira_dvcs_cloud_last_sync_at).to be > first_logged_at
+ end
+ end
+ end
+end
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 2c5121eb8f8..8ed0672af25 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -1362,6 +1362,36 @@ RSpec.describe Project do
end
end
+ describe '.with_active_jira_services' do
+ it 'returns the correct project' do
+ active_jira_service = create(:jira_service)
+ active_service = create(:service, active: true)
+
+ expect(described_class.with_active_jira_services).to include(active_jira_service.project)
+ expect(described_class.with_active_jira_services).not_to include(active_service.project)
+ end
+ end
+
+ describe '.with_jira_dvcs_cloud' do
+ it 'returns the correct project' do
+ jira_dvcs_cloud_project = create(:project, :jira_dvcs_cloud)
+ jira_dvcs_server_project = create(:project, :jira_dvcs_server)
+
+ expect(described_class.with_jira_dvcs_cloud).to include(jira_dvcs_cloud_project)
+ expect(described_class.with_jira_dvcs_cloud).not_to include(jira_dvcs_server_project)
+ end
+ end
+
+ describe '.with_jira_dvcs_server' do
+ it 'returns the correct project' do
+ jira_dvcs_server_project = create(:project, :jira_dvcs_server)
+ jira_dvcs_cloud_project = create(:project, :jira_dvcs_cloud)
+
+ expect(described_class.with_jira_dvcs_server).to include(jira_dvcs_server_project)
+ expect(described_class.with_jira_dvcs_server).not_to include(jira_dvcs_cloud_project)
+ end
+ end
+
describe '.cached_count', :use_clean_rails_memory_store_caching do
let(:group) { create(:group, :public) }
let!(:project1) { create(:project, :public, group: group) }
@@ -6068,6 +6098,18 @@ RSpec.describe Project do
end
end
+ describe '#jira_subscription_exists?' do
+ let(:project) { create(:project) }
+
+ subject { project.jira_subscription_exists? }
+
+ context 'jira connect subscription exists' do
+ let!(:jira_connect_subscription) { create(:jira_connect_subscription, namespace: project.namespace) }
+
+ it { is_expected.to eq(true) }
+ end
+ end
+
describe 'with services and chat names' do
subject { create(:project) }
diff --git a/spec/policies/group_policy_spec.rb b/spec/policies/group_policy_spec.rb
index 3e0ea164e3d..dbe444acb58 100644
--- a/spec/policies/group_policy_spec.rb
+++ b/spec/policies/group_policy_spec.rb
@@ -768,4 +768,48 @@ RSpec.describe GroupPolicy do
end
end
end
+
+ describe 'create_jira_connect_subscription' do
+ context 'admin' do
+ let(:current_user) { admin }
+
+ it { is_expected.to be_allowed(:create_jira_connect_subscription) }
+ end
+
+ context 'with owner' do
+ let(:current_user) { owner }
+
+ it { is_expected.to be_allowed(:create_jira_connect_subscription) }
+ end
+
+ context 'with maintainer' do
+ let(:current_user) { maintainer }
+
+ it { is_expected.to be_allowed(:create_jira_connect_subscription) }
+ end
+
+ context 'with reporter' do
+ let(:current_user) { reporter }
+
+ it { is_expected.to be_disallowed(:create_jira_connect_subscription) }
+ end
+
+ context 'with guest' do
+ let(:current_user) { guest }
+
+ it { is_expected.to be_disallowed(:create_jira_connect_subscription) }
+ end
+
+ context 'with non member' do
+ let(:current_user) { create(:user) }
+
+ it { is_expected.to be_disallowed(:create_jira_connect_subscription) }
+ end
+
+ context 'with anonymous' do
+ let(:current_user) { nil }
+
+ it { is_expected.to be_disallowed(:create_jira_connect_subscription) }
+ end
+ end
end
diff --git a/spec/policies/namespace_policy_spec.rb b/spec/policies/namespace_policy_spec.rb
index f2f411e48d6..8f71cf114c3 100644
--- a/spec/policies/namespace_policy_spec.rb
+++ b/spec/policies/namespace_policy_spec.rb
@@ -48,4 +48,30 @@ RSpec.describe NamespacePolicy do
it { is_expected.to be_disallowed(*owner_permissions) }
end
end
+
+ describe 'create_jira_connect_subscription' do
+ context 'admin' do
+ let(:current_user) { build_stubbed(:admin) }
+
+ context 'when admin mode enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed(:create_jira_connect_subscription) }
+ end
+
+ context 'when admin mode disabled' do
+ it { is_expected.to be_disallowed(:create_jira_connect_subscription) }
+ end
+ end
+
+ context 'owner' do
+ let(:current_user) { owner }
+
+ it { is_expected.to be_allowed(:create_jira_connect_subscription) }
+ end
+
+ context 'other user' do
+ let(:current_user) { build_stubbed(:user) }
+
+ it { is_expected.to be_disallowed(:create_jira_connect_subscription) }
+ end
+ end
end
diff --git a/spec/requests/api/v3/github_spec.rb b/spec/requests/api/v3/github_spec.rb
new file mode 100644
index 00000000000..86ddf4a78d8
--- /dev/null
+++ b/spec/requests/api/v3/github_spec.rb
@@ -0,0 +1,516 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::V3::Github do
+ let(:user) { create(:user) }
+ let(:unauthorized_user) { create(:user) }
+ let(:admin) { create(:user, :admin) }
+ let(:project) { create(:project, :repository, creator: user) }
+
+ before do
+ project.add_maintainer(user)
+ end
+
+ describe 'GET /orgs/:namespace/repos' do
+ it 'returns an empty array' do
+ group = create(:group)
+
+ jira_get v3_api("/orgs/#{group.path}/repos", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to eq([])
+ end
+
+ it 'returns 200 when namespace path include a dot' do
+ group = create(:group, path: 'foo.bar')
+
+ jira_get v3_api("/orgs/#{group.path}/repos", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ describe 'GET /user/repos' do
+ it 'returns an empty array' do
+ jira_get v3_api('/user/repos', user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to eq([])
+ end
+ end
+
+ shared_examples_for 'Jira-specific mimicked GitHub endpoints' do
+ describe 'GET /.../issues/:id/comments' do
+ let(:merge_request) do
+ create(:merge_request, source_project: project, target_project: project)
+ end
+
+ let!(:note) do
+ create(:note, project: project, noteable: merge_request)
+ end
+
+ context 'when user has access to the merge request' do
+ it 'returns an array of notes' do
+ jira_get v3_api("/repos/#{path}/issues/#{merge_request.id}/comments", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an(Array)
+ expect(json_response.size).to eq(1)
+ end
+ end
+
+ context 'when user has no access to the merge request' do
+ let(:project) { create(:project, :private) }
+
+ before do
+ project.add_guest(user)
+ end
+
+ it 'returns 404' do
+ jira_get v3_api("/repos/#{path}/issues/#{merge_request.id}/comments", user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ describe 'GET /.../pulls/:id/commits' do
+ it 'returns an empty array' do
+ jira_get v3_api("/repos/#{path}/pulls/xpto/commits", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to eq([])
+ end
+ end
+
+ describe 'GET /.../pulls/:id/comments' do
+ it 'returns an empty array' do
+ jira_get v3_api("/repos/#{path}/pulls/xpto/comments", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to eq([])
+ end
+ end
+ end
+
+ # Here we test that using /-/jira as namespace/project still works,
+ # since that is how old Jira setups will talk to us
+ context 'old /-/jira endpoints' do
+ it_behaves_like 'Jira-specific mimicked GitHub endpoints' do
+ let(:path) { '-/jira' }
+ end
+
+ it 'returns an empty Array for events' do
+ jira_get v3_api('/repos/-/jira/events', user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to eq([])
+ end
+ end
+
+ context 'new :namespace/:project jira endpoints' do
+ it_behaves_like 'Jira-specific mimicked GitHub endpoints' do
+ let(:path) { "#{project.namespace.path}/#{project.path}" }
+ end
+
+ describe 'GET /users/:username' do
+ let!(:user1) { create(:user, username: 'jane.porter') }
+
+ context 'user exists' do
+ it 'responds with the expected user' do
+ jira_get v3_api("/users/#{user.username}", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('entities/github/user')
+ end
+ end
+
+ context 'user does not exist' do
+ it 'responds with the expected status' do
+ jira_get v3_api('/users/unknown_user_name', user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'no rights to request user lists' do
+ before do
+ expect(Ability).to receive(:allowed?).with(unauthorized_user, :read_users_list, :global).and_return(false)
+ expect(Ability).to receive(:allowed?).at_least(:once).and_call_original
+ end
+
+ it 'responds with forbidden' do
+ jira_get v3_api("/users/#{user.username}", unauthorized_user)
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+ end
+
+ describe 'GET events' do
+ let(:group) { create(:group) }
+ let(:project) { create(:project, :empty_repo, path: 'project.with.dot', group: group) }
+ let(:events_path) { "/repos/#{group.path}/#{project.path}/events" }
+
+ context 'if there are no merge requests' do
+ it 'returns an empty array' do
+ jira_get v3_api(events_path, user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to eq([])
+ end
+ end
+
+ context 'if there is a merge request' do
+ let!(:merge_request) { create(:merge_request, source_project: project, target_project: project, author: user) }
+
+ it 'returns an event' do
+ jira_get v3_api(events_path, user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an(Array)
+ expect(json_response.size).to eq(1)
+ end
+ end
+
+ context 'if there are more merge requests' do
+ let!(:merge_request) { create(:merge_request, id: 10000, source_project: project, target_project: project, author: user) }
+ let!(:merge_request2) { create(:merge_request, id: 10001, source_project: project, source_branch: generate(:branch), target_project: project, author: user) }
+
+ it 'returns the expected amount of events' do
+ jira_get v3_api(events_path, user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an(Array)
+ expect(json_response.size).to eq(2)
+ end
+
+ it 'ensures each event has a unique id' do
+ jira_get v3_api(events_path, user)
+
+ ids = json_response.map { |event| event['id'] }.uniq
+ expect(ids.size).to eq(2)
+ end
+ end
+ end
+ end
+
+ describe 'repo pulls' do
+ let(:project2) { create(:project, :repository, creator: user) }
+ let(:assignee) { create(:user) }
+ let(:assignee2) { create(:user) }
+ let!(:merge_request) do
+ create(:merge_request, source_project: project, target_project: project, author: user, assignees: [assignee])
+ end
+
+ let!(:merge_request_2) do
+ create(:merge_request, source_project: project2, target_project: project2, author: user, assignees: [assignee, assignee2])
+ end
+
+ before do
+ project2.add_maintainer(user)
+ end
+
+ describe 'GET /-/jira/pulls' do
+ it 'returns an array of merge requests with github format' do
+ jira_get v3_api('/repos/-/jira/pulls', user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an(Array)
+ expect(json_response.size).to eq(2)
+ expect(response).to match_response_schema('entities/github/pull_requests')
+ end
+ end
+
+ describe 'GET /repos/:namespace/:project/pulls' do
+ it 'returns an array of merge requests for the proper project in github format' do
+ jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/pulls", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an(Array)
+ expect(json_response.size).to eq(1)
+ expect(response).to match_response_schema('entities/github/pull_requests')
+ end
+ end
+
+ describe 'GET /repos/:namespace/:project/pulls/:id' do
+ context 'when user has access to the merge requests' do
+ it 'returns the requested merge request in github format' do
+ jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/pulls/#{merge_request.id}", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('entities/github/pull_request')
+ end
+ end
+
+ context 'when user has no access to the merge request' do
+ it 'returns 404' do
+ project.add_guest(unauthorized_user)
+
+ jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/pulls/#{merge_request.id}", unauthorized_user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when instance admin' do
+ it 'returns the requested merge request in github format' do
+ jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/pulls/#{merge_request.id}", admin)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('entities/github/pull_request')
+ end
+ end
+ end
+ end
+
+ describe 'GET /users/:namespace/repos' do
+ let(:group) { create(:group, name: 'foo') }
+
+ def expect_project_under_namespace(projects, namespace, user)
+ jira_get v3_api("/users/#{namespace.path}/repos", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(response).to match_response_schema('entities/github/repositories')
+
+ projects.each do |project|
+ hash = json_response.find do |hash|
+ hash['name'] == ::Gitlab::Jira::Dvcs.encode_project_name(project)
+ end
+
+ raise "Project #{project.full_path} not present in response" if hash.nil?
+
+ expect(hash['owner']['login']).to eq(namespace.path)
+ end
+ expect(json_response.size).to eq(projects.size)
+ end
+
+ context 'group namespace' do
+ let(:project) { create(:project, group: group) }
+ let!(:project2) { create(:project, :public, group: group) }
+
+ it 'returns an array of projects belonging to group excluding the ones user is not directly a member of, even when public' do
+ expect_project_under_namespace([project], group, user)
+ end
+
+ context 'when instance admin' do
+ let(:user) { create(:user, :admin) }
+
+ it 'returns an array of projects belonging to group' do
+ expect_project_under_namespace([project, project2], group, user)
+ end
+
+ context 'with a private group' do
+ let(:group) { create(:group, :private) }
+ let!(:project2) { create(:project, :private, group: group) }
+
+ it 'returns an array of projects belonging to group' do
+ expect_project_under_namespace([project, project2], group, user)
+ end
+ end
+ end
+ end
+
+ context 'nested group namespace' do
+ let(:group) { create(:group, :nested) }
+ let!(:parent_group_project) { create(:project, group: group.parent, name: 'parent_group_project') }
+ let!(:child_group_project) { create(:project, group: group, name: 'child_group_project') }
+
+ before do
+ group.parent.add_maintainer(user)
+ end
+
+ it 'returns an array of projects belonging to group with github format' do
+ expect_project_under_namespace([parent_group_project, child_group_project], group.parent, user)
+ end
+
+ it 'avoids N+1 queries' do
+ jira_get v3_api("/users/#{group.parent.path}/repos", user)
+
+ control = ActiveRecord::QueryRecorder.new { jira_get v3_api("/users/#{group.parent.path}/repos", user) }
+
+ new_group = create(:group, parent: group.parent)
+ create(:project, :repository, group: new_group, creator: user)
+
+ expect { jira_get v3_api("/users/#{group.parent.path}/repos", user) }.not_to exceed_query_limit(control)
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context 'user namespace' do
+ let(:project) { create(:project, namespace: user.namespace) }
+
+ it 'returns an array of projects belonging to user namespace with github format' do
+ expect_project_under_namespace([project], user.namespace, user)
+ end
+ end
+
+ context 'namespace path includes a dot' do
+ let(:project) { create(:project, group: group) }
+ let(:group) { create(:group, name: 'foo.bar') }
+
+ before do
+ group.add_maintainer(user)
+ end
+
+ it 'returns an array of projects belonging to group with github format' do
+ expect_project_under_namespace([project], group, user)
+ end
+ end
+
+ context 'unauthenticated' do
+ it 'returns 401' do
+ jira_get v3_api('/users/foo/repos', nil)
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'namespace does not exist' do
+ it 'responds with not found status' do
+ jira_get v3_api('/users/noo/repos', user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ describe 'GET /repos/:namespace/:project/branches' do
+ context 'authenticated' do
+ context 'updating project feature usage' do
+ it 'counts Jira Cloud integration as enabled' do
+ user_agent = 'Jira DVCS Connector Vertigo/4.42.0'
+
+ Timecop.freeze do
+ jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/branches", user), user_agent
+
+ expect(project.reload.jira_dvcs_cloud_last_sync_at).to be_like_time(Time.now)
+ end
+ end
+
+ it 'counts Jira Server integration as enabled' do
+ user_agent = 'Jira DVCS Connector/3.2.4'
+
+ Timecop.freeze do
+ jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/branches", user), user_agent
+
+ expect(project.reload.jira_dvcs_server_last_sync_at).to be_like_time(Time.now)
+ end
+ end
+ end
+
+ it 'returns an array of project branches with github format' do
+ jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/branches", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an(Array)
+
+ expect(response).to match_response_schema('entities/github/branches')
+ end
+
+ it 'returns 200 when project path include a dot' do
+ project.update!(path: 'foo.bar')
+
+ jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/branches", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'returns 200 when namespace path include a dot' do
+ group = create(:group, path: 'foo.bar')
+ project = create(:project, :repository, group: group)
+ project.add_reporter(user)
+
+ jira_get v3_api("/repos/#{group.path}/#{project.path}/branches", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context 'unauthenticated' do
+ it 'returns 401' do
+ jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/branches", nil)
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'unauthorized' do
+ it 'returns 404 when lower access level' do
+ project.add_guest(unauthorized_user)
+
+ jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/branches", unauthorized_user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ describe 'GET /repos/:namespace/:project/commits/:sha' do
+ let(:commit) { project.repository.commit }
+ let(:commit_id) { commit.id }
+
+ context 'authenticated' do
+ it 'returns commit with github format' do
+ jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/commits/#{commit_id}", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('entities/github/commit')
+ end
+
+ it 'returns 200 when project path include a dot' do
+ project.update!(path: 'foo.bar')
+
+ jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/commits/#{commit_id}", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'returns 200 when namespace path include a dot' do
+ group = create(:group, path: 'foo.bar')
+ project = create(:project, :repository, group: group)
+ project.add_reporter(user)
+
+ jira_get v3_api("/repos/#{group.path}/#{project.path}/commits/#{commit_id}", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context 'unauthenticated' do
+ it 'returns 401' do
+ jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/commits/#{commit_id}", nil)
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'unauthorized' do
+ it 'returns 404 when lower access level' do
+ project.add_guest(unauthorized_user)
+
+ jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/commits/#{commit_id}",
+ unauthorized_user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ def jira_get(path, user_agent = 'Jira DVCS Connector/3.2.4')
+ get path, headers: { 'User-Agent' => user_agent }
+ end
+
+ def v3_api(path, user = nil, personal_access_token: nil, oauth_access_token: nil)
+ api(
+ path,
+ user,
+ version: 'v3',
+ personal_access_token: personal_access_token,
+ oauth_access_token: oauth_access_token
+ )
+ end
+end
diff --git a/spec/requests/jira_authorizations_spec.rb b/spec/requests/jira_authorizations_spec.rb
new file mode 100644
index 00000000000..24c6001814c
--- /dev/null
+++ b/spec/requests/jira_authorizations_spec.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Jira authorization requests' do
+ let(:user) { create :user }
+ let(:application) { create :oauth_application, scopes: 'api' }
+ let(:redirect_uri) { oauth_jira_callback_url(host: "http://www.example.com") }
+
+ def generate_access_grant
+ create :oauth_access_grant, application: application, resource_owner_id: user.id, redirect_uri: redirect_uri
+ end
+
+ describe 'POST access_token' do
+ let(:client_id) { application.uid }
+ let(:client_secret) { application.secret }
+
+ it 'returns values similar to a POST to /oauth/token' do
+ post_data = {
+ client_id: client_id,
+ client_secret: client_secret
+ }
+
+ post '/oauth/token', params: post_data.merge({
+ code: generate_access_grant.token,
+ grant_type: 'authorization_code',
+ redirect_uri: redirect_uri
+ })
+ oauth_response = json_response
+
+ post '/login/oauth/access_token', params: post_data.merge({
+ code: generate_access_grant.token
+ })
+ jira_response = response.body
+
+ access_token, scope, token_type = oauth_response.values_at('access_token', 'scope', 'token_type')
+ expect(jira_response).to eq("access_token=#{access_token}&scope=#{scope}&token_type=#{token_type}")
+ end
+
+ context 'when authorization fails' do
+ before do
+ post '/login/oauth/access_token', params: {
+ client_id: client_id,
+ client_secret: client_secret,
+ code: try(:code) || generate_access_grant.token
+ }
+ end
+
+ shared_examples 'an unauthorized request' do
+ it 'returns 401' do
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'when client_id is invalid' do
+ let(:client_id) { "invalid_id" }
+
+ it_behaves_like 'an unauthorized request'
+ end
+
+ context 'when client_secret is invalid' do
+ let(:client_secret) { "invalid_secret" }
+
+ it_behaves_like 'an unauthorized request'
+ end
+
+ context 'when code is invalid' do
+ let(:code) { "invalid_code" }
+
+ it 'returns bad request' do
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/jira_routing_spec.rb b/spec/requests/jira_routing_spec.rb
new file mode 100644
index 00000000000..a627eea33a8
--- /dev/null
+++ b/spec/requests/jira_routing_spec.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Jira referenced paths', type: :request do
+ using RSpec::Parameterized::TableSyntax
+
+ let(:user) { create(:user) }
+
+ let(:group) { create(:group, name: 'group') }
+ let(:sub_group) { create(:group, name: 'subgroup', parent: group) }
+
+ let!(:group_project) { create(:project, name: 'group_project', namespace: group) }
+ let!(:sub_group_project) { create(:project, name: 'sub_group_project', namespace: sub_group) }
+
+ before do
+ group.add_owner(user)
+
+ login_as user
+ end
+
+ def redirects_to_canonical_path(jira_path, redirect_path)
+ get(jira_path)
+
+ expect(response).to redirect_to(redirect_path)
+ end
+
+ context 'with encoded subgroup path' do
+ where(:jira_path, :redirect_path) do
+ '/group/group@sub_group@sub_group_project' | '/group/sub_group/sub_group_project'
+ '/group@sub_group/group@sub_group@sub_group_project' | '/group/sub_group/sub_group_project'
+ '/group/group@sub_group@sub_group_project/commit/1234567' | '/group/sub_group/sub_group_project/commit/1234567'
+ '/group/group@sub_group@sub_group_project/tree/1234567' | '/group/sub_group/sub_group_project/-/tree/1234567'
+ end
+
+ with_them do
+ context 'with legacy prefix' do
+ it 'redirects to canonical path' do
+ redirects_to_canonical_path "/-/jira#{jira_path}", redirect_path
+ end
+ end
+
+ it 'redirects to canonical path' do
+ redirects_to_canonical_path jira_path, redirect_path
+ end
+ end
+ end
+
+ context 'regular paths with legacy prefix' do
+ where(:jira_path, :redirect_path) do
+ '/-/jira/group/group_project' | '/group/group_project'
+ '/-/jira/group/group_project/commit/1234567' | '/group/group_project/commit/1234567'
+ '/-/jira/group/group_project/tree/1234567' | '/group/group_project/-/tree/1234567'
+ end
+
+ with_them do
+ it 'redirects to canonical path' do
+ redirects_to_canonical_path jira_path, redirect_path
+ end
+ end
+ end
+
+ context 'when tree path has an @' do
+ let(:path) { '/group/project/tree/folder-with-@' }
+
+ it 'does not do a redirect' do
+ get path
+
+ expect(response).not_to have_gitlab_http_status(:moved_permanently)
+ end
+ end
+end
diff --git a/spec/rubocop/cop/migration/safer_boolean_column_spec.rb b/spec/rubocop/cop/migration/safer_boolean_column_spec.rb
index 013f2edc5e9..72b817fde12 100644
--- a/spec/rubocop/cop/migration/safer_boolean_column_spec.rb
+++ b/spec/rubocop/cop/migration/safer_boolean_column_spec.rb
@@ -14,7 +14,7 @@ RSpec.describe RuboCop::Cop::Migration::SaferBooleanColumn, type: :rubocop do
allow(cop).to receive(:in_migration?).and_return(true)
end
- described_class::WHITELISTED_TABLES.each do |table|
+ described_class::SMALL_TABLES.each do |table|
context "for the #{table} table" do
sources_and_offense = [
["add_column :#{table}, :column, :boolean, default: true", 'should disallow nulls'],
@@ -59,14 +59,14 @@ RSpec.describe RuboCop::Cop::Migration::SaferBooleanColumn, type: :rubocop do
end
end
- it 'registers no offense for tables not listed in WHITELISTED_TABLES' do
+ it 'registers no offense for tables not listed in SMALL_TABLES' do
inspect_source("add_column :large_table, :column, :boolean")
expect(cop.offenses).to be_empty
end
it 'registers no offense for non-boolean columns' do
- table = described_class::WHITELISTED_TABLES.sample
+ table = described_class::SMALL_TABLES.sample
inspect_source("add_column :#{table}, :column, :string")
expect(cop.offenses).to be_empty
@@ -75,7 +75,7 @@ RSpec.describe RuboCop::Cop::Migration::SaferBooleanColumn, type: :rubocop do
context 'outside of migration' do
it 'registers no offense' do
- table = described_class::WHITELISTED_TABLES.sample
+ table = described_class::SMALL_TABLES.sample
inspect_source("add_column :#{table}, :column, :boolean")
expect(cop.offenses).to be_empty
diff --git a/spec/services/ci/destroy_expired_job_artifacts_service_spec.rb b/spec/services/ci/destroy_expired_job_artifacts_service_spec.rb
index 79443f16276..134fe2dd111 100644
--- a/spec/services/ci/destroy_expired_job_artifacts_service_spec.rb
+++ b/spec/services/ci/destroy_expired_job_artifacts_service_spec.rb
@@ -120,5 +120,25 @@ RSpec.describe Ci::DestroyExpiredJobArtifactsService, :clean_gitlab_redis_shared
expect { subject }.to change { Ci::JobArtifact.count }.by(-2)
end
end
+
+ context 'when artifact is a pipeline artifact' do
+ context 'when artifacts are expired' do
+ let!(:pipeline_artifact_1) { create(:ci_pipeline_artifact, expire_at: 1.week.ago) }
+ let!(:pipeline_artifact_2) { create(:ci_pipeline_artifact, expire_at: 1.week.ago) }
+
+ it 'destroys pipeline artifacts' do
+ expect { subject }.to change { Ci::PipelineArtifact.count }.by(-2)
+ end
+ end
+
+ context 'when artifacts are not expired' do
+ let!(:pipeline_artifact_1) { create(:ci_pipeline_artifact, expire_at: 2.days) }
+ let!(:pipeline_artifact_2) { create(:ci_pipeline_artifact, expire_at: 2.days) }
+
+ it 'do not destroy pipeline artifacts' do
+ expect { subject }.not_to change { Ci::PipelineArtifact.count }
+ end
+ end
+ end
end
end
diff --git a/spec/services/git/branch_push_service_spec.rb b/spec/services/git/branch_push_service_spec.rb
index d74d6be425b..5d73794c1ec 100644
--- a/spec/services/git/branch_push_service_spec.rb
+++ b/spec/services/git/branch_push_service_spec.rb
@@ -704,4 +704,68 @@ RSpec.describe Git::BranchPushService, services: true do
service.execute
service
end
+
+ context 'Jira Connect hooks' do
+ let_it_be(:project) { create(:project, :repository) }
+ let(:branch_to_sync) { nil }
+ let(:commits_to_sync) { [] }
+ let(:params) do
+ { change: { oldrev: oldrev, newrev: newrev, ref: ref } }
+ end
+
+ subject do
+ described_class.new(project, user, params)
+ end
+
+ shared_examples 'enqueues Jira sync worker' do
+ specify do
+ Sidekiq::Testing.fake! do
+ expect(JiraConnect::SyncBranchWorker).to receive(:perform_async)
+ .with(project.id, branch_to_sync, commits_to_sync)
+ .and_call_original
+
+ expect { subject.execute }.to change(JiraConnect::SyncBranchWorker.jobs, :size).by(1)
+ end
+ end
+ end
+
+ shared_examples 'does not enqueue Jira sync worker' do
+ specify do
+ Sidekiq::Testing.fake! do
+ expect { subject.execute }.not_to change(JiraConnect::SyncBranchWorker.jobs, :size)
+ end
+ end
+ end
+
+ context 'with a Jira subscription' do
+ before do
+ create(:jira_connect_subscription, namespace: project.namespace)
+ end
+
+ context 'branch name contains Jira issue key' do
+ let(:branch_to_sync) { 'branch-JIRA-123' }
+ let(:ref) { "refs/heads/#{branch_to_sync}" }
+
+ it_behaves_like 'enqueues Jira sync worker'
+ end
+
+ context 'commit message contains Jira issue key' do
+ let(:commits_to_sync) { [newrev] }
+
+ before do
+ allow_any_instance_of(Commit).to receive(:safe_message).and_return('Commit with key JIRA-123')
+ end
+
+ it_behaves_like 'enqueues Jira sync worker'
+ end
+
+ context 'branch name and commit message does not contain Jira issue key' do
+ it_behaves_like 'does not enqueue Jira sync worker'
+ end
+ end
+
+ context 'without a Jira subscription' do
+ it_behaves_like 'does not enqueue Jira sync worker'
+ end
+ end
end
diff --git a/spec/services/jira_connect/sync_service_spec.rb b/spec/services/jira_connect/sync_service_spec.rb
new file mode 100644
index 00000000000..e26ca30d0e1
--- /dev/null
+++ b/spec/services/jira_connect/sync_service_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe JiraConnect::SyncService do
+ describe '#execute' do
+ let_it_be(:project) { create(:project, :repository) }
+ let(:branches) { [project.repository.find_branch('master')] }
+ let(:commits) { project.commits_by(oids: %w[b83d6e3 5a62481]) }
+ let(:merge_requests) { [create(:merge_request, source_project: project, target_project: project)] }
+
+ subject do
+ described_class.new(project).execute(commits: commits, branches: branches, merge_requests: merge_requests)
+ end
+
+ before do
+ create(:jira_connect_subscription, namespace: project.namespace)
+ end
+
+ def expect_jira_client_call(return_value = { 'status': 'success' })
+ expect_next_instance_of(Atlassian::JiraConnect::Client) do |instance|
+ expect(instance).to receive(:store_dev_info).with(
+ project: project,
+ commits: commits,
+ branches: [instance_of(Gitlab::Git::Branch)],
+ merge_requests: merge_requests
+ ).and_return(return_value)
+ end
+ end
+
+ def expect_log(type, message)
+ expect(Gitlab::ProjectServiceLogger)
+ .to receive(type).with(
+ message: 'response from jira dev_info api',
+ integration: 'JiraConnect',
+ project_id: project.id,
+ project_path: project.full_path,
+ jira_response: message&.to_json
+ )
+ end
+
+ it 'calls Atlassian::JiraConnect::Client#store_dev_info and logs the response' do
+ expect_jira_client_call
+
+ expect_log(:info, { 'status': 'success' })
+
+ subject
+ end
+
+ context 'when request returns an error' do
+ it 'logs the response as an error' do
+ expect_jira_client_call({
+ 'errorMessages' => ['some error message']
+ })
+
+ expect_log(:error, { 'errorMessages' => ['some error message'] })
+
+ subject
+ end
+ end
+ end
+end
diff --git a/spec/services/jira_connect_subscriptions/create_service_spec.rb b/spec/services/jira_connect_subscriptions/create_service_spec.rb
new file mode 100644
index 00000000000..77e758cf6fe
--- /dev/null
+++ b/spec/services/jira_connect_subscriptions/create_service_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe JiraConnectSubscriptions::CreateService do
+ let(:installation) { create(:jira_connect_installation) }
+ let(:current_user) { create(:user) }
+ let(:group) { create(:group) }
+ let(:path) { group.full_path }
+
+ subject { described_class.new(installation, current_user, namespace_path: path).execute }
+
+ before do
+ group.add_maintainer(current_user)
+ end
+
+ shared_examples 'a failed execution' do
+ it 'does not create a subscription' do
+ expect { subject }.not_to change { installation.subscriptions.count }
+ end
+
+ it 'returns an error status' do
+ expect(subject[:status]).to eq(:error)
+ end
+ end
+
+ context 'when user does have access' do
+ it 'creates a subscription' do
+ expect { subject }.to change { installation.subscriptions.count }.from(0).to(1)
+ end
+
+ it 'returns success' do
+ expect(subject[:status]).to eq(:success)
+ end
+ end
+
+ context 'when path is invalid' do
+ let(:path) { 'some_invalid_namespace_path' }
+
+ it_behaves_like 'a failed execution'
+ end
+
+ context 'when user does not have access' do
+ subject { described_class.new(installation, create(:user), namespace_path: path).execute }
+
+ it_behaves_like 'a failed execution'
+ end
+end
diff --git a/spec/services/merge_requests/base_service_spec.rb b/spec/services/merge_requests/base_service_spec.rb
new file mode 100644
index 00000000000..bb7b70f1ba2
--- /dev/null
+++ b/spec/services/merge_requests/base_service_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe MergeRequests::BaseService do
+ include ProjectForksHelper
+
+ let_it_be(:project) { create(:project, :repository) }
+ let(:title) { 'Awesome merge_request' }
+ let(:params) do
+ {
+ title: title,
+ description: 'please fix',
+ source_branch: 'feature',
+ target_branch: 'master'
+ }
+ end
+
+ subject { MergeRequests::CreateService.new(project, project.owner, params) }
+
+ describe '#execute_hooks' do
+ shared_examples 'enqueues Jira sync worker' do
+ it do
+ Sidekiq::Testing.fake! do
+ expect { subject.execute }.to change(JiraConnect::SyncMergeRequestWorker.jobs, :size).by(1)
+ end
+ end
+ end
+
+ shared_examples 'does not enqueue Jira sync worker' do
+ it do
+ Sidekiq::Testing.fake! do
+ expect { subject.execute }.not_to change(JiraConnect::SyncMergeRequestWorker.jobs, :size)
+ end
+ end
+ end
+
+ context 'with a Jira subscription' do
+ before do
+ create(:jira_connect_subscription, namespace: project.namespace)
+ end
+
+ context 'MR contains Jira issue key' do
+ let(:title) { 'Awesome merge_request with issue JIRA-123' }
+
+ it_behaves_like 'enqueues Jira sync worker'
+ end
+
+ context 'MR does not contain Jira issue key' do
+ it_behaves_like 'does not enqueue Jira sync worker'
+ end
+ end
+
+ context 'without a Jira subscription' do
+ it_behaves_like 'does not enqueue Jira sync worker'
+ end
+ end
+end
diff --git a/spec/support/helpers/usage_data_helpers.rb b/spec/support/helpers/usage_data_helpers.rb
index fab775dd404..61922f4656b 100644
--- a/spec/support/helpers/usage_data_helpers.rb
+++ b/spec/support/helpers/usage_data_helpers.rb
@@ -88,6 +88,8 @@ module UsageDataHelpers
projects_jira_active
projects_jira_server_active
projects_jira_cloud_active
+ projects_jira_dvcs_cloud_active
+ projects_jira_dvcs_server_active
projects_slack_active
projects_slack_slash_commands_active
projects_custom_issue_tracker_active
diff --git a/spec/workers/jira_connect/sync_branch_worker_spec.rb b/spec/workers/jira_connect/sync_branch_worker_spec.rb
new file mode 100644
index 00000000000..2da3ea9d256
--- /dev/null
+++ b/spec/workers/jira_connect/sync_branch_worker_spec.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe JiraConnect::SyncBranchWorker do
+ describe '#perform' do
+ let_it_be(:project) { create(:project, :repository) }
+ let(:project_id) { project.id }
+ let(:branch_name) { 'master' }
+ let(:commit_shas) { %w(b83d6e3 5a62481) }
+
+ subject { described_class.new.perform(project_id, branch_name, commit_shas) }
+
+ def expect_jira_sync_service_execute(args)
+ expect_next_instance_of(JiraConnect::SyncService) do |instance|
+ expect(instance).to receive(:execute).with(args)
+ end
+ end
+
+ it 'calls JiraConnect::SyncService#execute' do
+ expect_jira_sync_service_execute(
+ branches: [instance_of(Gitlab::Git::Branch)],
+ commits: project.commits_by(oids: commit_shas)
+ )
+
+ subject
+ end
+
+ context 'without branch name' do
+ let(:branch_name) { nil }
+
+ it 'calls JiraConnect::SyncService#execute' do
+ expect_jira_sync_service_execute(
+ branches: nil,
+ commits: project.commits_by(oids: commit_shas)
+ )
+
+ subject
+ end
+ end
+
+ context 'without commits' do
+ let(:commit_shas) { nil }
+
+ it 'calls JiraConnect::SyncService#execute' do
+ expect_jira_sync_service_execute(
+ branches: [instance_of(Gitlab::Git::Branch)],
+ commits: nil
+ )
+
+ subject
+ end
+ end
+
+ context 'when project no longer exists' do
+ let(:project_id) { non_existing_record_id }
+
+ it 'does not call JiraConnect::SyncService' do
+ expect(JiraConnect::SyncService).not_to receive(:new)
+
+ subject
+ end
+ end
+ end
+end
diff --git a/spec/workers/jira_connect/sync_merge_request_worker_spec.rb b/spec/workers/jira_connect/sync_merge_request_worker_spec.rb
new file mode 100644
index 00000000000..764201e750a
--- /dev/null
+++ b/spec/workers/jira_connect/sync_merge_request_worker_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe JiraConnect::SyncMergeRequestWorker do
+ describe '#perform' do
+ let(:merge_request) { create(:merge_request) }
+ let(:merge_request_id) { merge_request.id }
+
+ subject { described_class.new.perform(merge_request_id) }
+
+ it 'calls JiraConnect::SyncService#execute' do
+ expect_next_instance_of(JiraConnect::SyncService) do |service|
+ expect(service).to receive(:execute).with(merge_requests: [merge_request])
+ end
+
+ subject
+ end
+
+ context 'when MR no longer exists' do
+ let(:merge_request_id) { non_existing_record_id }
+
+ it 'does not call JiraConnect::SyncService' do
+ expect(JiraConnect::SyncService).not_to receive(:new)
+
+ subject
+ end
+ end
+ end
+end
diff --git a/yarn.lock b/yarn.lock
index cb02879d4cc..ee67bcbbf6d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -848,10 +848,10 @@
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.161.0.tgz#661e8d19862dfba0e4c558e2eb6d64b402c1453e"
integrity sha512-qsbboEICn08ZoEoAX/TuYygsFaXlzsCY+CfmdOzqvJbOdfHhVXmrJBxd2hP2qqjTZm2PkbRRmn+03+ce1jvatQ==
-"@gitlab/ui@20.12.1":
- version "20.12.1"
- resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-20.12.1.tgz#7ff01a52c777168feac764f94a9d9e024c157b02"
- integrity sha512-X7ICu/gVK48ok3SQyH9bOE5zaVGIb8nN0Oz+pKhTz2PoZz7e3Js5ZSQFrb4UQmkQGYIOZiSj+NCeKlpmzPYk2g==
+"@gitlab/ui@20.13.0":
+ version "20.13.0"
+ resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-20.13.0.tgz#03e50f42ff25777f0538cea4348b40700c73a247"
+ integrity sha512-00NxjSmFS78rUNtcqhKfmf9Ip8YjBMoO3JHY8a6sacYVJ+a+UKHvsi1ksCn7F5elSIZIuVmngSvIebw0LUqvpw==
dependencies:
"@babel/standalone" "^7.0.0"
"@gitlab/vue-toasted" "^1.3.0"