Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-09-01 12:11:01 +00:00
parent a928c5170f
commit 08b3b98051
162 changed files with 4527 additions and 128 deletions

1
.gitignore vendored
View File

@ -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

View File

@ -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
##################################################

View File

@ -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

View File

@ -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'

View File

@ -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"

View File

@ -1 +1 @@
e9860f7988a2c87638abf695d8613e3096312857
851da3925944b969da7f87057ba8da8274d5c18d

View File

@ -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>

View File

@ -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.',
),
};

View File

@ -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),
};
}

View File

@ -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);

View File

@ -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');
}

View File

@ -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>

View File

@ -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>

View File

@ -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;
}
}
}

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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')

View File

@ -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) }

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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'

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,5 @@
---
title: Adjust badge key text and width limits
merge_request: 40199
author: Fabian Schneider @fabsrc
type: changed

View File

@ -0,0 +1,5 @@
---
title: Remove the expiry on user passwords after a user resets their password
merge_request: 40712
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Surround selected text in markdown fields on certain key presses
merge_request: 37151
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Add index for expire_at to ci_pipeline_artifacts
merge_request: 39882
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Move Jira Development Panel integration to Core
merge_request: 40485
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Add table for storing user settings for board epic swimlanes
merge_request: 40360
author:
type: added

View File

@ -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:

View File

@ -3,3 +3,4 @@
Feature.register_feature_groups
Feature.register_definitions
Feature.register_hot_reloader unless Rails.configuration.cache_classes

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
1ee7ae93dde7099f78cd6218b5419a34b2cfebe196521bcbee1583e31f19ffda

View File

@ -0,0 +1 @@
26fe286e565f776f64ae8b6b0ad91ef1d3bf2195384f44f8b093a1b66ee0d05d

View File

@ -0,0 +1 @@
deb88efebc989a014b6ecaca4a91624d1b21f34c85cbf6d3460363f1b498b427

View File

@ -0,0 +1 @@
8fc437f09321cfe29262075009bce6f7b0047c2291df4a29bcc304c6dd54d27d

View File

@ -0,0 +1 @@
85b7ffba53c9cec30e9778dd806277ca8e9877c9a18dc1d6004402c0e66b8ef1

View File

@ -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;

View File

@ -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")
}
"""

View File

@ -7866,10 +7866,28 @@
"description": "Represents a DAST scanner profile.",
"fields": [
{
"name": "id",
"name": "globalId",
"description": "ID of the DAST scanner profile",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "DastScannerProfileID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "id",
"description": "ID of the DAST scanner profile. Deprecated in 13.4: Use `global_id`",
"args": [
],
"type": {
"kind": "NON_NULL",
@ -7880,8 +7898,8 @@
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
"isDeprecated": true,
"deprecationReason": "Use `global_id`. Deprecated in 13.4"
},
{
"name": "profileName",
@ -8115,18 +8133,32 @@
"deprecationReason": null
},
{
"name": "id",
"name": "globalId",
"description": "ID of the scanner profile.",
"args": [
],
"type": {
"kind": "SCALAR",
"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": false,
"deprecationReason": null
"isDeprecated": true,
"deprecationReason": "Use `global_id`. Deprecated in 13.4"
}
],
"inputFields": null,

View File

@ -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

View File

@ -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
}
```

View File

@ -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.

View File

@ -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).

View File

@ -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

217
lib/api/github/entities.rb Normal file
View File

@ -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

232
lib/api/v3/github.rb Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

48
lib/gitlab/jira/dvcs.rb Normal file
View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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 ""

View File

@ -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",

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

Some files were not shown because too many files have changed in this diff Show More