......@@ -268,6 +268,7 @@ Rails/Presence:
- 'app/models/clusters/platforms/kubernetes.rb'
- 'app/models/concerns/mentionable.rb'
- 'app/models/concerns/token_authenticatable.rb'
- 'app/models/project_services/hipchat_service.rb'
- 'app/models/project_services/irker_service.rb'
- 'app/models/project_services/jira_service.rb'
- 'app/models/project_services/kubernetes_service.rb'
......
......
......@@ -201,6 +201,9 @@ gem 'connection_pool', '~> 2.0'
# Discord integration
gem 'discordrb-webhooks-blackst0ne', '~> 3.3', require: false
# HipChat integration
gem 'hipchat', '~> 1.5.0'
# JIRA integration
gem 'jira-ruby', '~> 1.4'
......
......
......@@ -366,6 +366,9 @@ GEM
hashie (>= 3.0)
health_check (2.6.0)
rails (>= 4.0)
hipchat (1.5.2)
httparty
mimemagic
html-pipeline (2.8.4)
activesupport (>= 2)
nokogiri (>= 1.4)
......@@ -1040,6 +1043,7 @@ DEPENDENCIES
hangouts-chat (~> 0.0.5)
hashie-forbidden_attributes
health_check (~> 2.6.0)
hipchat (~> 1.5.0)
html-pipeline (~> 2.8)
html2text
httparty (~> 0.16.4)
......
......
......@@ -15,7 +15,7 @@ const BreakpointInstance = {
return breakpoint;
},
isDesktop() {
return ['lg', 'md'].includes(this.getBreakpointSize);
return ['lg', 'md'].includes(this.getBreakpointSize());
},
};
......
......
......@@ -64,6 +64,11 @@ export default {
required: false,
default: '',
},
isFluidLayout: {
type: Boolean,
required: false,
default: false,
},
},
data() {
const treeWidth =
......@@ -116,7 +121,7 @@ export default {
return this.treeWidth <= TREE_HIDE_STATS_WIDTH;
},
isLimitedContainer() {
return !this.showTreeList && !this.isParallelView;
return !this.showTreeList && !this.isParallelView && !this.isFluidLayout;
},
},
watch: {
......
......
......@@ -49,4 +49,4 @@ export const TYPE_KEY = 'type';
export const LEFT_LINE_KEY = 'left';
export const CENTERED_LIMITED_CONTAINER_CLASSES =
'container-limited limit-container-width mx-auto px-3';
'container-limited limit-container-width mx-lg-auto px-3';
......@@ -71,6 +71,7 @@ export default function initDiffsApp(store) {
helpPagePath: dataset.helpPagePath,
currentUser: JSON.parse(dataset.currentUserData) || {},
changesEmptyStateIllustration: dataset.changesEmptyStateIllustration,
isFluidLayout: parseBoolean(dataset.isFluidLayout),
};
},
computed: {
......@@ -97,6 +98,7 @@ export default function initDiffsApp(store) {
helpPagePath: this.helpPagePath,
shouldShow: this.activeTab === 'diffs',
changesEmptyStateIllustration: this.changesEmptyStateIllustration,
isFluidLayout: this.isFluidLayout,
},
});
},
......
......
......@@ -7,6 +7,8 @@ export default {
});
},
[types.SET_MERGE_REQUEST](state, { projectPath, mergeRequestId, mergeRequest }) {
const existingMergeRequest = state.projects[projectPath].mergeRequests[mergeRequestId] || {};
Object.assign(state.projects[projectPath], {
mergeRequests: {
[mergeRequestId]: {
......@@ -15,6 +17,7 @@ export default {
changes: [],
versions: [],
baseCommitSha: null,
...existingMergeRequest,
},
},
});
......
......
......@@ -54,9 +54,13 @@ export default elements => {
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
const listenerAddedAttr = 'data-mr-listener-added';
mrLinks.forEach(el => {
el.addEventListener('mouseenter', handleMRPopoverMount(apolloProvider));
if (!el.getAttribute(listenerAddedAttr)) {
el.addEventListener('mouseenter', handleMRPopoverMount(apolloProvider));
el.setAttribute(listenerAddedAttr, true);
}
});
}
};
......@@ -67,6 +67,10 @@
}
}
.classification-label {
background-color: $red-500;
}
.toggle-wrapper {
margin-top: 5px;
}
......
......
......@@ -124,7 +124,9 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
end
def visible_application_setting_attributes
ApplicationSettingsHelper.visible_attributes + [
[
*::ApplicationSettingsHelper.visible_attributes,
*::ApplicationSettingsHelper.external_authorization_service_attributes,
:domain_blacklist_file,
disabled_oauth_sign_in_sources: [],
import_sources: [],
......
......
# frozen_string_literal: true
module ProjectUnauthorized
extend ActiveSupport::Concern
# EE would override this
def project_unauthorized_proc
# no-op
lambda do |project|
if project
label = project.external_authorization_classification_label
rejection_reason = nil
unless ::Gitlab::ExternalAuthorization.access_allowed?(current_user, label)
rejection_reason = ::Gitlab::ExternalAuthorization.rejection_reason(current_user, label)
rejection_reason ||= _('External authorization denied access to this project')
end
if rejection_reason
access_denied!(rejection_reason)
end
end
end
end
end
......@@ -345,6 +345,7 @@ class ProjectsController < Projects::ApplicationController
:container_registry_enabled,
:default_branch,
:description,
:external_authorization_classification_label,
:import_url,
:issues_tracker,
:issues_tracker_id,
......
......
......@@ -119,6 +119,39 @@ module ApplicationSettingsHelper
options_for_select(options, selected)
end
def external_authorization_description
_("If enabled, access to projects will be validated on an external service"\
" using their classification label.")
end
def external_authorization_timeout_help_text
_("Time in seconds GitLab will wait for a response from the external "\
"service. When the service does not respond in time, access will be "\
"denied.")
end
def external_authorization_url_help_text
_("When leaving the URL blank, classification labels can still be "\
"specified without disabling cross project features or performing "\
"external authorization checks.")
end
def external_authorization_client_certificate_help_text
_("The X509 Certificate to use when mutual TLS is required to communicate "\
"with the external authorization service. If left blank, the server "\
"certificate is still validated when accessing over HTTPS.")
end
def external_authorization_client_key_help_text
_("The private key to use when a client certificate is provided. This value "\
"is encrypted at rest.")
end
def external_authorization_client_pass_help_text
_("The passphrase required to decrypt the private key. This is optional "\
"and the value is encrypted at rest.")
end
def visible_attributes
[
:admin_notification_email,
......@@ -238,6 +271,18 @@ module ApplicationSettingsHelper
]
end
def external_authorization_service_attributes
[
:external_auth_client_cert,
:external_auth_client_key,
:external_auth_client_key_pass,
:external_authorization_service_default_label,
:external_authorization_service_enabled,
:external_authorization_service_timeout,
:external_authorization_service_url
]
end
def expanded_by_default?
Rails.env.test?
end
......
......
......@@ -299,6 +299,16 @@ module ProjectsHelper
}.to_json
end
def external_classification_label_help_message
default_label = ::Gitlab::CurrentSettings.current_application_settings
.external_authorization_service_default_label
s_(
"ExternalAuthorizationService|When no classification label is set the "\
"default label `%{default_label}` will be used."
) % { default_label: default_label }
end
private
def get_project_nav_tabs(project, current_user)
......
......
......@@ -213,6 +213,40 @@ class ApplicationSetting < ApplicationRecord
validate :terms_exist, if: :enforce_terms?
validates :external_authorization_service_default_label,
presence: true,
if: :external_authorization_service_enabled
validates :external_authorization_service_url,
url: true, allow_blank: true,
if: :external_authorization_service_enabled
validates :external_authorization_service_timeout,
numericality: { greater_than: 0, less_than_or_equal_to: 10 },
if: :external_authorization_service_enabled
validates :external_auth_client_key,
presence: true,
if: -> (setting) { setting.external_auth_client_cert.present? }
validates_with X509CertificateCredentialsValidator,
certificate: :external_auth_client_cert,
pkey: :external_auth_client_key,
pass: :external_auth_client_key_pass,
if: -> (setting) { setting.external_auth_client_cert.present? }
attr_encrypted :external_auth_client_key,
mode: :per_attribute_iv,
key: Settings.attr_encrypted_db_key_base_truncated,
algorithm: 'aes-256-gcm',
encode: true
attr_encrypted :external_auth_client_key_pass,
mode: :per_attribute_iv,
key: Settings.attr_encrypted_db_key_base_truncated,
algorithm: 'aes-256-gcm',
encode: true
before_validation :ensure_uuid!
before_validation :strip_sentry_values
......
......
......@@ -230,7 +230,13 @@ class Issue < ApplicationRecord
def visible_to_user?(user = nil)
return false unless project && project.feature_available?(:issues, user)
user ? readable_by?(user) : publicly_visible?
return publicly_visible? unless user
return false unless readable_by?(user)
user.full_private_access? ||
::Gitlab::ExternalAuthorization.access_allowed?(
user, project.external_authorization_classification_label)
end
def check_for_spam?
......@@ -298,7 +304,7 @@ class Issue < ApplicationRecord
# Returns `true` if this Issue is visible to everybody.
def publicly_visible?
project.public? && !confidential?
project.public? && !confidential? && !::Gitlab::ExternalAuthorization.enabled?
end
def expire_etag_cache
......
......
......@@ -146,6 +146,7 @@ class Project < ApplicationRecord
has_one :pipelines_email_service
has_one :irker_service
has_one :pivotaltracker_service
has_one :hipchat_service
has_one :flowdock_service
has_one :assembla_service
has_one :asana_service
......@@ -2035,6 +2036,11 @@ class Project < ApplicationRecord
fetch_branch_allows_collaboration(user, branch_name)
end
def external_authorization_classification_label
super || ::Gitlab::CurrentSettings.current_application_settings
.external_authorization_service_default_label
end
def licensed_features
[]
end
......
......
# frozen_string_literal: true
class HipchatService < Service
include ActionView::Helpers::SanitizeHelper
MAX_COMMITS = 3
HIPCHAT_ALLOWED_TAGS = %w[
a b i strong em br img pre code
table th tr td caption colgroup col thead tbody tfoot
ul ol li dl dt dd
].freeze
prop_accessor :token, :room, :server, :color, :api_version
boolean_accessor :notify_only_broken_pipelines, :notify
validates :token, presence: true, if: :activated?
def initialize_properties
if properties.nil?
self.properties = {}
self.notify_only_broken_pipelines = true
end
end
def title
'HipChat'
end
def description
'Private group chat and IM'
end
def self.to_param
'hipchat'
end
def fields
[
{ type: 'text', name: 'token', placeholder: 'Room token', required: true },
{ type: 'text', name: 'room', placeholder: 'Room name or ID' },
{ type: 'checkbox', name: 'notify' },
{ type: 'select', name: 'color', choices: %w(yellow red green purple gray random) },
{ type: 'text', name: 'api_version',
placeholder: 'Leave blank for default (v2)' },
{ type: 'text', name: 'server',
placeholder: 'Leave blank for default. https://hipchat.example.com' },
{ type: 'checkbox', name: 'notify_only_broken_pipelines' }
]
end
def self.supported_events
%w(push issue confidential_issue merge_request note confidential_note tag_push pipeline)
end
def execute(data)
return unless supported_events.include?(data[:object_kind])
message = create_message(data)
return unless message.present?
gate[room].send('GitLab', message, message_options(data)) # rubocop:disable GitlabSecurity/PublicSend
end
def test(data)
begin
result = execute(data)
rescue StandardError => error
return { success: false, result: error }
end
{ success: true, result: result }
end
private
def gate
options = { api_version: api_version.present? ? api_version : 'v2' }
options[:server_url] = server unless server.blank?
@gate ||= HipChat::Client.new(token, options)
end
def message_options(data = nil)
{ notify: notify.present? && Gitlab::Utils.to_boolean(notify), color: message_color(data) }
end
def create_message(data)
object_kind = data[:object_kind]
case object_kind
when "push", "tag_push"
create_push_message(data)
when "issue"
create_issue_message(data) unless update?(data)
when "merge_request"
create_merge_request_message(data) unless update?(data)
when "note"
create_note_message(data)
when "pipeline"
create_pipeline_message(data) if should_pipeline_be_notified?(data)
end
end
def render_line(text)
markdown(text.lines.first.chomp, pipeline: :single_line) if text
end
def create_push_message(push)
ref_type = Gitlab::Git.tag_ref?(push[:ref]) ? 'tag' : 'branch'
ref = Gitlab::Git.ref_name(push[:ref])
before = push[:before]
after = push[:after]
message = []
message << "#{push[:user_name]} "
if Gitlab::Git.blank_ref?(before)
message << "pushed new #{ref_type} <a href=\""\
"#{project_url}/commits/#{CGI.escape(ref)}\">#{ref}</a>"\
" to #{project_link}\n"
elsif Gitlab::Git.blank_ref?(after)
message << "removed #{ref_type} <b>#{ref}</b> from <a href=\"#{project.web_url}\">#{project_name}</a> \n"
else
message << "pushed to #{ref_type} <a href=\""\
"#{project.web_url}/commits/#{CGI.escape(ref)}\">#{ref}</a> "
message << "of <a href=\"#{project.web_url}\">#{project.full_name.gsub!(/\s/, '')}</a> "
message << "(<a href=\"#{project.web_url}/compare/#{before}...#{after}\">Compare changes</a>)"
push[:commits].take(MAX_COMMITS).each do |commit|
message << "<br /> - #{render_line(commit[:message])} (<a href=\"#{commit[:url]}\">#{commit[:id][0..5]}</a>)"
end
if push[:commits].count > MAX_COMMITS
message << "<br />... #{push[:commits].count - MAX_COMMITS} more commits"
end
end
message.join
end
def markdown(text, options = {})
return "" unless text
context = {
project: project,
pipeline: :email
}
Banzai.render(text, context)
context.merge!(options)
html = Banzai.render_and_post_process(text, context)
sanitized_html = sanitize(html, tags: HIPCHAT_ALLOWED_TAGS, attributes: %w[href title alt])
sanitized_html.truncate(200, separator: ' ', omission: '...')
end
def create_issue_message(data)
user_name = data[:user][:name]
obj_attr = data[:object_attributes]
obj_attr = HashWithIndifferentAccess.new(obj_attr)
title = render_line(obj_attr[:title])
state = obj_attr[:state]
issue_iid = obj_attr[:iid]
issue_url = obj_attr[:url]
description = obj_attr[:description]
issue_link = "<a href=\"#{issue_url}\">issue ##{issue_iid}</a>"
message = ["#{user_name} #{state} #{issue_link} in #{project_link}: <b>#{title}</b>"]
message << "<pre>#{markdown(description)}</pre>"
message.join
end
def create_merge_request_message(data)
user_name = data[:user][:name]
obj_attr = data[:object_attributes]
obj_attr = HashWithIndifferentAccess.new(obj_attr)
merge_request_id = obj_attr[:iid]
state = obj_attr[:state]
description = obj_attr[:description]
title = render_line(obj_attr[:title])
merge_request_url = "#{project_url}/merge_requests/#{merge_request_id}"
merge_request_link = "<a href=\"#{merge_request_url}\">merge request !#{merge_request_id}</a>"
message = ["#{user_name} #{state} #{merge_request_link} in " \
"#{project_link}: <b>#{title}</b>"]
message << "<pre>#{markdown(description)}</pre>"
message.join
end
def format_title(title)
"<b>#{render_line(title)}</b>"
end
def create_note_message(data)
data = HashWithIndifferentAccess.new(data)
user_name = data[:user][:name]
obj_attr = HashWithIndifferentAccess.new(data[:object_attributes])
note = obj_attr[:note]
note_url = obj_attr[:url]
noteable_type = obj_attr[:noteable_type]
commit_id = nil
case noteable_type
when "Commit"
commit_attr = HashWithIndifferentAccess.new(data[:commit])
commit_id = commit_attr[:id]
subject_desc = commit_id
subject_desc = Commit.truncate_sha(subject_desc)
subject_type = "commit"
title = format_title(commit_attr[:message])
when "Issue"
subj_attr = HashWithIndifferentAccess.new(data[:issue])
subject_id = subj_attr[:iid]
subject_desc = "##{subject_id}"
subject_type = "issue"
title = format_title(subj_attr[:title])
when "MergeRequest"
subj_attr = HashWithIndifferentAccess.new(data[:merge_request])
subject_id = subj_attr[:iid]
subject_desc = "!#{subject_id}"
subject_type = "merge request"
title = format_title(subj_attr[:title])
when "Snippet"
subj_attr = HashWithIndifferentAccess.new(data[:snippet])
subject_id = subj_attr[:id]
subject_desc = "##{subject_id}"
subject_type = "snippet"
title = format_title(subj_attr[:title])
end
subject_html = "<a href=\"#{note_url}\">#{subject_type} #{subject_desc}</a>"
message = ["#{user_name} commented on #{subject_html} in #{project_link}: "]
message << title
message << "<pre>#{markdown(note, ref: commit_id)}</pre>"
message.join
end
def create_pipeline_message(data)
pipeline_attributes = data[:object_attributes]
pipeline_id = pipeline_attributes[:id]
ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch'
ref = pipeline_attributes[:ref]
user_name = (data[:user] && data[:user][:name]) || 'API'
status = pipeline_attributes[:status]
duration = pipeline_attributes[:duration]
branch_link = "<a href=\"#{project_url}/commits/#{CGI.escape(ref)}\">#{ref}</a>"
pipeline_url = "<a href=\"#{project_url}/pipelines/#{pipeline_id}\">##{pipeline_id}</a>"
"#{project_link}: Pipeline #{pipeline_url} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status(status)} in #{duration} second(s)"
end
def message_color(data)
pipeline_status_color(data) || color || 'yellow'
end
def pipeline_status_color(data)
return unless data && data[:object_kind] == 'pipeline'
case data[:object_attributes][:status]
when 'success'
'green'
else
'red'
end
end
def project_name
project.full_name.gsub(/\s/, '')
end
def project_url
project.web_url
end
def project_link
"<a href=\"#{project_url}\">#{project_name}</a>"
end
def update?(data)
data[:object_attributes][:action] == 'update'
end
def humanized_status(status)
case status
when 'success'
'passed'
else
status
end
end
def should_pipeline_be_notified?(data)
case data[:object_attributes][:status]
when 'success'
!notify_only_broken_pipelines?
when 'failed'
true
else
false
end
end
end
......@@ -255,6 +255,7 @@ class Service < ApplicationRecord
external_wiki
flowdock
hangouts_chat
hipchat
irker
jira
kubernetes
......
......