diff options
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" |