diff options
author | Grzegorz Bizon <grzesiek.bizon@gmail.com> | 2017-03-21 14:22:56 +0100 |
---|---|---|
committer | Grzegorz Bizon <grzesiek.bizon@gmail.com> | 2017-03-21 14:22:56 +0100 |
commit | c5912ecd73560b730eda625c77d900ca23ab16d5 (patch) | |
tree | 8f7288b6209fb7e542e5d3bf867138ea6bde7faf /app/models | |
parent | 53d332d3c73f8a883fa54d8eaaf91f92da73c33f (diff) | |
parent | 1e5888d115df1973cd5af0aa95013dbbf29ddefd (diff) | |
download | gitlab-ce-c5912ecd73560b730eda625c77d900ca23ab16d5.tar.gz |
Merge branch 'master' into feature/multi-level-container-registry-images
* master: (1327 commits)
Merge branch 'render-json-leak' into 'security'
Merge branch 'ssrf' into 'security'
Merge branch 'ssrf' into 'security'
Merge branch 'fix-links-target-blank' into 'security'
Merge branch '28058-hide-emails-in-atom-feeds' into 'security'
Fix karma test
Reset filters after click
Handle Route#name being nil after an update
Only add frontend code coverage instrumentation when generating coverage report
fix recompile assets step in 9.0 upgrade guide to use yarn
Undo explicit conversion to Integer
Make level_value accept string integers
Make feature spec more robust
Removed d3.js from the main application.js bundle
Extend compound status for manual actions specs
Update css to be nice and tidy.
Fix pipeline status for transition between stages
add an index to the ghost column
Return 404 in project issues API endpoint when project cannot be found
Improve rename projects migration
...
Conflicts:
doc/ci/docker/using_docker_build.md
spec/lib/gitlab/import_export/all_models.yml
Diffstat (limited to 'app/models')
80 files changed, 1384 insertions, 807 deletions
diff --git a/app/models/ability.rb b/app/models/ability.rb index ad6c588202e..f3692a5a067 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -56,15 +56,16 @@ class Ability end end - def allowed?(user, action, subject) + def allowed?(user, action, subject = :global) allowed(user, subject).include?(action) end - def allowed(user, subject) + def allowed(user, subject = :global) + return BasePolicy::RuleSet.none if subject.nil? return uncached_allowed(user, subject) unless RequestStore.active? user_key = user ? user.id : 'anonymous' - subject_key = subject ? "#{subject.class.name}/#{subject.id}" : 'global' + subject_key = subject == :global ? 'global' : "#{subject.class.name}/#{subject.id}" key = "/ability/#{user_key}/#{subject_key}" RequestStore[key] ||= uncached_allowed(user, subject).freeze end diff --git a/app/models/appearance.rb b/app/models/appearance.rb index e4106e1c2e9..c79326e8427 100644 --- a/app/models/appearance.rb +++ b/app/models/appearance.rb @@ -10,4 +10,5 @@ class Appearance < ActiveRecord::Base mount_uploader :logo, AttachmentUploader mount_uploader :header_logo, AttachmentUploader + has_many :uploads, as: :model, dependent: :destroy end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index b94a71e1ea7..9d01a70c77d 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -6,7 +6,7 @@ class ApplicationSetting < ActiveRecord::Base add_authentication_token_field :health_check_access_token add_authentication_token_field :container_registry_access_token - CACHE_KEY = 'application_setting.last' + CACHE_KEY = 'application_setting.last'.freeze DOMAIN_LIST_SEPARATOR = %r{\s*[,;]\s* # comma or semicolon, optionally surrounded by whitespace | # or \s # any whitespace character @@ -65,6 +65,16 @@ class ApplicationSetting < ActiveRecord::Base presence: true, if: :akismet_enabled + validates :unique_ips_limit_per_user, + numericality: { greater_than_or_equal_to: 1 }, + presence: true, + if: :unique_ips_limit_enabled + + validates :unique_ips_limit_time_window, + numericality: { greater_than_or_equal_to: 0 }, + presence: true, + if: :unique_ips_limit_enabled + validates :koding_url, presence: true, if: :koding_enabled @@ -77,6 +87,12 @@ class ApplicationSetting < ActiveRecord::Base presence: true, numericality: { only_integer: true, greater_than: 0 } + validates :max_artifacts_size, + presence: true, + numericality: { only_integer: true, greater_than: 0 } + + validates :default_artifacts_expire_in, presence: true, duration: true + validates :container_registry_token_expire_delay, presence: true, numericality: { only_integer: true, greater_than: 0 } @@ -149,6 +165,8 @@ class ApplicationSetting < ActiveRecord::Base end def self.current + ensure_cache_setup + Rails.cache.fetch(CACHE_KEY) do ApplicationSetting.last end @@ -162,22 +180,34 @@ class ApplicationSetting < ActiveRecord::Base end def self.cached + ensure_cache_setup Rails.cache.fetch(CACHE_KEY) end + def self.ensure_cache_setup + # This is a workaround for a Rails bug that causes attribute methods not + # to be loaded when read from cache: https://github.com/rails/rails/issues/27348 + ApplicationSetting.define_attribute_methods + end + def self.defaults_ce { after_sign_up_text: nil, akismet_enabled: false, container_registry_token_expire_delay: 5, + default_artifacts_expire_in: '30 days', default_branch_protection: Settings.gitlab['default_branch_protection'], default_project_visibility: Settings.gitlab.default_projects_features['visibility_level'], default_projects_limit: Settings.gitlab['default_projects_limit'], default_snippet_visibility: Settings.gitlab.default_projects_features['visibility_level'], + default_group_visibility: Settings.gitlab.default_projects_features['visibility_level'], disabled_oauth_sign_in_sources: [], domain_whitelist: Settings.gitlab['domain_whitelist'], gravatar_enabled: Settings.gravatar['enabled'], help_page_text: nil, + unique_ips_limit_per_user: 10, + unique_ips_limit_time_window: 3600, + unique_ips_limit_enabled: false, housekeeping_bitmaps_enabled: true, housekeeping_enabled: true, housekeeping_full_repack_period: 50, @@ -203,9 +233,9 @@ class ApplicationSetting < ActiveRecord::Base sign_in_text: nil, signin_enabled: Settings.gitlab['signin_enabled'], signup_enabled: Settings.gitlab['signup_enabled'], + terminal_max_session_time: 0, two_factor_grace_period: 48, - user_default_external: false, - terminal_max_session_time: 0 + user_default_external: false } end @@ -217,6 +247,14 @@ class ApplicationSetting < ActiveRecord::Base create(defaults) end + def self.human_attribute_name(attr, _options = {}) + if attr == :default_artifacts_expire_in + 'Default artifacts expiration' + else + super + end + end + def home_page_url_column_exist ActiveRecord::Base.connection.column_exists?(:application_settings, :home_page_url) end @@ -264,6 +302,22 @@ class ApplicationSetting < ActiveRecord::Base self.repository_storages = [value] end + def default_project_visibility=(level) + super(Gitlab::VisibilityLevel.level_value(level)) + end + + def default_snippet_visibility=(level) + super(Gitlab::VisibilityLevel.level_value(level)) + end + + def default_group_visibility=(level) + super(Gitlab::VisibilityLevel.level_value(level)) + end + + def restricted_visibility_levels=(levels) + super(levels.map { |level| Gitlab::VisibilityLevel.level_value(level) }) + end + # Choose one of the available repository storage options. Currently all have # equal weighting. def pick_repository_storage diff --git a/app/models/blob.rb b/app/models/blob.rb index ab92e820335..1376b86fdad 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -54,9 +54,13 @@ class Blob < SimpleDelegator UploaderHelper::VIDEO_EXT.include?(extname.downcase.delete('.')) end - def to_partial_path + def to_partial_path(project) if lfs_pointer? - 'download' + if project.lfs_enabled? + 'download' + else + 'text' + end elsif image? || svg? 'image' elsif text? diff --git a/app/models/chat_team.rb b/app/models/chat_team.rb new file mode 100644 index 00000000000..c52b6f15913 --- /dev/null +++ b/app/models/chat_team.rb @@ -0,0 +1,6 @@ +class ChatTeam < ActiveRecord::Base + validates :team_id, presence: true + validates :namespace, uniqueness: true + + belongs_to :namespace +end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index e018f8e7c4e..ad0be70c32a 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -15,15 +15,17 @@ module Ci def persisted_environment @persisted_environment ||= Environment.find_by( name: expanded_environment_name, - project_id: gl_project_id + project: project ) end serialize :options serialize :yaml_variables, Gitlab::Serializer::Ci::Variables + delegate :name, to: :project, prefix: true + validates :coverage, numericality: true, allow_blank: true - validates_presence_of :ref + validates :ref, presence: true scope :unstarted, ->() { where(runner_id: nil) } scope :ignore_failures, ->() { where(allow_failure: false) } @@ -53,15 +55,6 @@ module Ci pending.unstarted.order('created_at ASC').first end - def create_from(build) - new_build = build.dup - new_build.status = 'pending' - new_build.runner_id = nil - new_build.trigger_request_id = nil - new_build.token = nil - new_build.save - end - def retry(build, current_user) Ci::RetryBuildService .new(build.project, current_user) @@ -70,6 +63,10 @@ module Ci end state_machine :status do + event :actionize do + transition created: :manual + end + after_transition any => [:pending] do |build| build.run_after_commit do BuildQueueWorker.perform_async(id) @@ -101,16 +98,21 @@ module Ci .fabricate! end - def manual? - self.when == 'manual' - end - def other_actions pipeline.manual_actions.where.not(name: name) end def playable? - project.builds_enabled? && commands.present? && manual? && skipped? + project.builds_enabled? && has_commands? && + action? && manual? + end + + def action? + self.when == 'manual' + end + + def has_commands? + commands.present? end def play(current_user) @@ -129,7 +131,7 @@ module Ci end def retryable? - project.builds_enabled? && commands.present? && + project.builds_enabled? && has_commands? && (success? || failed? || canceled?) end @@ -221,7 +223,8 @@ module Ci def merge_request merge_requests = MergeRequest.includes(:merge_request_diff) - .where(source_branch: ref, source_project_id: pipeline.gl_project_id) + .where(source_branch: ref, + source_project: pipeline.project) .reorder(iid: :asc) merge_requests.find do |merge_request| @@ -229,14 +232,6 @@ module Ci end end - def project_id - gl_project_id - end - - def project_name - project.name - end - def repo_url auth = "gitlab-ci-token:#{ensure_token!}@" project.http_url_to_repo.sub(/^https?:\/\//) do |prefix| @@ -257,7 +252,7 @@ module Ci return unless regex matches = text.scan(Regexp.new(regex)).last - matches = matches.last if matches.kind_of?(Array) + matches = matches.last if matches.is_a?(Array) coverage = matches.gsub(/\d+(\.\d+)?/).first if coverage.present? @@ -486,7 +481,7 @@ module Ci def artifacts_expire_in=(value) self.artifacts_expire_at = if value - Time.now + ChronicDuration.parse(value) + ChronicDuration.parse(value)&.seconds&.from_now end end @@ -519,10 +514,41 @@ module Ci ] end + def steps + [Gitlab::Ci::Build::Step.from_commands(self), + Gitlab::Ci::Build::Step.from_after_script(self)].compact + end + + def image + Gitlab::Ci::Build::Image.from_image(self) + end + + def services + Gitlab::Ci::Build::Image.from_services(self) + end + + def artifacts + [options[:artifacts]] + end + + def cache + [options[:cache]] + end + def credentials Gitlab::Ci::Build::Credentials::Factory.new(self).create! end + def dependencies + depended_jobs = depends_on_builds + + return depended_jobs unless options[:dependencies].present? + + depended_jobs.select do |job| + options[:dependencies].include?(job.name) + end + end + private def update_artifacts_size @@ -542,13 +568,38 @@ module Ci end def unscoped_project - @unscoped_project ||= Project.unscoped.find_by(id: gl_project_id) + @unscoped_project ||= Project.unscoped.find_by(id: project_id) end + CI_REGISTRY_USER = 'gitlab-ci-token'.freeze + def predefined_variables variables = [ { key: 'CI', value: 'true', public: true }, { key: 'GITLAB_CI', value: 'true', public: true }, + { key: 'CI_SERVER_NAME', value: 'GitLab', public: true }, + { key: 'CI_SERVER_VERSION', value: Gitlab::VERSION, public: true }, + { key: 'CI_SERVER_REVISION', value: Gitlab::REVISION, public: true }, + { key: 'CI_JOB_ID', value: id.to_s, public: true }, + { key: 'CI_JOB_NAME', value: name, public: true }, + { key: 'CI_JOB_STAGE', value: stage, public: true }, + { key: 'CI_JOB_TOKEN', value: token, public: false }, + { key: 'CI_COMMIT_SHA', value: sha, public: true }, + { key: 'CI_COMMIT_REF_NAME', value: ref, public: true }, + { key: 'CI_COMMIT_REF_SLUG', value: ref_slug, public: true }, + { key: 'CI_REGISTRY_USER', value: CI_REGISTRY_USER, public: true }, + { key: 'CI_REGISTRY_PASSWORD', value: token, public: false }, + { key: 'CI_REPOSITORY_URL', value: repo_url, public: false } + ] + + variables << { key: "CI_COMMIT_TAG", value: ref, public: true } if tag? + variables << { key: "CI_PIPELINE_TRIGGERED", value: 'true', public: true } if trigger_request + variables << { key: "CI_JOB_MANUAL", value: 'true', public: true } if action? + variables.concat(legacy_variables) + end + + def legacy_variables + variables = [ { key: 'CI_BUILD_ID', value: id.to_s, public: true }, { key: 'CI_BUILD_TOKEN', value: token, public: false }, { key: 'CI_BUILD_REF', value: sha, public: true }, @@ -556,14 +607,12 @@ module Ci { key: 'CI_BUILD_REF_NAME', value: ref, public: true }, { key: 'CI_BUILD_REF_SLUG', value: ref_slug, public: true }, { key: 'CI_BUILD_NAME', value: name, public: true }, - { key: 'CI_BUILD_STAGE', value: stage, public: true }, - { key: 'CI_SERVER_NAME', value: 'GitLab', public: true }, - { key: 'CI_SERVER_VERSION', value: Gitlab::VERSION, public: true }, - { key: 'CI_SERVER_REVISION', value: Gitlab::REVISION, public: true } + { key: 'CI_BUILD_STAGE', value: stage, public: true } ] - variables << { key: 'CI_BUILD_TAG', value: ref, public: true } if tag? - variables << { key: 'CI_BUILD_TRIGGERED', value: 'true', public: true } if trigger_request - variables << { key: 'CI_BUILD_MANUAL', value: 'true', public: true } if manual? + + variables << { key: "CI_BUILD_TAG", value: ref, public: true } if tag? + variables << { key: "CI_BUILD_TRIGGERED", value: 'true', public: true } if trigger_request + variables << { key: "CI_BUILD_MANUAL", value: 'true', public: true } if action? variables end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index dc4590a9923..f12be98c80c 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -5,21 +5,22 @@ module Ci include Importable include AfterCommitQueue - self.table_name = 'ci_commits' - - belongs_to :project, foreign_key: :gl_project_id + belongs_to :project belongs_to :user has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id has_many :builds, foreign_key: :commit_id has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id - validates_presence_of :sha, unless: :importing? - validates_presence_of :ref, unless: :importing? - validates_presence_of :status, unless: :importing? + delegate :id, to: :project, prefix: true + + validates :sha, presence: { unless: :importing? } + validates :ref, presence: { unless: :importing? } + validates :status, presence: { unless: :importing? } validate :valid_commit_sha, unless: :importing? after_create :keep_around_commits, unless: :importing? + after_create :refresh_build_status_cache state_machine :status, initial: :created do event :enqueue do @@ -47,6 +48,10 @@ module Ci transition any - [:canceled] => :canceled end + event :block do + transition any - [:manual] => :manual + end + # IMPORTANT # Do not add any operations to this state_machine # Create a separate worker for each new operation @@ -93,8 +98,11 @@ module Ci .select("max(#{quoted_table_name}.id)") .group(:ref, :sha) - relation = ref ? where(ref: ref) : self - relation.where(id: max_id) + if ref + where(ref: ref, id: max_id.where(ref: ref)) + else + where(id: max_id) + end end def self.latest_status(ref = nil) @@ -105,6 +113,12 @@ module Ci success.latest(ref).order(id: :desc).first end + def self.latest_successful_for_refs(refs) + success.latest(refs).order(id: :desc).each_with_object({}) do |pipeline, hash| + hash[pipeline.ref] ||= pipeline + end + end + def self.truncate_sha(sha) sha[0...8] end @@ -135,7 +149,7 @@ module Ci status_sql = statuses.latest.where('stage=sg.stage').status_sql - warnings_sql = statuses.latest.select('COUNT(*) > 0') + warnings_sql = statuses.latest.select('COUNT(*)') .where('stage=sg.stage').failed_but_allowed.to_sql stages_with_statuses = CommitStatus.from(stages_query, :sg) @@ -150,10 +164,6 @@ module Ci builds.latest.with_artifacts_not_expired.includes(project: [:namespace]) end - def project_id - project.id - end - # For now the only user who participates is the user who triggered def participants(_current_user = nil) Array(user) @@ -320,8 +330,10 @@ module Ci when 'failed' then drop when 'canceled' then cancel when 'skipped' then skip + when 'manual' then block end end + refresh_build_status_cache end def predefined_variables @@ -363,6 +375,10 @@ module Ci .fabricate! end + def refresh_build_status_cache + Ci::PipelineStatus.new(project, sha: sha, status: status).store_in_cache_if_needed + end + private def pipeline_data diff --git a/app/models/ci/pipeline_status.rb b/app/models/ci/pipeline_status.rb new file mode 100644 index 00000000000..048047d0e34 --- /dev/null +++ b/app/models/ci/pipeline_status.rb @@ -0,0 +1,86 @@ +# This class is not backed by a table in the main database. +# It loads the latest Pipeline for the HEAD of a repository, and caches that +# in Redis. +module Ci + class PipelineStatus + attr_accessor :sha, :status, :project, :loaded + + delegate :commit, to: :project + + def self.load_for_project(project) + new(project).tap do |status| + status.load_status + end + end + + def initialize(project, sha: nil, status: nil) + @project = project + @sha = sha + @status = status + end + + def has_status? + loaded? && sha.present? && status.present? + end + + def load_status + return if loaded? + + if has_cache? + load_from_cache + else + load_from_commit + store_in_cache + end + + self.loaded = true + end + + def load_from_commit + return unless commit + + self.sha = commit.sha + self.status = commit.status + end + + # We only cache the status for the HEAD commit of a project + # This status is rendered in project lists + def store_in_cache_if_needed + return unless sha + return delete_from_cache unless commit + store_in_cache if commit.sha == self.sha + end + + def load_from_cache + Gitlab::Redis.with do |redis| + self.sha, self.status = redis.hmget(cache_key, :sha, :status) + end + end + + def store_in_cache + Gitlab::Redis.with do |redis| + redis.mapped_hmset(cache_key, { sha: sha, status: status }) + end + end + + def delete_from_cache + Gitlab::Redis.with do |redis| + redis.del(cache_key) + end + end + + def has_cache? + Gitlab::Redis.with do |redis| + redis.exists(cache_key) + end + end + + def loaded? + self.loaded + end + + def cache_key + "projects/#{project.id}/build_status" + end + end +end diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 07a086b0aca..487ba61bc9c 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -4,12 +4,12 @@ module Ci RUNNER_QUEUE_EXPIRY_TIME = 60.minutes LAST_CONTACT_TIME = 1.hour.ago - AVAILABLE_SCOPES = %w[specific shared active paused online] - FORM_EDITABLE = %i[description tag_list active run_untagged locked] + AVAILABLE_SCOPES = %w[specific shared active paused online].freeze + FORM_EDITABLE = %i[description tag_list active run_untagged locked].freeze has_many :builds has_many :runner_projects, dependent: :destroy - has_many :projects, through: :runner_projects, foreign_key: :gl_project_id + has_many :projects, through: :runner_projects has_one :last_build, ->() { order('id DESC') }, class_name: 'Ci::Build' @@ -24,7 +24,7 @@ module Ci scope :owned_or_shared, ->(project_id) do joins('LEFT JOIN ci_runner_projects ON ci_runner_projects.runner_id = ci_runners.id') - .where("ci_runner_projects.gl_project_id = :project_id OR ci_runners.is_shared = true", project_id: project_id) + .where("ci_runner_projects.project_id = :project_id OR ci_runners.is_shared = true", project_id: project_id) end scope :assignable_for, ->(project) do @@ -127,18 +127,15 @@ module Ci def tick_runner_queue SecureRandom.hex.tap do |new_update| - Gitlab::Redis.with do |redis| - redis.set(runner_queue_key, new_update, ex: RUNNER_QUEUE_EXPIRY_TIME) - end + ::Gitlab::Workhorse.set_key_and_notify(runner_queue_key, new_update, + expire: RUNNER_QUEUE_EXPIRY_TIME, overwrite: true) end end def ensure_runner_queue_value - Gitlab::Redis.with do |redis| - value = SecureRandom.hex - redis.set(runner_queue_key, value, ex: RUNNER_QUEUE_EXPIRY_TIME, nx: true) - redis.get(runner_queue_key) - end + new_value = SecureRandom.hex + ::Gitlab::Workhorse.set_key_and_notify(runner_queue_key, new_value, + expire: RUNNER_QUEUE_EXPIRY_TIME, overwrite: false) end def is_runner_queue_value_latest?(value) diff --git a/app/models/ci/runner_project.rb b/app/models/ci/runner_project.rb index 1f9baeca5b1..5f01a0daae9 100644 --- a/app/models/ci/runner_project.rb +++ b/app/models/ci/runner_project.rb @@ -1,10 +1,10 @@ module Ci class RunnerProject < ActiveRecord::Base extend Ci::Model - + belongs_to :runner - belongs_to :project, foreign_key: :gl_project_id + belongs_to :project - validates_uniqueness_of :runner_id, scope: :gl_project_id + validates :runner_id, uniqueness: { scope: :project_id } end end diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index ca74c91b062..e7d6b17d445 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -46,10 +46,10 @@ module Ci end def has_warnings? - if @warnings.nil? - statuses.latest.failed_but_allowed.any? + if @warnings.is_a?(Integer) + @warnings > 0 else - @warnings + statuses.latest.failed_but_allowed.any? end end end diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb index 62889fe80d8..cba1d81a861 100644 --- a/app/models/ci/trigger.rb +++ b/app/models/ci/trigger.rb @@ -4,11 +4,12 @@ module Ci acts_as_paranoid - belongs_to :project, foreign_key: :gl_project_id + belongs_to :project + belongs_to :owner, class_name: "User" + has_many :trigger_requests, dependent: :destroy - validates_presence_of :token - validates_uniqueness_of :token + validates :token, presence: true, uniqueness: true before_validation :set_default_values @@ -25,7 +26,15 @@ module Ci end def short_token - token[0...10] + token[0...4] + end + + def legacy? + self.owner_id.blank? + end + + def can_access_project? + self.owner_id.blank? || Ability.allowed?(self.owner, :create_build, project) end end end diff --git a/app/models/ci/variable.rb b/app/models/ci/variable.rb index 2c8698d8b5d..6c6586110c5 100644 --- a/app/models/ci/variable.rb +++ b/app/models/ci/variable.rb @@ -2,11 +2,11 @@ module Ci class Variable < ActiveRecord::Base extend Ci::Model - belongs_to :project, foreign_key: :gl_project_id + belongs_to :project validates :key, presence: true, - uniqueness: { scope: :gl_project_id }, + uniqueness: { scope: :project_id }, length: { maximum: 255 }, format: { with: /\A[a-zA-Z0-9_]+\z/, message: "can contain only letters, digits and '_'." } diff --git a/app/models/commit.rb b/app/models/commit.rb index 46f06733da1..ce92cc369ad 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -22,12 +22,12 @@ class Commit DIFF_HARD_LIMIT_LINES = 50000 # The SHA can be between 7 and 40 hex characters. - COMMIT_SHA_PATTERN = '\h{7,40}' + COMMIT_SHA_PATTERN = '\h{7,40}'.freeze class << self def decorate(commits, project) commits.map do |commit| - if commit.kind_of?(Commit) + if commit.is_a?(Commit) commit else self.new(commit, project) @@ -105,7 +105,7 @@ class Commit end def diff_line_count - @diff_line_count ||= Commit::diff_line_count(raw_diffs) + @diff_line_count ||= Commit.diff_line_count(raw_diffs) @diff_line_count end @@ -122,11 +122,12 @@ class Commit def full_title return @full_title if @full_title - if safe_message.blank? - @full_title = no_commit_message - else - @full_title = safe_message.split("\n", 2).first - end + @full_title = + if safe_message.blank? + no_commit_message + else + safe_message.split("\n", 2).first + end end # Returns the commits description @@ -230,6 +231,10 @@ class Commit project.pipelines.where(sha: sha) end + def latest_pipeline + pipelines.last + end + def status(ref = nil) @statuses ||= {} @@ -316,7 +321,14 @@ class Commit end def raw_diffs(*args) - raw.diffs(*args) + use_gitaly = Gitlab::GitalyClient.feature_enabled?(:commit_raw_diffs) + deltas_only = args.last.is_a?(Hash) && args.last[:deltas_only] + + if use_gitaly && !deltas_only + Gitlab::GitalyClient::Commit.diff_from_parent(self, *args) + else + raw.diffs(*args) + end end def diffs(diff_options = nil) diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 99a6326309d..8c71267da65 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -5,15 +5,16 @@ class CommitStatus < ActiveRecord::Base self.table_name = 'ci_builds' - belongs_to :project, foreign_key: :gl_project_id + belongs_to :project belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id belongs_to :user delegate :commit, to: :pipeline + delegate :sha, :short_sha, to: :pipeline validates :pipeline, presence: true, unless: :importing? - validates_presence_of :name + validates :name, presence: true alias_attribute :author, :user @@ -28,9 +29,11 @@ class CommitStatus < ActiveRecord::Base end scope :exclude_ignored, -> do - # We want to ignore failed_but_allowed jobs + # We want to ignore failed but allowed to fail jobs. + # + # TODO, we also skip ignored optional manual actions. where("allow_failure = ? OR status IN (?)", - false, all_state_names - [:failed, :canceled]) + false, all_state_names - [:failed, :canceled, :manual]) end scope :retried, -> { where.not(id: latest) } @@ -41,11 +44,11 @@ class CommitStatus < ActiveRecord::Base state_machine :status do event :enqueue do - transition [:created, :skipped] => :pending + transition [:created, :skipped, :manual] => :pending end event :process do - transition skipped: :created + transition [:skipped, :manual] => :created end event :run do @@ -65,7 +68,7 @@ class CommitStatus < ActiveRecord::Base end event :cancel do - transition [:created, :pending, :running] => :canceled + transition [:created, :pending, :running, :manual] => :canceled end before_transition created: [:pending, :running] do |commit_status| @@ -85,7 +88,7 @@ class CommitStatus < ActiveRecord::Base commit_status.run_after_commit do pipeline.try do |pipeline| - if complete? + if complete? || manual? PipelineProcessWorker.perform_async(pipeline.id) else PipelineUpdateWorker.perform_async(pipeline.id) @@ -102,8 +105,6 @@ class CommitStatus < ActiveRecord::Base end end - delegate :sha, :short_sha, to: :pipeline - def before_sha pipeline.before_sha || Gitlab::Git::BLANK_SHA end @@ -132,6 +133,12 @@ class CommitStatus < ActiveRecord::Base false end + # Added in 9.0 to keep backward compatibility for projects exported in 8.17 + # and prior. + def gl_project_id + 'dummy' + end + def detailed_status(current_user) Gitlab::Ci::Status::Factory .new(self, current_user) diff --git a/app/models/concerns/awardable.rb b/app/models/concerns/awardable.rb index 073ac4c1b65..a7fd0a15f0f 100644 --- a/app/models/concerns/awardable.rb +++ b/app/models/concerns/awardable.rb @@ -101,6 +101,6 @@ module Awardable private def normalize_name(name) - Gitlab::AwardEmoji.normalize_emoji_name(name) + Gitlab::Emoji.normalize_emoji_name(name) end end diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index a600f9c14c5..8ea95beed79 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -11,14 +11,15 @@ module CacheMarkdownField # Knows about the relationship between markdown and html field names, and # stores the rendering contexts for the latter class FieldData - extend Forwardable - def initialize @data = {} end - def_delegators :@data, :[], :[]= - def_delegator :@data, :keys, :markdown_fields + delegate :[], :[]=, to: :@data + + def markdown_fields + @data.keys + end def html_field(markdown_field) "#{markdown_field}_html" @@ -45,7 +46,7 @@ module CacheMarkdownField Project Release Snippet - ] + ].freeze def self.caching_classes CACHING_CLASSES.map(&:constantize) diff --git a/app/models/concerns/case_sensitivity.rb b/app/models/concerns/case_sensitivity.rb index fe0cea8465f..034e9f40ff0 100644 --- a/app/models/concerns/case_sensitivity.rb +++ b/app/models/concerns/case_sensitivity.rb @@ -13,11 +13,12 @@ module CaseSensitivity params.each do |key, value| column = ActiveRecord::Base.connection.quote_table_name(key) - if cast_lower - condition = "LOWER(#{column}) = LOWER(:value)" - else - condition = "#{column} = :value" - end + condition = + if cast_lower + "LOWER(#{column}) = LOWER(:value)" + else + "#{column} = :value" + end criteria = criteria.where(condition, value: value) end diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb index 431c0354969..0a1a65da05a 100644 --- a/app/models/concerns/has_status.rb +++ b/app/models/concerns/has_status.rb @@ -1,23 +1,22 @@ module HasStatus extend ActiveSupport::Concern - DEFAULT_STATUS = 'created' - AVAILABLE_STATUSES = %w[created pending running success failed canceled skipped] - STARTED_STATUSES = %w[running success failed skipped] - ACTIVE_STATUSES = %w[pending running] - COMPLETED_STATUSES = %w[success failed canceled skipped] - ORDERED_STATUSES = %w[failed pending running canceled success skipped] + DEFAULT_STATUS = 'created'.freeze + BLOCKED_STATUS = 'manual'.freeze + AVAILABLE_STATUSES = %w[created pending running success failed canceled skipped manual].freeze + STARTED_STATUSES = %w[running success failed skipped manual].freeze + ACTIVE_STATUSES = %w[pending running].freeze + COMPLETED_STATUSES = %w[success failed canceled skipped].freeze + ORDERED_STATUSES = %w[failed pending running manual canceled success skipped created].freeze class_methods do def status_sql - scope = if respond_to?(:exclude_ignored) - exclude_ignored - else - all - end + scope = respond_to?(:exclude_ignored) ? exclude_ignored : all + builds = scope.select('count(*)').to_sql created = scope.created.select('count(*)').to_sql success = scope.success.select('count(*)').to_sql + manual = scope.manual.select('count(*)').to_sql pending = scope.pending.select('count(*)').to_sql running = scope.running.select('count(*)').to_sql skipped = scope.skipped.select('count(*)').to_sql @@ -30,7 +29,9 @@ module HasStatus WHEN (#{builds})=(#{success})+(#{skipped}) THEN 'success' WHEN (#{builds})=(#{success})+(#{skipped})+(#{canceled}) THEN 'canceled' WHEN (#{builds})=(#{created})+(#{skipped})+(#{pending}) THEN 'pending' - WHEN (#{running})+(#{pending})+(#{created})>0 THEN 'running' + WHEN (#{running})+(#{pending})>0 THEN 'running' + WHEN (#{manual})>0 THEN 'manual' + WHEN (#{created})>0 THEN 'running' ELSE 'failed' END)" end @@ -63,6 +64,7 @@ module HasStatus state :success, value: 'success' state :canceled, value: 'canceled' state :skipped, value: 'skipped' + state :manual, value: 'manual' end scope :created, -> { where(status: 'created') } @@ -73,12 +75,13 @@ module HasStatus scope :failed, -> { where(status: 'failed') } scope :canceled, -> { where(status: 'canceled') } scope :skipped, -> { where(status: 'skipped') } + scope :manual, -> { where(status: 'manual') } scope :running_or_pending, -> { where(status: [:running, :pending]) } scope :finished, -> { where(status: [:success, :failed, :canceled]) } scope :failed_or_canceled, -> { where(status: [:failed, :canceled]) } scope :cancelable, -> do - where(status: [:running, :pending, :created]) + where(status: [:running, :pending, :created, :manual]) end end @@ -94,6 +97,10 @@ module HasStatus COMPLETED_STATUSES.include?(status) end + def blocked? + BLOCKED_STATUS == status + end + private def calculate_duration diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 5f53c48fc88..e7bd20b322a 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -16,9 +16,9 @@ module Issuable include TimeTrackable # This object is used to gather issuable meta data for displaying - # upvotes, downvotes and notes count for issues and merge requests + # upvotes, downvotes, notes and closing merge requests count for issues and merge requests # lists avoiding n+1 queries and improving performance. - IssuableMeta = Struct.new(:upvotes, :downvotes, :notes_count) + IssuableMeta = Struct.new(:upvotes, :downvotes, :notes_count, :merge_requests_count) included do cache_markdown_field :title, pipeline: :single_line @@ -46,12 +46,26 @@ module Issuable has_one :metrics + delegate :name, + :email, + :public_email, + to: :author, + prefix: true + + delegate :name, + :email, + :public_email, + to: :assignee, + allow_nil: true, + prefix: true + validates :author, presence: true validates :title, presence: true, length: { maximum: 255 } scope :authored, ->(user) { where(author_id: user) } scope :assigned_to, ->(u) { where(assignee_id: u.id)} scope :recent, -> { reorder(id: :desc) } + scope :order_position_asc, -> { reorder(position: :asc) } scope :assigned, -> { where("assignee_id IS NOT NULL") } scope :unassigned, -> { where("assignee_id IS NULL") } scope :of_projects, ->(ids) { where(project_id: ids) } @@ -68,21 +82,10 @@ module Issuable scope :without_label, -> { joins("LEFT OUTER JOIN label_links ON label_links.target_type = '#{name}' AND label_links.target_id = #{table_name}.id").where(label_links: { id: nil }) } scope :join_project, -> { joins(:project) } - scope :inc_notes_with_associations, -> { includes(notes: [ :project, :author, :award_emoji ]) } + scope :inc_notes_with_associations, -> { includes(notes: [:project, :author, :award_emoji]) } scope :references_project, -> { references(:project) } scope :non_archived, -> { join_project.where(projects: { archived: false }) } - delegate :name, - :email, - to: :author, - prefix: true - - delegate :name, - :email, - to: :assignee, - allow_nil: true, - prefix: true - attr_mentionable :title, pipeline: :single_line attr_mentionable :description @@ -143,7 +146,9 @@ module Issuable when 'milestone_due_desc' then order_milestone_due_desc when 'downvotes_desc' then order_downvotes_desc when 'upvotes_desc' then order_upvotes_desc - when 'priority' then order_labels_priority(excluded_labels: excluded_labels) + when 'label_priority' then order_labels_priority(excluded_labels: excluded_labels) + when 'priority' then order_due_date_and_labels_priority(excluded_labels: excluded_labels) + when 'position_asc' then order_position_asc else order_by(method) end @@ -152,7 +157,28 @@ module Issuable sorted.order(id: :desc) end - def order_labels_priority(excluded_labels: []) + def order_due_date_and_labels_priority(excluded_labels: []) + # The order_ methods also modify the query in other ways: + # + # - For milestones, we add a JOIN. + # - For label priority, we change the SELECT, and add a GROUP BY.# + # + # After doing those, we need to reorder to the order we want. The existing + # ORDER BYs won't work because: + # + # 1. We need milestone due date first. + # 2. We can't ORDER BY a column that isn't in the GROUP BY and doesn't + # have an aggregate function applied, so we do a useless MIN() instead. + # + milestones_due_date = 'MIN(milestones.due_date)' + + order_milestone_due_asc. + order_labels_priority(excluded_labels: excluded_labels, extra_select_columns: [milestones_due_date]). + reorder(Gitlab::Database.nulls_last_order(milestones_due_date, 'ASC'), + Gitlab::Database.nulls_last_order('highest_priority', 'ASC')) + end + + def order_labels_priority(excluded_labels: [], extra_select_columns: []) params = { target_type: name, target_column: "#{table_name}.id", @@ -162,7 +188,12 @@ module Issuable highest_priority = highest_label_priority(params).to_sql - select("#{table_name}.*, (#{highest_priority}) AS highest_priority"). + select_columns = [ + "#{table_name}.*", + "(#{highest_priority}) AS highest_priority" + ] + extra_select_columns + + select(select_columns.join(', ')). group(arel_table[:id]). reorder(Gitlab::Database.nulls_last_order('highest_priority', 'ASC')) end @@ -182,7 +213,7 @@ module Issuable def grouping_columns(sort) grouping_columns = [arel_table[:id]] - if ["milestone_due_desc", "milestone_due_asc"].include?(sort) + if %w(milestone_due_desc milestone_due_asc).include?(sort) milestone_table = Milestone.arel_table grouping_columns << milestone_table[:id] grouping_columns << milestone_table[:due_date] @@ -232,10 +263,11 @@ module Issuable user: user.hook_attrs, project: project.hook_attrs, object_attributes: hook_attrs, + labels: labels.map(&:hook_attrs), # DEPRECATED repository: project.hook_attrs.slice(:name, :url, :description, :homepage) } - hook_data.merge!(assignee: assignee.hook_attrs) if assignee + hook_data[:assignee] = assignee.hook_attrs if assignee hook_data end diff --git a/app/models/concerns/reactive_service.rb b/app/models/concerns/reactive_service.rb index e1f868a299b..713246039c1 100644 --- a/app/models/concerns/reactive_service.rb +++ b/app/models/concerns/reactive_service.rb @@ -5,6 +5,6 @@ module ReactiveService include ReactiveCaching # Default cache key: class name + project_id - self.reactive_cache_key = ->(service) { [ service.class.model_name.singular, service.project_id ] } + self.reactive_cache_key = ->(service) { [service.class.model_name.singular, service.project_id] } end end diff --git a/app/models/concerns/relative_positioning.rb b/app/models/concerns/relative_positioning.rb new file mode 100644 index 00000000000..f1d8532a6d6 --- /dev/null +++ b/app/models/concerns/relative_positioning.rb @@ -0,0 +1,139 @@ +module RelativePositioning + extend ActiveSupport::Concern + + MIN_POSITION = 0 + START_POSITION = Gitlab::Database::MAX_INT_VALUE / 2 + MAX_POSITION = Gitlab::Database::MAX_INT_VALUE + IDEAL_DISTANCE = 500 + + included do + after_save :save_positionable_neighbours + end + + def max_relative_position + self.class.in_projects(project.id).maximum(:relative_position) + end + + def prev_relative_position + prev_pos = nil + + if self.relative_position + prev_pos = self.class. + in_projects(project.id). + where('relative_position < ?', self.relative_position). + maximum(:relative_position) + end + + prev_pos + end + + def next_relative_position + next_pos = nil + + if self.relative_position + next_pos = self.class. + in_projects(project.id). + where('relative_position > ?', self.relative_position). + minimum(:relative_position) + end + + next_pos + end + + def move_between(before, after) + return move_after(before) unless after + return move_before(after) unless before + + # If there is no place to insert an issue we need to create one by moving the before issue closer + # to its predecessor. This process will recursively move all the predecessors until we have a place + if (after.relative_position - before.relative_position) < 2 + before.move_before + @positionable_neighbours = [before] + end + + self.relative_position = position_between(before.relative_position, after.relative_position) + end + + def move_after(before = self) + pos_before = before.relative_position + pos_after = before.next_relative_position + + if before.shift_after? + issue_to_move = self.class.in_projects(project.id).find_by!(relative_position: pos_after) + issue_to_move.move_after + @positionable_neighbours = [issue_to_move] + + pos_after = issue_to_move.relative_position + end + + self.relative_position = position_between(pos_before, pos_after) + end + + def move_before(after = self) + pos_after = after.relative_position + pos_before = after.prev_relative_position + + if after.shift_before? + issue_to_move = self.class.in_projects(project.id).find_by!(relative_position: pos_before) + issue_to_move.move_before + @positionable_neighbours = [issue_to_move] + + pos_before = issue_to_move.relative_position + end + + self.relative_position = position_between(pos_before, pos_after) + end + + def move_to_end + self.relative_position = position_between(max_relative_position || START_POSITION, MAX_POSITION) + end + + # Indicates if there is an issue that should be shifted to free the place + def shift_after? + next_pos = next_relative_position + next_pos && (next_pos - relative_position) == 1 + end + + # Indicates if there is an issue that should be shifted to free the place + def shift_before? + prev_pos = prev_relative_position + prev_pos && (relative_position - prev_pos) == 1 + end + + private + + # This method takes two integer values (positions) and + # calculates the position between them. The range is huge as + # the maximum integer value is 2147483647. We are incrementing position by IDEAL_DISTANCE * 2 every time + # when we have enough space. If distance is less then IDEAL_DISTANCE we are calculating an average number + def position_between(pos_before, pos_after) + pos_before ||= MIN_POSITION + pos_after ||= MAX_POSITION + + pos_before, pos_after = [pos_before, pos_after].sort + + halfway = (pos_after + pos_before) / 2 + distance_to_halfway = pos_after - halfway + + if distance_to_halfway < IDEAL_DISTANCE + halfway + else + if pos_before == MIN_POSITION + pos_after - IDEAL_DISTANCE + elsif pos_after == MAX_POSITION + pos_before + IDEAL_DISTANCE + else + halfway + end + end + end + + def save_positionable_neighbours + return unless @positionable_neighbours + + status = @positionable_neighbours.all?(&:save) + @positionable_neighbours = nil + + status + end +end diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index 9f6d215ceb3..529fb5ce988 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -51,11 +51,13 @@ module Routable paths.each do |path| path = connection.quote(path) - where = "(routes.path = #{path})" - if cast_lower - where = "(#{where} OR (LOWER(routes.path) = LOWER(#{path})))" - end + where = + if cast_lower + "(LOWER(routes.path) = LOWER(#{path}))" + else + "(routes.path = #{path})" + end wheres << where end diff --git a/app/models/concerns/sortable.rb b/app/models/concerns/sortable.rb index 7edb0acd56c..b9a2d812edd 100644 --- a/app/models/concerns/sortable.rb +++ b/app/models/concerns/sortable.rb @@ -46,11 +46,12 @@ module Sortable where("label_links.target_id = #{target_column}"). reorder(nil) - if target_type_column - query = query.where("label_links.target_type = #{target_type_column}") - else - query = query.where(label_links: { target_type: target_type }) - end + query = + if target_type_column + query.where("label_links.target_type = #{target_type_column}") + else + query.where(label_links: { target_type: target_type }) + end query = query.where.not(title: excluded_labels) if excluded_labels.present? diff --git a/app/models/concerns/uniquify.rb b/app/models/concerns/uniquify.rb new file mode 100644 index 00000000000..a7fe5951b6e --- /dev/null +++ b/app/models/concerns/uniquify.rb @@ -0,0 +1,30 @@ +class Uniquify + # Return a version of the given 'base' string that is unique + # by appending a counter to it. Uniqueness is determined by + # repeated calls to the passed block. + # + # If `base` is a function/proc, we expect that calling it with a + # candidate counter returns a string to test/return. + def string(base) + @base = base + @counter = nil + + increment_counter! while yield(base_string) + base_string + end + + private + + def base_string + if @base.respond_to?(:call) + @base.call(@counter) + else + "#{@base}#{@counter}" + end + end + + def increment_counter! + @counter ||= 0 + @counter += 1 + end +end diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index 559b3075905..895a91139c9 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -8,7 +8,7 @@ class DiffNote < Note validates :position, presence: true validates :diff_line, presence: true validates :line_code, presence: true, line_code: true - validates :noteable_type, inclusion: { in: ['Commit', 'MergeRequest'] } + validates :noteable_type, inclusion: { in: %w(Commit MergeRequest) } validates :resolved_by, presence: true, if: :resolved? validate :positions_complete validate :verify_supported diff --git a/app/models/environment.rb b/app/models/environment.rb index 1a21b5e52b5..bf33010fd21 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -145,6 +145,14 @@ class Environment < ActiveRecord::Base project.deployment_service.terminals(self) if has_terminals? end + def has_metrics? + project.monitoring_service.present? && available? && last_deployment.present? + end + + def metrics + project.monitoring_service.metrics(self) if has_metrics? + end + # An environment name is not necessarily suitable for use in URLs, DNS # or other third-party contexts, so provide a slugified version. A slug has # the following properties: diff --git a/app/models/event.rb b/app/models/event.rb index e5027df3f8a..5c34844b5d3 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -16,7 +16,7 @@ class Event < ActiveRecord::Base RESET_PROJECT_ACTIVITY_INTERVAL = 1.hour - delegate :name, :email, to: :author, prefix: true, allow_nil: true + delegate :name, :email, :public_email, to: :author, prefix: true, allow_nil: true delegate :title, to: :issue, prefix: true, allow_nil: true delegate :title, to: :merge_request, prefix: true, allow_nil: true delegate :title, to: :note, prefix: true, allow_nil: true @@ -36,7 +36,7 @@ class Event < ActiveRecord::Base scope :code_push, -> { where(action: PUSHED) } scope :in_projects, ->(projects) do - where(project_id: projects).recent + where(project_id: projects.pluck(:id)).recent end scope :with_associations, -> { includes(:author, :project, project: :namespace).preload(:target) } @@ -47,7 +47,7 @@ class Event < ActiveRecord::Base def contributions where("action = ? OR (target_type IN (?) AND action IN (?)) OR (target_type = ? AND action = ?)", Event::PUSHED, - ["MergeRequest", "Issue"], [Event::CREATED, Event::CLOSED, Event::MERGED], + %w(MergeRequest Issue), [Event::CREATED, Event::CLOSED, Event::MERGED], "Note", Event::COMMENTED) end diff --git a/app/models/external_issue.rb b/app/models/external_issue.rb index 26712c19b5a..e63f89a9f85 100644 --- a/app/models/external_issue.rb +++ b/app/models/external_issue.rb @@ -24,6 +24,11 @@ class ExternalIssue def ==(other) other.is_a?(self.class) && (to_s == other.to_s) end + alias_method :eql?, :== + + def hash + [self.class, to_s].hash + end def project @project @@ -43,7 +48,7 @@ class ExternalIssue end def reference_link_text(from_project = nil) - return "##{id}" if /^\d+$/.match(id) + return "##{id}" if id =~ /^\d+$/ id end diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb index b991d78e27f..0afbca2cb32 100644 --- a/app/models/global_milestone.rb +++ b/app/models/global_milestone.rb @@ -28,6 +28,28 @@ class GlobalMilestone new(title, child_milestones) end + def self.states_count(projects) + relation = MilestonesFinder.new.execute(projects, state: 'all') + milestones_by_state_and_title = relation.reorder(nil).group(:state, :title).count + + opened = count_by_state(milestones_by_state_and_title, 'active') + closed = count_by_state(milestones_by_state_and_title, 'closed') + all = milestones_by_state_and_title.map { |(_, title), _| title }.uniq.count + + { + opened: opened, + closed: closed, + all: all + } + end + + def self.count_by_state(milestones_by_state_and_title, state) + milestones_by_state_and_title.count do |(milestone_state, _), _| + milestone_state == state + end + end + private_class_method :count_by_state + def initialize(title, milestones) @title = title @name = title diff --git a/app/models/group.rb b/app/models/group.rb index 240a17f1dc1..bd0ecae3da4 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -28,6 +28,7 @@ class Group < Namespace validates :avatar, file_size: { maximum: 200.kilobytes.to_i } mount_uploader :avatar, AvatarUploader + has_many :uploads, as: :model, dependent: :destroy after_create :post_create_hook after_destroy :post_destroy_hook @@ -93,7 +94,7 @@ class Group < Namespace end def visibility_level_field - visibility_level + :visibility_level end def visibility_level_allowed_by_projects @@ -212,4 +213,14 @@ class Group < Namespace def users_with_parents User.where(id: members_with_parents.select(:user_id)) end + + def mattermost_team_params + max_length = 59 + + { + name: path[0..max_length], + display_name: name[0..max_length], + type: public? ? 'O' : 'I' # Open vs Invite-only + } + end end diff --git a/app/models/guest.rb b/app/models/guest.rb index 01285ca1264..df287c277a7 100644 --- a/app/models/guest.rb +++ b/app/models/guest.rb @@ -1,6 +1,6 @@ class Guest class << self - def can?(action, subject) + def can?(action, subject = :global) Ability.allowed?(nil, action, subject) end end diff --git a/app/models/issue.rb b/app/models/issue.rb index d8826b65fcc..602eed86d9e 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -7,6 +7,7 @@ class Issue < ActiveRecord::Base include Sortable include Spammable include FasterCacheKeys + include RelativePositioning DueDateStruct = Struct.new(:title, :name).freeze NoDueDate = DueDateStruct.new('No Due Date', '0').freeze @@ -15,8 +16,6 @@ class Issue < ActiveRecord::Base DueThisWeek = DueDateStruct.new('Due This Week', 'week').freeze DueThisMonth = DueDateStruct.new('Due This Month', 'month').freeze - ActsAsTaggableOn.strict_case_match = true - belongs_to :project belongs_to :moved_to, class_name: 'Issue' @@ -56,10 +55,24 @@ class Issue < ActiveRecord::Base state :opened state :reopened state :closed + + before_transition any => :closed do |issue| + issue.closed_at = Time.zone.now + end + + before_transition closed: any do |issue| + issue.closed_at = nil + end end def hook_attrs - attributes + attrs = { + total_time_spent: total_time_spent, + human_total_time_spent: human_total_time_spent, + human_time_estimate: human_time_estimate + } + + attributes.merge!(attrs) end def self.reference_prefix @@ -97,6 +110,13 @@ class Issue < ActiveRecord::Base end end + def self.order_by_position_and_priority + order_labels_priority. + reorder(Gitlab::Database.nulls_last_order('relative_position', 'ASC'), + Gitlab::Database.nulls_last_order('highest_priority', 'ASC'), + "id DESC") + end + # `from` argument can be a Namespace or Project. def to_reference(from = nil, full: false) reference = "#{self.class.reference_prefix}#{iid}" diff --git a/app/models/label.rb b/app/models/label.rb index 5b6b9a7a736..568fa6d44f5 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -11,7 +11,7 @@ class Label < ActiveRecord::Base cache_markdown_field :description, pipeline: :single_line - DEFAULT_COLOR = '#428BCA' + DEFAULT_COLOR = '#428BCA'.freeze default_value_for :color, DEFAULT_COLOR @@ -169,6 +169,10 @@ class Label < ActiveRecord::Base end end + def hook_attrs + attributes + end + private def issues_count(user, params = {}) diff --git a/app/models/member.rb b/app/models/member.rb index d07f270b757..0545bd4eedf 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -10,6 +10,8 @@ class Member < ActiveRecord::Base belongs_to :user belongs_to :source, polymorphic: true + delegate :name, :username, :email, to: :user, prefix: true + validates :user, presence: true, unless: :invite? validates :source, presence: true validates :user_id, uniqueness: { scope: [:source_type, :source_id], @@ -73,8 +75,6 @@ class Member < ActiveRecord::Base after_destroy :post_destroy_hook, unless: :pending? after_commit :refresh_member_authorized_projects - delegate :name, :username, :email, to: :user, prefix: true - default_value_for :notification_level, NotificationSetting.levels[:global] class << self diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index 204f34f0269..446f9f8f8a7 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -1,11 +1,11 @@ class GroupMember < Member - SOURCE_TYPE = 'Namespace' + SOURCE_TYPE = 'Namespace'.freeze belongs_to :group, foreign_key: 'source_id' # Make sure group member points only to group as it source default_value_for :source_type, SOURCE_TYPE - validates_format_of :source_type, with: /\ANamespace\z/ + validates :source_type, format: { with: /\ANamespace\z/ } default_scope { where(source_type: SOURCE_TYPE) } def self.access_level_roles diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 008fff0857c..912820b51ac 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -1,5 +1,5 @@ class ProjectMember < Member - SOURCE_TYPE = 'Project' + SOURCE_TYPE = 'Project'.freeze include Gitlab::ShellAdapter @@ -7,7 +7,7 @@ class ProjectMember < Member # Make sure project member points only to project as it source default_value_for :source_type, SOURCE_TYPE - validates_format_of :source_type, with: /\AProject\z/ + validates :source_type, format: { with: /\AProject\z/ } validates :access_level, inclusion: { in: Gitlab::Access.values } default_scope { where(source_type: SOURCE_TYPE) } diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 204d2b153ad..cef8ad76b07 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -7,6 +7,7 @@ class MergeRequest < ActiveRecord::Base belongs_to :target_project, class_name: "Project" belongs_to :source_project, class_name: "Project" + belongs_to :project, foreign_key: :target_project_id belongs_to :merge_user, class_name: "User" has_many :merge_request_diffs, dependent: :destroy @@ -91,17 +92,13 @@ class MergeRequest < ActiveRecord::Base around_transition do |merge_request, transition, block| Gitlab::Timeless.timeless(merge_request, &block) end - - after_transition unchecked: :cannot_be_merged do |merge_request, transition| - TodoService.new.merge_request_became_unmergeable(merge_request) - end end validates :source_project, presence: true, unless: [:allow_broken, :importing?, :closed_without_fork?] validates :source_branch, presence: true validates :target_project, presence: true validates :target_branch, presence: true - validates :merge_user, presence: true, if: :merge_when_build_succeeds?, unless: :importing? + validates :merge_user, presence: true, if: :merge_when_pipeline_succeeds?, unless: :importing? validate :validate_branches, unless: [:allow_broken, :importing?, :closed_without_fork?] validate :validate_fork, unless: :closed_without_fork? @@ -203,7 +200,11 @@ class MergeRequest < ActiveRecord::Base end def diff_size - opts = diff_options || {} + # The `#diffs` method ends up at an instance of a class inheriting from + # `Gitlab::Diff::FileCollection::Base`, so use those options as defaults + # here too, to get the same diff size without performing highlighting. + # + opts = Gitlab::Diff::FileCollection::Base.default_options.merge(diff_options || {}) raw_diffs(opts).size end @@ -436,7 +437,7 @@ class MergeRequest < ActiveRecord::Base true end - def can_cancel_merge_when_build_succeeds?(current_user) + def can_cancel_merge_when_pipeline_succeeds?(current_user) can_be_merged_by?(current_user) || self.author == current_user end @@ -523,11 +524,14 @@ class MergeRequest < ActiveRecord::Base source: source_project.try(:hook_attrs), target: target_project.hook_attrs, last_commit: nil, - work_in_progress: work_in_progress? + work_in_progress: work_in_progress?, + total_time_spent: total_time_spent, + human_total_time_spent: human_total_time_spent, + human_time_estimate: human_time_estimate } if diff_head_commit - attrs.merge!(last_commit: diff_head_commit.hook_attrs) + attrs[:last_commit] = diff_head_commit.hook_attrs end attributes.merge!(attrs) @@ -537,10 +541,6 @@ class MergeRequest < ActiveRecord::Base target_project != source_project end - def project - target_project - end - # If the merge request closes any issues, save this information in the # `MergeRequestsClosingIssues` model. This is a performance optimization. # Calculating this information for a number of merge requests requires @@ -644,10 +644,10 @@ class MergeRequest < ActiveRecord::Base message.join("\n\n") end - def reset_merge_when_build_succeeds - return unless merge_when_build_succeeds? + def reset_merge_when_pipeline_succeeds + return unless merge_when_pipeline_succeeds? - self.merge_when_build_succeeds = false + self.merge_when_pipeline_succeeds = false self.merge_user = nil if merge_params merge_params.delete('should_remove_source_branch') @@ -684,7 +684,10 @@ class MergeRequest < ActiveRecord::Base end def has_ci? - source_project.try(:ci_service) && commits.any? + has_ci_integration = source_project.try(:ci_service) + uses_gitlab_ci = all_pipelines.any? + + (has_ci_integration || uses_gitlab_ci) && commits.any? end def branch_missing? @@ -706,7 +709,7 @@ class MergeRequest < ActiveRecord::Base end def mergeable_ci_state? - return true unless project.only_allow_merge_if_build_succeeds? + return true unless project.only_allow_merge_if_pipeline_succeeds? !head_pipeline || head_pipeline.success? || head_pipeline.skipped? end diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 70bad2a4396..baee00b8fcd 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -7,7 +7,7 @@ class MergeRequestDiff < ActiveRecord::Base COMMITS_SAFE_SIZE = 100 # Valid types of serialized diffs allowed by Gitlab::Git::Diff - VALID_CLASSES = [Hash, Rugged::Patch, Rugged::Diff::Delta] + VALID_CLASSES = [Hash, Rugged::Patch, Rugged::Diff::Delta].freeze belongs_to :merge_request diff --git a/app/models/merge_requests_closing_issues.rb b/app/models/merge_requests_closing_issues.rb index ab597c37947..daafb137be4 100644 --- a/app/models/merge_requests_closing_issues.rb +++ b/app/models/merge_requests_closing_issues.rb @@ -4,4 +4,12 @@ class MergeRequestsClosingIssues < ActiveRecord::Base validates :merge_request_id, uniqueness: { scope: :issue_id }, presence: true validates :issue_id, presence: true + + class << self + def count_for_collection(ids) + group(:issue_id). + where(issue_id: ids). + pluck('issue_id', 'COUNT(*) as count') + end + end end diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 7331000a9f2..c0deb59ec4c 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -5,6 +5,7 @@ class Milestone < ActiveRecord::Base None = MilestoneStruct.new('No Milestone', 'No Milestone', 0) Any = MilestoneStruct.new('Any Milestone', '', -1) Upcoming = MilestoneStruct.new('Upcoming', '#upcoming', -2) + Started = MilestoneStruct.new('Started', '#started', -3) include CacheMarkdownField include InternalId diff --git a/app/models/namespace.rb b/app/models/namespace.rb index a803be2e780..4ae9d0122f2 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -20,6 +20,7 @@ class Namespace < ActiveRecord::Base belongs_to :parent, class_name: "Namespace" has_many :children, class_name: "Namespace", foreign_key: :parent_id + has_one :chat_team, dependent: :destroy validates :owner, presence: true, unless: ->(n) { n.type == "Group" } validates :name, @@ -98,14 +99,8 @@ class Namespace < ActiveRecord::Base # Work around that by setting their username to "blank", followed by a counter. path = "blank" if path.blank? - counter = 0 - base = path - while Namespace.find_by_path_or_name(path) - counter += 1 - path = "#{base}#{counter}" - end - - path + uniquify = Uniquify.new + uniquify.string(path) { |s| Namespace.find_by_path_or_name(s) } end end diff --git a/app/models/network/graph.rb b/app/models/network/graph.rb index b524ca50ee8..0bbc9451ffd 100644 --- a/app/models/network/graph.rb +++ b/app/models/network/graph.rb @@ -188,11 +188,12 @@ module Network end # and mark it as reserved - if parent_time.nil? - min_time = leaves.first.time - else - min_time = parent_time + 1 - end + min_time = + if parent_time.nil? + leaves.first.time + else + parent_time + 1 + end max_time = leaves.last.time leaves.last.parents(@map).each do |parent| diff --git a/app/models/note.rb b/app/models/note.rb index 029fe667a45..e22e96aec6f 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -72,7 +72,7 @@ class Note < ActiveRecord::Base scope :inc_author, ->{ includes(:author) } scope :inc_relations_for_view, ->{ includes(:project, :author, :updated_by, :resolved_by, :award_emoji) } - scope :diff_notes, ->{ where(type: ['LegacyDiffNote', 'DiffNote']) } + scope :diff_notes, ->{ where(type: %w(LegacyDiffNote DiffNote)) } scope :non_diff_notes, ->{ where(type: ['Note', nil]) } scope :with_associations, -> do @@ -85,6 +85,7 @@ class Note < ActiveRecord::Base before_validation :nullify_blank_type, :nullify_blank_line_code before_validation :set_discussion_id after_save :keep_around_commit, unless: :for_personal_snippet? + after_save :expire_etag_cache class << self def model_name @@ -231,10 +232,6 @@ class Note < ActiveRecord::Base note =~ /\A#{Banzai::Filter::EmojiFilter.emoji_pattern}\s?\Z/ end - def award_emoji_name - note.match(Banzai::Filter::EmojiFilter.emoji_pattern)[1] - end - def to_ability_name for_personal_snippet? ? 'personal_snippet' : noteable_type.underscore end @@ -276,4 +273,16 @@ class Note < ActiveRecord::Base self.class.build_discussion_id(noteable_type, noteable_id || commit_id) end end + + def expire_etag_cache + return unless for_issue? + + key = Gitlab::Routing.url_helpers.namespace_project_noteable_notes_path( + noteable.project.namespace, + noteable.project, + target_type: noteable_type.underscore, + target_id: noteable.id + ) + Gitlab::EtagCaching::Store.new.touch(key) + end end diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index 58f6214bea7..52577bd52ea 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -35,11 +35,11 @@ class NotificationSetting < ActiveRecord::Base :merge_merge_request, :failed_pipeline, :success_pipeline - ] + ].freeze EXCLUDED_WATCHER_EVENTS = [ :success_pipeline - ] + ].freeze store :events, accessors: EMAIL_EVENTS, coder: JSON diff --git a/app/models/oauth_access_grant.rb b/app/models/oauth_access_grant.rb new file mode 100644 index 00000000000..3a997406565 --- /dev/null +++ b/app/models/oauth_access_grant.rb @@ -0,0 +1,4 @@ +class OauthAccessGrant < Doorkeeper::AccessGrant + belongs_to :resource_owner, class_name: 'User' + belongs_to :application, class_name: 'Doorkeeper::Application' +end diff --git a/app/models/oauth_access_token.rb b/app/models/oauth_access_token.rb index 116fb71ac08..b85f5dbaf2e 100644 --- a/app/models/oauth_access_token.rb +++ b/app/models/oauth_access_token.rb @@ -1,4 +1,4 @@ -class OauthAccessToken < ActiveRecord::Base +class OauthAccessToken < Doorkeeper::AccessToken belongs_to :resource_owner, class_name: 'User' belongs_to :application, class_name: 'Doorkeeper::Application' end diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb index 0b9ebf1ffe2..f2f2fc1e32a 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -2,7 +2,7 @@ class PagesDomain < ActiveRecord::Base belongs_to :project validates :domain, hostname: true - validates_uniqueness_of :domain, case_sensitive: false + validates :domain, uniqueness: { case_sensitive: false } validates :certificate, certificate: true, allow_nil: true, allow_blank: true validates :key, certificate_key: true, allow_nil: true, allow_blank: true diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index 10a34c42fd8..e8b000ddad6 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -1,4 +1,5 @@ class PersonalAccessToken < ActiveRecord::Base + include Expirable include TokenAuthenticatable add_authentication_token_field :token @@ -6,17 +7,30 @@ class PersonalAccessToken < ActiveRecord::Base belongs_to :user - scope :active, -> { where(revoked: false).where("expires_at >= NOW() OR expires_at IS NULL") } + before_save :ensure_token + + scope :active, -> { where("revoked = false AND (expires_at >= NOW() OR expires_at IS NULL)") } scope :inactive, -> { where("revoked = true OR expires_at < NOW()") } + scope :with_impersonation, -> { where(impersonation: true) } + scope :without_impersonation, -> { where(impersonation: false) } - def self.generate(params) - personal_access_token = self.new(params) - personal_access_token.ensure_token - personal_access_token - end + validates :scopes, presence: true + validate :validate_api_scopes def revoke! self.revoked = true self.save end + + def active? + !revoked? && !expired? + end + + protected + + def validate_api_scopes + unless scopes.all? { |scope| Gitlab::Auth::API_SCOPES.include?(scope.to_sym) } + errors.add :scopes, "can only contain API scopes" + end + end end diff --git a/app/models/project.rb b/app/models/project.rb index d4f5584f53d..928965643a0 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -19,10 +19,10 @@ class Project < ActiveRecord::Base extend Gitlab::ConfigHelper - class BoardLimitExceeded < StandardError; end + BoardLimitExceeded = Class.new(StandardError) NUMBER_OF_PERMITTED_BOARDS = 1 - UNKNOWN_IMPORT_URL = 'http://unknown.git' + UNKNOWN_IMPORT_URL = 'http://unknown.git'.freeze cache_markdown_field :description, pipeline: :description @@ -70,8 +70,7 @@ class Project < ActiveRecord::Base after_validation :check_pending_delete - ActsAsTaggableOn.strict_case_match = true - acts_as_taggable_on :tags + acts_as_taggable attr_accessor :new_default_branch attr_accessor :old_path_with_namespace @@ -90,7 +89,6 @@ class Project < ActiveRecord::Base has_one :campfire_service, dependent: :destroy has_one :drone_ci_service, dependent: :destroy has_one :emails_on_push_service, dependent: :destroy - has_one :builds_email_service, dependent: :destroy has_one :pipelines_email_service, dependent: :destroy has_one :irker_service, dependent: :destroy has_one :pivotaltracker_service, dependent: :destroy @@ -114,6 +112,8 @@ class Project < ActiveRecord::Base has_one :gitlab_issue_tracker_service, dependent: :destroy, inverse_of: :project has_one :external_wiki_service, dependent: :destroy has_one :kubernetes_service, dependent: :destroy, inverse_of: :project + has_one :prometheus_service, dependent: :destroy, inverse_of: :project + has_one :mock_ci_service, dependent: :destroy has_one :forked_project_link, dependent: :destroy, foreign_key: "forked_to_project_id" has_one :forked_from_project, through: :forked_project_link @@ -159,13 +159,13 @@ class Project < ActiveRecord::Base has_one :statistics, class_name: 'ProjectStatistics', dependent: :delete has_many :container_images, dependent: :destroy - has_many :commit_statuses, dependent: :destroy, foreign_key: :gl_project_id - has_many :pipelines, dependent: :destroy, class_name: 'Ci::Pipeline', foreign_key: :gl_project_id - has_many :builds, class_name: 'Ci::Build', foreign_key: :gl_project_id # the builds are created from the commit_statuses - has_many :runner_projects, dependent: :destroy, class_name: 'Ci::RunnerProject', foreign_key: :gl_project_id + has_many :commit_statuses, dependent: :destroy + has_many :pipelines, dependent: :destroy, class_name: 'Ci::Pipeline' + has_many :builds, class_name: 'Ci::Build' # the builds are created from the commit_statuses + has_many :runner_projects, dependent: :destroy, class_name: 'Ci::RunnerProject' has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' - has_many :variables, dependent: :destroy, class_name: 'Ci::Variable', foreign_key: :gl_project_id - has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :gl_project_id + has_many :variables, dependent: :destroy, class_name: 'Ci::Variable' + has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger' has_many :environments, dependent: :destroy has_many :deployments, dependent: :destroy @@ -173,9 +173,11 @@ class Project < ActiveRecord::Base accepts_nested_attributes_for :project_feature delegate :name, to: :owner, allow_nil: true, prefix: true + delegate :count, to: :forks, prefix: true delegate :members, to: :team, prefix: true delegate :add_user, to: :team delegate :add_guest, :add_reporter, :add_developer, :add_master, to: :team + delegate :empty_repo?, to: :repository # Validations validates :creator, presence: true, on: :create @@ -192,9 +194,10 @@ class Project < ActiveRecord::Base format: { with: Gitlab::Regex.project_path_regex, message: Gitlab::Regex.project_path_regex_message } validates :namespace, presence: true - validates_uniqueness_of :name, scope: :namespace_id - validates_uniqueness_of :path, scope: :namespace_id + validates :name, uniqueness: { scope: :namespace_id } + validates :path, uniqueness: { scope: :namespace_id } validates :import_url, addressable_url: true, if: :external_import? + validates :import_url, importable_url: true, if: [:external_import?, :import_url_changed?] validates :star_count, numericality: { greater_than_or_equal_to: 0 } validate :check_limit, on: :create validate :avatar_type, @@ -211,6 +214,7 @@ class Project < ActiveRecord::Base before_save :ensure_runners_token mount_uploader :avatar, AvatarUploader + has_many :uploads, as: :model, dependent: :destroy # Scopes default_scope { where(pending_delete: false) } @@ -334,7 +338,7 @@ class Project < ActiveRecord::Base end def search_by_visibility(level) - where(visibility_level: Gitlab::VisibilityLevel.const_get(level.upcase)) + where(visibility_level: Gitlab::VisibilityLevel.string_options[level]) end def search_by_title(query) @@ -359,7 +363,7 @@ class Project < ActiveRecord::Base end def reference_pattern - name_pattern = Gitlab::Regex::NAMESPACE_REGEX_STR + name_pattern = Gitlab::Regex::FULL_NAMESPACE_REGEX_STR %r{ ((?<namespace>#{name_pattern})\/)? @@ -390,7 +394,7 @@ class Project < ActiveRecord::Base end def repository_storage_path - Gitlab.config.repositories.storages[repository_storage] + Gitlab.config.repositories.storages[repository_storage]['path'] end def team @@ -452,13 +456,14 @@ class Project < ActiveRecord::Base end def add_import_job - if forked? - job_id = RepositoryForkWorker.perform_async(id, forked_from_project.repository_storage_path, - forked_from_project.path_with_namespace, - self.namespace.full_path) - else - job_id = RepositoryImportWorker.perform_async(self.id) - end + job_id = + if forked? + RepositoryForkWorker.perform_async(id, forked_from_project.repository_storage_path, + forked_from_project.path_with_namespace, + self.namespace.full_path) + else + RepositoryImportWorker.perform_async(self.id) + end if job_id Rails.logger.info "Import job started for #{path_with_namespace} with job ID #{job_id}" @@ -766,6 +771,14 @@ class Project < ActiveRecord::Base @deployment_service ||= deployment_services.reorder(nil).find_by(active: true) end + def monitoring_services + services.where(category: :monitoring) + end + + def monitoring_service + @monitoring_service ||= monitoring_services.reorder(nil).find_by(active: true) + end + def jira_tracker? issues_tracker.to_param == 'jira' end @@ -836,10 +849,6 @@ class Project < ActiveRecord::Base false end - def empty_repo? - repository.empty_repo? - end - def repo repository.raw end @@ -848,10 +857,6 @@ class Project < ActiveRecord::Base gitlab_shell.url_to_repo(path_with_namespace) end - def namespace_dir - namespace.try(:path) || '' - end - def repo_exists? @repo_exists ||= repository.exists? rescue @@ -874,8 +879,10 @@ class Project < ActiveRecord::Base url_to_repo end - def http_url_to_repo - "#{web_url}.git" + def http_url_to_repo(user = nil) + credentials = Gitlab::UrlSanitizer.http_credentials_for_user(user) + + Gitlab::UrlSanitizer.new("#{web_url}.git", credentials: credentials).full_url end # Check if current branch name is marked as protected in the system @@ -900,8 +907,8 @@ class Project < ActiveRecord::Base def rename_repo path_was = previous_changes['path'].first - old_path_with_namespace = File.join(namespace_dir, path_was) - new_path_with_namespace = File.join(namespace_dir, path) + old_path_with_namespace = File.join(namespace.full_path, path_was) + new_path_with_namespace = File.join(namespace.full_path, path) Rails.logger.error "Attempting to rename #{old_path_with_namespace} -> #{new_path_with_namespace}" @@ -1002,7 +1009,7 @@ class Project < ActiveRecord::Base end def visibility_level_field - visibility_level + :visibility_level end def archive! @@ -1027,10 +1034,6 @@ class Project < ActiveRecord::Base forked? && project == forked_from_project end - def forks_count - forks.count - end - def origin_merge_requests merge_requests.where(source_project_id: self.id) end @@ -1201,6 +1204,10 @@ class Project < ActiveRecord::Base end end + def pipeline_status + @pipeline_status ||= Ci::PipelineStatus.load_for_project(self) + end + def mark_import_as_failed(error_message) original_errors = errors.dup sanitized_message = Gitlab::UrlSanitizer.sanitize(error_message) diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index 03194fc2141..e3ef4919b28 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -18,7 +18,7 @@ class ProjectFeature < ActiveRecord::Base PRIVATE = 10 ENABLED = 20 - FEATURES = %i(issues merge_requests wiki snippets builds repository) + FEATURES = %i(issues merge_requests wiki snippets builds repository).freeze class << self def access_level_attribute(feature) diff --git a/app/models/project_group_link.rb b/app/models/project_group_link.rb index 5cb6b0c527d..ac1e9ab2b0b 100644 --- a/app/models/project_group_link.rb +++ b/app/models/project_group_link.rb @@ -33,8 +33,15 @@ class ProjectGroupLink < ActiveRecord::Base private def different_group - if self.group && self.project && self.project.group == self.group - errors.add(:base, "Project cannot be shared with the project it is in.") + return unless self.group && self.project + + project_group = self.project.group + return unless project_group + + group_ids = project_group.ancestors.map(&:id).push(project_group.id) + + if group_ids.include?(self.group.id) + errors.add(:base, "Project cannot be shared with the group it is in or one of its ancestors.") end end diff --git a/app/models/project_services/buildkite_service.rb b/app/models/project_services/buildkite_service.rb index 0956c4a4ede..5fb95050b83 100644 --- a/app/models/project_services/buildkite_service.rb +++ b/app/models/project_services/buildkite_service.rb @@ -3,7 +3,7 @@ require "addressable/uri" class BuildkiteService < CiService include ReactiveService - ENDPOINT = "https://buildkite.com" + ENDPOINT = "https://buildkite.com".freeze prop_accessor :project_url, :token boolean_accessor :enable_ssl_verification diff --git a/app/models/project_services/builds_email_service.rb b/app/models/project_services/builds_email_service.rb index ebd21e37189..0c526b53d72 100644 --- a/app/models/project_services/builds_email_service.rb +++ b/app/models/project_services/builds_email_service.rb @@ -1,107 +1,11 @@ +# This class is to be removed with 9.1 +# We should also by then remove BuildsEmailService from database class BuildsEmailService < Service - prop_accessor :recipients - boolean_accessor :add_pusher - boolean_accessor :notify_only_broken_builds - validates :recipients, presence: true, if: ->(s) { s.activated? && !s.add_pusher? } - - def initialize_properties - if properties.nil? - self.properties = {} - self.notify_only_broken_builds = true - end - end - - def title - 'Builds emails' - end - - def description - 'Email the builds status to a list of recipients.' - end - def self.to_param 'builds_email' end def self.supported_events - %w(build) - end - - def execute(push_data) - return unless supported_events.include?(push_data[:object_kind]) - return unless should_build_be_notified?(push_data) - - recipients = all_recipients(push_data) - - if recipients.any? - BuildEmailWorker.perform_async( - push_data[:build_id], - recipients, - push_data - ) - end - end - - def can_test? - project.builds.any? - end - - def disabled_title - "Please setup a build on your repository." - end - - def test_data(project = nil, user = nil) - Gitlab::DataBuilder::Build.build(project.builds.last) - end - - def fields - [ - { type: 'textarea', name: 'recipients', placeholder: 'Emails separated by comma' }, - { type: 'checkbox', name: 'add_pusher', label: 'Add pusher to recipients list' }, - { type: 'checkbox', name: 'notify_only_broken_builds' }, - ] - end - - def test(data) - begin - # bypass build status verification when testing - data[:build_status] = "failed" - data[:build_allow_failure] = false - - result = execute(data) - rescue StandardError => error - return { success: false, result: error } - end - - { success: true, result: result } - end - - def should_build_be_notified?(data) - case data[:build_status] - when 'success' - !notify_only_broken_builds? - when 'failed' - !allow_failure?(data) - else - false - end - end - - def allow_failure?(data) - data[:build_allow_failure] == true - end - - def all_recipients(data) - all_recipients = [] - - unless recipients.blank? - all_recipients += recipients.split(',').compact.reject(&:blank?) - end - - if add_pusher? && data[:user][:email] - all_recipients << data[:user][:email] - end - - all_recipients + %w[] end end diff --git a/app/models/project_services/chat_message/build_message.rb b/app/models/project_services/chat_message/build_message.rb deleted file mode 100644 index c776e0a20c4..00000000000 --- a/app/models/project_services/chat_message/build_message.rb +++ /dev/null @@ -1,102 +0,0 @@ -module ChatMessage - class BuildMessage < BaseMessage - attr_reader :sha - attr_reader :ref_type - attr_reader :ref - attr_reader :status - attr_reader :project_name - attr_reader :project_url - attr_reader :user_name - attr_reader :user_url - attr_reader :duration - attr_reader :stage - attr_reader :build_id - attr_reader :build_name - - def initialize(params) - @sha = params[:sha] - @ref_type = params[:tag] ? 'tag' : 'branch' - @ref = params[:ref] - @project_name = params[:project_name] - @project_url = params[:project_url] - @status = params[:commit][:status] - @user_name = params[:commit][:author_name] - @user_url = params[:commit][:author_url] - @duration = params[:commit][:duration] - @stage = params[:build_stage] - @build_name = params[:build_name] - @build_id = params[:build_id] - end - - def pretext - '' - end - - def fallback - format(message) - end - - def attachments - [{ text: format(message), color: attachment_color }] - end - - private - - def message - "#{project_link}: Commit #{commit_link} of #{branch_link} #{ref_type} by #{user_link} #{humanized_status} on build #{build_link} of stage #{stage} in #{duration} #{'second'.pluralize(duration)}" - end - - def build_url - "#{project_url}/builds/#{build_id}" - end - - def build_link - link(build_name, build_url) - end - - def user_link - link(user_name, user_url) - end - - def format(string) - Slack::Notifier::LinkFormatter.format(string) - end - - def humanized_status - case status - when 'success' - 'passed' - else - status - end - end - - def attachment_color - if status == 'success' - 'good' - else - 'danger' - end - end - - def branch_url - "#{project_url}/commits/#{ref}" - end - - def branch_link - link(ref, branch_url) - end - - def project_link - link(project_name, project_url) - end - - def commit_url - "#{project_url}/commit/#{sha}/builds" - end - - def commit_link - link(Commit.truncate_sha(sha), commit_url) - end - end -end diff --git a/app/models/project_services/chat_message/issue_message.rb b/app/models/project_services/chat_message/issue_message.rb index b96aca47e65..791e5b0cec7 100644 --- a/app/models/project_services/chat_message/issue_message.rb +++ b/app/models/project_services/chat_message/issue_message.rb @@ -51,7 +51,8 @@ module ChatMessage title: issue_title, title_link: issue_url, text: format(description), - color: "#C95823" }] + color: "#C95823" + }] end def project_link diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb index 8468934425f..200be99f36b 100644 --- a/app/models/project_services/chat_notification_service.rb +++ b/app/models/project_services/chat_notification_service.rb @@ -6,7 +6,7 @@ class ChatNotificationService < Service default_value_for :category, 'chat' prop_accessor :webhook, :username, :channel - boolean_accessor :notify_only_broken_builds, :notify_only_broken_pipelines + boolean_accessor :notify_only_broken_pipelines validates :webhook, presence: true, url: true, if: :activated? @@ -16,7 +16,6 @@ class ChatNotificationService < Service if properties.nil? self.properties = {} - self.notify_only_broken_builds = true self.notify_only_broken_pipelines = true end end @@ -27,7 +26,7 @@ class ChatNotificationService < Service def self.supported_events %w[push issue confidential_issue merge_request note tag_push - build pipeline wiki_page] + pipeline wiki_page] end def execute(data) @@ -89,8 +88,6 @@ class ChatNotificationService < Service ChatMessage::MergeMessage.new(data) unless is_update?(data) when "note" ChatMessage::NoteMessage.new(data) - when "build" - ChatMessage::BuildMessage.new(data) if should_build_be_notified?(data) when "pipeline" ChatMessage::PipelineMessage.new(data) if should_pipeline_be_notified?(data) when "wiki_page" @@ -125,17 +122,6 @@ class ChatNotificationService < Service data[:object_attributes][:action] == 'update' end - def should_build_be_notified?(data) - case data[:commit][:status] - when 'success' - !notify_only_broken_builds? - when 'failed' - true - else - false - end - end - def should_pipeline_be_notified?(data) case data[:object_attributes][:status] when 'success' diff --git a/app/models/project_services/drone_ci_service.rb b/app/models/project_services/drone_ci_service.rb index 1ad9efac196..2717c240f05 100644 --- a/app/models/project_services/drone_ci_service.rb +++ b/app/models/project_services/drone_ci_service.rb @@ -39,7 +39,7 @@ class DroneCiService < CiService def commit_status_path(sha, ref) url = [drone_url, "gitlab/#{project.full_path}/commits/#{sha}", - "?branch=#{URI::encode(ref.to_s)}&access_token=#{token}"] + "?branch=#{URI.encode(ref.to_s)}&access_token=#{token}"] URI.join(*url).to_s end @@ -74,7 +74,7 @@ class DroneCiService < CiService def build_page(sha, ref) url = [drone_url, "gitlab/#{project.full_path}/redirect/commits/#{sha}", - "?branch=#{URI::encode(ref.to_s)}"] + "?branch=#{URI.encode(ref.to_s)}"] URI.join(*url).to_s end @@ -114,7 +114,7 @@ class DroneCiService < CiService end def merge_request_valid?(data) - ['opened', 'reopened'].include?(data[:object_attributes][:state]) && + %w(opened reopened).include?(data[:object_attributes][:state]) && data[:object_attributes][:merge_status] == 'unchecked' end end diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb index 72da219df28..8b181221bb0 100644 --- a/app/models/project_services/hipchat_service.rb +++ b/app/models/project_services/hipchat_service.rb @@ -6,16 +6,16 @@ class HipchatService < Service 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_builds, :notify + 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_builds = true + self.notify_only_broken_pipelines = true end end @@ -36,17 +36,17 @@ class HipchatService < Service { type: 'text', name: 'token', placeholder: 'Room token' }, { type: 'text', name: 'room', placeholder: 'Room name or ID' }, { type: 'checkbox', name: 'notify' }, - { type: 'select', name: 'color', choices: ['yellow', 'red', 'green', 'purple', 'gray', 'random'] }, + { 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_builds' }, + { type: 'checkbox', name: 'notify_only_broken_pipelines' }, ] end def self.supported_events - %w(push issue confidential_issue merge_request note tag_push build) + %w(push issue confidential_issue merge_request note tag_push pipeline) end def execute(data) @@ -90,8 +90,8 @@ class HipchatService < Service create_merge_request_message(data) unless is_update?(data) when "note" create_note_message(data) - when "build" - create_build_message(data) if should_build_be_notified?(data) + when "pipeline" + create_pipeline_message(data) if should_pipeline_be_notified?(data) end end @@ -240,28 +240,29 @@ class HipchatService < Service message end - def create_build_message(data) - ref_type = data[:tag] ? 'tag' : 'branch' - ref = data[:ref] - sha = data[:sha] - user_name = data[:commit][:author_name] - status = data[:commit][:status] - duration = data[:commit][:duration] + 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>" - commit_link = "<a href=\"#{project_url}/commit/#{CGI.escape(sha)}/builds\">#{Commit.truncate_sha(sha)}</a>" + pipeline_url = "<a href=\"#{project_url}/pipelines/#{pipeline_id}\">##{pipeline_id}</a>" - "#{project_link}: Commit #{commit_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status(status)} in #{duration} second(s)" + "#{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) - build_status_color(data) || color || 'yellow' + pipeline_status_color(data) || color || 'yellow' end - def build_status_color(data) - return unless data && data[:object_kind] == 'build' + def pipeline_status_color(data) + return unless data && data[:object_kind] == 'pipeline' - case data[:commit][:status] + case data[:object_attributes][:status] when 'success' 'green' else @@ -294,10 +295,10 @@ class HipchatService < Service end end - def should_build_be_notified?(data) - case data[:commit][:status] + def should_pipeline_be_notified?(data) + case data[:object_attributes][:status] when 'success' - !notify_only_broken_builds? + !notify_only_broken_pipelines? when 'failed' true else diff --git a/app/models/project_services/irker_service.rb b/app/models/project_services/irker_service.rb index 5d6862d9faa..c62bb4fa120 100644 --- a/app/models/project_services/irker_service.rb +++ b/app/models/project_services/irker_service.rb @@ -33,7 +33,8 @@ class IrkerService < Service end def settings - { server_host: server_host.present? ? server_host : 'localhost', + { + server_host: server_host.present? ? server_host : 'localhost', server_port: server_port.present? ? server_port : 6659 } end diff --git a/app/models/project_services/issue_tracker_service.rb b/app/models/project_services/issue_tracker_service.rb index 9e65fdbf9d6..50435b67eda 100644 --- a/app/models/project_services/issue_tracker_service.rb +++ b/app/models/project_services/issue_tracker_service.rb @@ -1,4 +1,6 @@ class IssueTrackerService < Service + validate :one_issue_tracker, if: :activated?, on: :manual_change + default_value_for :category, 'issue_tracker' # Pattern used to extract links from comments @@ -92,4 +94,13 @@ class IssueTrackerService < Service def issues_tracker Gitlab.config.issues_tracker[to_param] end + + def one_issue_tracker + return if template? + return if project.blank? + + if project.services.external_issue_trackers.where.not(id: id).any? + errors.add(:base, 'Another issue tracker is already in use. Only one issue tracker service can be active at a time') + end + end end diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb index f2f019c43bb..02fbd5497fa 100644 --- a/app/models/project_services/kubernetes_service.rb +++ b/app/models/project_services/kubernetes_service.rb @@ -3,7 +3,7 @@ class KubernetesService < DeploymentService include Gitlab::Kubernetes include ReactiveCaching - self.reactive_cache_key = ->(service) { [ service.class.model_name.singular, service.project_id ] } + self.reactive_cache_key = ->(service) { [service.class.model_name.singular, service.project_id] } # Namespace defaults to the project path, but can be overridden in case that # is an invalid or inappropriate name @@ -36,7 +36,7 @@ class KubernetesService < DeploymentService def initialize_properties if properties.nil? self.properties = {} - self.namespace = project.path if project.present? + self.namespace = "#{project.path}-#{project.id}" if project.present? end end @@ -62,23 +62,19 @@ class KubernetesService < DeploymentService { type: 'text', name: 'namespace', title: 'Kubernetes namespace', - placeholder: 'Kubernetes namespace', - }, + placeholder: 'Kubernetes namespace' }, { type: 'text', name: 'api_url', title: 'API URL', - placeholder: 'Kubernetes API URL, like https://kube.example.com/', - }, + placeholder: 'Kubernetes API URL, like https://kube.example.com/' }, { type: 'text', name: 'token', title: 'Service token', - placeholder: 'Service token', - }, + placeholder: 'Service token' }, { type: 'textarea', name: 'ca_pem', title: 'Custom CA bundle', - placeholder: 'Certificate Authority bundle (PEM format)', - }, + placeholder: 'Certificate Authority bundle (PEM format)' }, ] end @@ -98,7 +94,12 @@ class KubernetesService < DeploymentService { key: 'KUBE_TOKEN', value: token, public: false }, { key: 'KUBE_NAMESPACE', value: namespace, public: true } ] - variables << { key: 'KUBE_CA_PEM', value: ca_pem, public: true } if ca_pem.present? + + if ca_pem.present? + variables << { key: 'KUBE_CA_PEM', value: ca_pem, public: true } + variables << { key: 'KUBE_CA_PEM_FILE', value: ca_pem, public: true, file: true } + end + variables end @@ -167,7 +168,7 @@ class KubernetesService < DeploymentService url = URI.parse(api_url) prefix = url.path.sub(%r{/+\z}, '') - url.path = [ prefix, *parts ].join("/") + url.path = [prefix, *parts].join("/") url.to_s end diff --git a/app/models/project_services/mattermost_service.rb b/app/models/project_services/mattermost_service.rb index 4ebc5318da1..1156d050622 100644 --- a/app/models/project_services/mattermost_service.rb +++ b/app/models/project_services/mattermost_service.rb @@ -15,10 +15,10 @@ class MattermostService < ChatNotificationService 'This service sends notifications about projects events to Mattermost channels.<br /> To set up this service: <ol> - <li><a href="https://docs.mattermost.com/developer/webhooks-incoming.html#enabling-incoming-webhooks">Enable incoming webhooks</a> in your Mattermost installation. </li> - <li><a href="https://docs.mattermost.com/developer/webhooks-incoming.html#creating-integrations-using-incoming-webhooks">Add an incoming webhook</a> in your Mattermost team. The default channel can be overridden for each event. </li> - <li>Paste the webhook <strong>URL</strong> into the field bellow. </li> - <li>Select events below to enable notifications. The channel and username are optional. </li> + <li><a href="https://docs.mattermost.com/developer/webhooks-incoming.html#enabling-incoming-webhooks">Enable incoming webhooks</a> in your Mattermost installation.</li> + <li><a href="https://docs.mattermost.com/developer/webhooks-incoming.html#creating-integrations-using-incoming-webhooks">Add an incoming webhook</a> in your Mattermost team. The default channel can be overridden for each event.</li> + <li>Paste the webhook <strong>URL</strong> into the field below.</li> + <li>Select events below to enable notifications. The <strong>Channel handle</strong> and <strong>Username</strong> fields are optional.</li> </ol>' end @@ -28,14 +28,13 @@ class MattermostService < ChatNotificationService def default_fields [ - { type: 'text', name: 'webhook', placeholder: 'http://mattermost_host/hooks/...' }, - { type: 'text', name: 'username', placeholder: 'username' }, - { type: 'checkbox', name: 'notify_only_broken_builds' }, + { type: 'text', name: 'webhook', placeholder: 'e.g. http://mattermost_host/hooks/…' }, + { type: 'text', name: 'username', placeholder: 'e.g. GitLab' }, { type: 'checkbox', name: 'notify_only_broken_pipelines' }, ] end def default_channel_placeholder - "town-square" + "Channel handle (e.g. town-square)" end end diff --git a/app/models/project_services/mock_ci_service.rb b/app/models/project_services/mock_ci_service.rb new file mode 100644 index 00000000000..a8d581a1f67 --- /dev/null +++ b/app/models/project_services/mock_ci_service.rb @@ -0,0 +1,82 @@ +# For an example companion mocking service, see https://gitlab.com/gitlab-org/gitlab-mock-ci-service +class MockCiService < CiService + ALLOWED_STATES = %w[failed canceled running pending success success_with_warnings skipped not_found].freeze + + prop_accessor :mock_service_url + validates :mock_service_url, presence: true, url: true, if: :activated? + + def title + 'MockCI' + end + + def description + 'Mock an external CI' + end + + def self.to_param + 'mock_ci' + end + + def fields + [ + { type: 'text', + name: 'mock_service_url', + placeholder: 'http://localhost:4004' }, + ] + end + + # Return complete url to build page + # + # Ex. + # http://jenkins.example.com:8888/job/test1/scm/bySHA1/12d65c + # + def build_page(sha, ref) + url = [mock_service_url, + "#{project.namespace.path}/#{project.path}/status/#{sha}"] + + URI.join(*url).to_s + end + + # Return string with build status or :error symbol + # + # Allowed states: 'success', 'failed', 'running', 'pending', 'skipped' + # + # + # Ex. + # @service.commit_status('13be4ac', 'master') + # # => 'success' + # + # @service.commit_status('2abe4ac', 'dev') + # # => 'running' + # + # + def commit_status(sha, ref) + response = HTTParty.get(commit_status_path(sha), verify: false) + read_commit_status(response) + rescue Errno::ECONNREFUSED + :error + end + + def commit_status_path(sha) + url = [mock_service_url, + "#{project.namespace.path}/#{project.path}/status/#{sha}.json"] + + URI.join(*url).to_s + end + + def read_commit_status(response) + return :error unless response.code == 200 || response.code == 404 + + status = if response.code == 404 + 'pending' + else + response['status'] + end + + if status.present? && ALLOWED_STATES.include?(status) + status + else + :error + end + end +end diff --git a/app/models/project_services/monitoring_service.rb b/app/models/project_services/monitoring_service.rb new file mode 100644 index 00000000000..ea585721e8f --- /dev/null +++ b/app/models/project_services/monitoring_service.rb @@ -0,0 +1,16 @@ +# Base class for monitoring services +# +# These services integrate with a deployment solution like Prometheus +# to provide additional features for environments. +class MonitoringService < Service + default_value_for :category, 'monitoring' + + def self.supported_events + %w() + end + + # Environments have a number of metrics + def metrics(environment) + raise NotImplementedError + end +end diff --git a/app/models/project_services/pivotaltracker_service.rb b/app/models/project_services/pivotaltracker_service.rb index 9cc642591f4..d86f4f6f448 100644 --- a/app/models/project_services/pivotaltracker_service.rb +++ b/app/models/project_services/pivotaltracker_service.rb @@ -1,7 +1,7 @@ class PivotaltrackerService < Service include HTTParty - API_ENDPOINT = 'https://www.pivotaltracker.com/services/v5/source_commits' + API_ENDPOINT = 'https://www.pivotaltracker.com/services/v5/source_commits'.freeze prop_accessor :token, :restrict_to_branch validates :token, presence: true, if: :activated? diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb new file mode 100644 index 00000000000..375966b9efc --- /dev/null +++ b/app/models/project_services/prometheus_service.rb @@ -0,0 +1,93 @@ +class PrometheusService < MonitoringService + include ReactiveCaching + + self.reactive_cache_key = ->(service) { [service.class.model_name.singular, service.project_id] } + self.reactive_cache_lease_timeout = 30.seconds + self.reactive_cache_refresh_interval = 30.seconds + self.reactive_cache_lifetime = 1.minute + + # Access to prometheus is directly through the API + prop_accessor :api_url + + with_options presence: true, if: :activated? do + validates :api_url, url: true + end + + after_save :clear_reactive_cache! + + def initialize_properties + if properties.nil? + self.properties = {} + end + end + + def title + 'Prometheus' + end + + def description + 'Prometheus monitoring' + end + + def help + 'Retrieves `container_cpu_usage_seconds_total` and `container_memory_usage_bytes` from the configured Prometheus server. An `environment` label is required on each metric to identify the Environment.' + end + + def self.to_param + 'prometheus' + end + + def fields + [ + { + type: 'text', + name: 'api_url', + title: 'API URL', + placeholder: 'Prometheus API Base URL, like http://prometheus.example.com/' + } + ] + end + + # Check we can connect to the Prometheus API + def test(*args) + client.ping + + { success: true, result: 'Checked API endpoint' } + rescue Gitlab::PrometheusError => err + { success: false, result: err } + end + + def metrics(environment) + with_reactive_cache(environment.slug) do |data| + data + end + end + + # Cache metrics for specific environment + def calculate_reactive_cache(environment_slug) + return unless active? && project && !project.pending_delete? + + memory_query = %{sum(container_memory_usage_bytes{container_name="app",environment="#{environment_slug}"})/1024/1024} + cpu_query = %{sum(rate(container_cpu_usage_seconds_total{container_name="app",environment="#{environment_slug}"}[2m]))} + + { + success: true, + metrics: { + # Memory used in MB + memory_values: client.query_range(memory_query, start: 8.hours.ago), + memory_current: client.query(memory_query), + # CPU Usage rate in cores. + cpu_values: client.query_range(cpu_query, start: 8.hours.ago), + cpu_current: client.query(cpu_query) + }, + last_update: Time.now.utc + } + + rescue Gitlab::PrometheusError => err + { success: false, result: err.message } + end + + def client + @prometheus ||= Gitlab::Prometheus.new(api_url: api_url) + end +end diff --git a/app/models/project_services/pushover_service.rb b/app/models/project_services/pushover_service.rb index a963d27a376..3e618a8dbf1 100644 --- a/app/models/project_services/pushover_service.rb +++ b/app/models/project_services/pushover_service.rb @@ -29,25 +29,24 @@ class PushoverService < Service ['Normal Priority', 0], ['High Priority', 1] ], - default_choice: 0 - }, + default_choice: 0 }, { type: 'select', name: 'sound', choices: [ ['Device default sound', nil], ['Pushover (default)', 'pushover'], - ['Bike', 'bike'], - ['Bugle', 'bugle'], + %w(Bike bike), + %w(Bugle bugle), ['Cash Register', 'cashregister'], - ['Classical', 'classical'], - ['Cosmic', 'cosmic'], - ['Falling', 'falling'], - ['Gamelan', 'gamelan'], - ['Incoming', 'incoming'], - ['Intermission', 'intermission'], - ['Magic', 'magic'], - ['Mechanical', 'mechanical'], + %w(Classical classical), + %w(Cosmic cosmic), + %w(Falling falling), + %w(Gamelan gamelan), + %w(Incoming incoming), + %w(Intermission intermission), + %w(Magic magic), + %w(Mechanical mechanical), ['Piano Bar', 'pianobar'], - ['Siren', 'siren'], + %w(Siren siren), ['Space Alarm', 'spacealarm'], ['Tug Boat', 'tugboat'], ['Alien Alarm (long)', 'alien'], @@ -56,8 +55,7 @@ class PushoverService < Service ['Pushover Echo (long)', 'echo'], ['Up Down (long)', 'updown'], ['None (silent)', 'none'] - ] - }, + ] }, ] end @@ -72,13 +70,14 @@ class PushoverService < Service before = data[:before] after = data[:after] - if Gitlab::Git.blank_ref?(before) - message = "#{data[:user_name]} pushed new branch \"#{ref}\"." - elsif Gitlab::Git.blank_ref?(after) - message = "#{data[:user_name]} deleted branch \"#{ref}\"." - else - message = "#{data[:user_name]} push to branch \"#{ref}\"." - end + message = + if Gitlab::Git.blank_ref?(before) + "#{data[:user_name]} pushed new branch \"#{ref}\"." + elsif Gitlab::Git.blank_ref?(after) + "#{data[:user_name]} deleted branch \"#{ref}\"." + else + "#{data[:user_name]} push to branch \"#{ref}\"." + end if data[:total_commits_count] > 0 message << "\nTotal commits count: #{data[:total_commits_count]}" @@ -97,7 +96,7 @@ class PushoverService < Service # Sound parameter MUST NOT be sent to API if not selected if sound - pushover_data.merge!(sound: sound) + pushover_data[:sound] = sound end PushoverService.post('/messages.json', body: pushover_data) diff --git a/app/models/project_services/slack_service.rb b/app/models/project_services/slack_service.rb index f77d2d7c60b..b657db6f9ee 100644 --- a/app/models/project_services/slack_service.rb +++ b/app/models/project_services/slack_service.rb @@ -13,11 +13,11 @@ class SlackService < ChatNotificationService def help 'This service sends notifications about projects events to Slack channels.<br /> - To setup this service: + To set up this service: <ol> - <li><a href="https://slack.com/apps/A0F7XDUAZ-incoming-webhooks">Add an incoming webhook</a> in your Slack team. The default channel can be overridden for each event. </li> - <li>Paste the <strong>Webhook URL</strong> into the field below. </li> - <li>Select events below to enable notifications. The channel and username are optional. </li> + <li><a href="https://slack.com/apps/A0F7XDUAZ-incoming-webhooks">Add an incoming webhook</a> in your Slack team. The default channel can be overridden for each event.</li> + <li>Paste the <strong>Webhook URL</strong> into the field below.</li> + <li>Select events below to enable notifications. The <strong>Channel name</strong> and <strong>Username</strong> fields are optional.</li> </ol>' end @@ -27,14 +27,13 @@ class SlackService < ChatNotificationService def default_fields [ - { type: 'text', name: 'webhook', placeholder: 'https://hooks.slack.com/services/...' }, - { type: 'text', name: 'username', placeholder: 'username' }, - { type: 'checkbox', name: 'notify_only_broken_builds' }, + { type: 'text', name: 'webhook', placeholder: 'e.g. https://hooks.slack.com/services/…' }, + { type: 'text', name: 'username', placeholder: 'e.g. GitLab' }, { type: 'checkbox', name: 'notify_only_broken_pipelines' }, ] end def default_channel_placeholder - "#general" + "Channel name (e.g. general)" end end diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb index 06abd406523..aeaf63abab9 100644 --- a/app/models/project_statistics.rb +++ b/app/models/project_statistics.rb @@ -4,7 +4,7 @@ class ProjectStatistics < ActiveRecord::Base before_save :update_storage_size - STORAGE_COLUMNS = [:repository_size, :lfs_objects_size, :build_artifacts_size] + STORAGE_COLUMNS = [:repository_size, :lfs_objects_size, :build_artifacts_size].freeze STATISTICS_COLUMNS = [:commit_count] + STORAGE_COLUMNS def total_repository_size diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index d0b991db112..70eef359cdd 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -5,9 +5,9 @@ class ProjectWiki 'Markdown' => :markdown, 'RDoc' => :rdoc, 'AsciiDoc' => :asciidoc - } unless defined?(MARKUPS) + }.freeze unless defined?(MARKUPS) - class CouldNotCreateWikiError < StandardError; end + CouldNotCreateWikiError = Class.new(StandardError) # Returns a string describing what went wrong after # an operation fails. @@ -19,6 +19,9 @@ class ProjectWiki @user = user end + delegate :empty?, to: :pages + delegate :repository_storage_path, to: :project + def path @project.path + '.wiki' end @@ -39,8 +42,11 @@ class ProjectWiki url_to_repo end - def http_url_to_repo - [Gitlab.config.gitlab.url, "/", path_with_namespace, ".git"].join('') + def http_url_to_repo(user = nil) + url = "#{Gitlab.config.gitlab.url}/#{path_with_namespace}.git" + credentials = Gitlab::UrlSanitizer.http_credentials_for_user(user) + + Gitlab::UrlSanitizer.new(url, credentials: credentials).full_url end def wiki_base_path @@ -60,10 +66,6 @@ class ProjectWiki !!repository.exists? end - def empty? - pages.empty? - end - # Returns an Array of Gitlab WikiPage instances or an # empty Array if this Wiki has no pages. def pages @@ -160,10 +162,6 @@ class ProjectWiki } end - def repository_storage_path - project.repository_storage_path - end - private def init_repo(path_with_namespace) diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 6240912a6e1..39e979ef15b 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -8,8 +8,8 @@ class ProtectedBranch < ActiveRecord::Base has_many :merge_access_levels, dependent: :destroy has_many :push_access_levels, dependent: :destroy - validates_length_of :merge_access_levels, is: 1, message: "are restricted to a single instance per protected branch." - validates_length_of :push_access_levels, is: 1, message: "are restricted to a single instance per protected branch." + validates :merge_access_levels, length: { is: 1, message: "are restricted to a single instance per protected branch." } + validates :push_access_levels, length: { is: 1, message: "are restricted to a single instance per protected branch." } accepts_nested_attributes_for :push_access_levels accepts_nested_attributes_for :merge_access_levels diff --git a/app/models/repository.rb b/app/models/repository.rb index 56c582cd9be..6ab04440ca8 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -6,6 +6,7 @@ class Repository attr_accessor :path_with_namespace, :project CommitError = Class.new(StandardError) + CreateTreeError = Class.new(StandardError) # Methods that cache data from the Git repository. # @@ -18,7 +19,7 @@ class Repository CACHED_METHODS = %i(size commit_count readme version contribution_guide changelog license_blob license_key gitignore koding_yml gitlab_ci_yml branch_names tag_names branch_count - tag_count avatar exists? empty? root_ref) + tag_count avatar exists? empty? root_ref).freeze # Certain method caches should be refreshed when certain types of files are # changed. This Hash maps file types (as returned by Gitlab::FileDetector) to @@ -33,7 +34,7 @@ class Repository koding: :koding_yml, gitlab_ci: :gitlab_ci_yml, avatar: :avatar - } + }.freeze # Wraps around the given method and caches its output in Redis and an instance # variable. @@ -49,10 +50,6 @@ class Repository end end - def self.storages - Gitlab.config.repositories.storages - end - def initialize(path_with_namespace, project) @path_with_namespace = path_with_namespace @project = project @@ -109,9 +106,7 @@ class Repository offset: offset, after: after, before: before, - # --follow doesn't play well with --skip. See: - # https://gitlab.com/gitlab-org/gitlab-ce/issues/3574#note_3040520 - follow: false, + follow: path.present?, skip_merges: skip_merges } @@ -317,11 +312,13 @@ class Repository if !branch_name || branch_name == root_ref branches.each do |branch| cache.expire(:"diverging_commit_counts_#{branch.name}") + cache.expire(:"commit_count_#{branch.name}") end # In case a commit is pushed to a non-root branch we only have to flush the # cache for said branch. else cache.expire(:"diverging_commit_counts_#{branch_name}") + cache.expire(:"commit_count_#{branch_name}") end end @@ -487,9 +484,7 @@ class Repository end cache_method :exists? - def empty? - raw_repository.empty? - end + delegate :empty?, to: :raw_repository cache_method :empty? # The size of this repository in megabytes. @@ -503,14 +498,22 @@ class Repository end cache_method :commit_count, fallback: 0 + def commit_count_for_ref(ref) + return 0 unless exists? + + begin + cache.fetch(:"commit_count_#{ref}") { raw_repository.commit_count(ref) } + rescue Rugged::ReferenceError + 0 + end + end + def branch_names branches.map(&:name) end cache_method :branch_names, fallback: [] - def tag_names - raw_repository.tag_names - end + delegate :tag_names, to: :raw_repository cache_method :tag_names, fallback: [] def branch_count @@ -750,136 +753,63 @@ class Repository @tags ||= raw_repository.tags end - # rubocop:disable Metrics/ParameterLists - def commit_dir( - user, path, - message:, branch_name:, - author_email: nil, author_name: nil, - start_branch_name: nil, start_project: project) - check_tree_entry_for_dir(branch_name, path) - - if start_branch_name - start_project.repository. - check_tree_entry_for_dir(start_branch_name, path) - end + def create_dir(user, path, **options) + options[:user] = user + options[:actions] = [{ action: :create_dir, file_path: path }] - commit_file( - user, - "#{path}/.gitkeep", - '', - message: message, - branch_name: branch_name, - update: false, - author_email: author_email, - author_name: author_name, - start_branch_name: start_branch_name, - start_project: start_project) + multi_action(**options) end - # rubocop:enable Metrics/ParameterLists - # rubocop:disable Metrics/ParameterLists - def commit_file( - user, path, content, - message:, branch_name:, update: true, - author_email: nil, author_name: nil, - start_branch_name: nil, start_project: project) - unless update - error_message = "Filename already exists; update not allowed" + def create_file(user, path, content, **options) + options[:user] = user + options[:actions] = [{ action: :create, file_path: path, content: content }] - if tree_entry_at(branch_name, path) - raise Gitlab::Git::Repository::InvalidBlobName.new(error_message) - end + multi_action(**options) + end - if start_branch_name && - start_project.repository.tree_entry_at(start_branch_name, path) - raise Gitlab::Git::Repository::InvalidBlobName.new(error_message) - end - end + def update_file(user, path, content, **options) + previous_path = options.delete(:previous_path) + action = previous_path && previous_path != path ? :move : :update - multi_action( - user: user, - message: message, - branch_name: branch_name, - author_email: author_email, - author_name: author_name, - start_branch_name: start_branch_name, - start_project: start_project, - actions: [{ action: :create, - file_path: path, - content: content }]) - end - # rubocop:enable Metrics/ParameterLists + options[:user] = user + options[:actions] = [{ action: action, file_path: path, previous_path: previous_path, content: content }] - # rubocop:disable Metrics/ParameterLists - def update_file( - user, path, content, - message:, branch_name:, previous_path:, - author_email: nil, author_name: nil, - start_branch_name: nil, start_project: project) - action = if previous_path && previous_path != path - :move - else - :update - end - - multi_action( - user: user, - message: message, - branch_name: branch_name, - author_email: author_email, - author_name: author_name, - start_branch_name: start_branch_name, - start_project: start_project, - actions: [{ action: action, - file_path: path, - content: content, - previous_path: previous_path }]) + multi_action(**options) end - # rubocop:enable Metrics/ParameterLists - # rubocop:disable Metrics/ParameterLists - def remove_file( - user, path, - message:, branch_name:, - author_email: nil, author_name: nil, - start_branch_name: nil, start_project: project) - multi_action( - user: user, - message: message, - branch_name: branch_name, - author_email: author_email, - author_name: author_name, - start_branch_name: start_branch_name, - start_project: start_project, - actions: [{ action: :delete, - file_path: path }]) + def delete_file(user, path, **options) + options[:user] = user + options[:actions] = [{ action: :delete, file_path: path }] + + multi_action(**options) end - # rubocop:enable Metrics/ParameterLists # rubocop:disable Metrics/ParameterLists def multi_action( user:, branch_name:, message:, actions:, author_email: nil, author_name: nil, start_branch_name: nil, start_project: project) + GitOperationService.new(user, self).with_branch( branch_name, start_branch_name: start_branch_name, start_project: start_project) do |start_commit| - index = rugged.index - parents = if start_commit - index.read_tree(start_commit.raw_commit.tree) - [start_commit.sha] - else - [] - end + index = Gitlab::Git::Index.new(raw_repository) - actions.each do |act| - git_action(index, act) + if start_commit + index.read_tree(start_commit.raw_commit.tree) + parents = [start_commit.sha] + else + parents = [] + end + + actions.each do |options| + index.public_send(options.delete(:action), options) end options = { - tree: index.write_tree(rugged), + tree: index.write_tree, message: message, parents: parents } @@ -892,7 +822,7 @@ class Repository def get_committer_and_author(user, email: nil, name: nil) committer = user_to_committer(user) - author = Gitlab::Git::committer_hash(email: email, name: name) || committer + author = Gitlab::Git.committer_hash(email: email, name: name) || committer { author: author, @@ -941,17 +871,18 @@ class Repository end def revert( - user, commit, branch_name, revert_tree_id = nil, + user, commit, branch_name, start_branch_name: nil, start_project: project) - revert_tree_id ||= check_revert_content(commit, branch_name) - - return false unless revert_tree_id - GitOperationService.new(user, self).with_branch( branch_name, start_branch_name: start_branch_name, start_project: start_project) do |start_commit| + revert_tree_id = check_revert_content(commit, start_commit.sha) + unless revert_tree_id + raise Repository::CreateTreeError.new('Failed to revert commit') + end + committer = user_to_committer(user) Rugged::Commit.create(rugged, @@ -964,17 +895,18 @@ class Repository end def cherry_pick( - user, commit, branch_name, cherry_pick_tree_id = nil, + user, commit, branch_name, start_branch_name: nil, start_project: project) - cherry_pick_tree_id ||= check_cherry_pick_content(commit, branch_name) - - return false unless cherry_pick_tree_id - GitOperationService.new(user, self).with_branch( branch_name, start_branch_name: start_branch_name, start_project: start_project) do |start_commit| + cherry_pick_tree_id = check_cherry_pick_content(commit, start_commit.sha) + unless cherry_pick_tree_id + raise Repository::CreateTreeError.new('Failed to cherry-pick commit') + end + committer = user_to_committer(user) Rugged::Commit.create(rugged, @@ -998,9 +930,8 @@ class Repository end end - def check_revert_content(target_commit, branch_name) - source_sha = commit(branch_name).sha - args = [target_commit.sha, source_sha] + def check_revert_content(target_commit, source_sha) + args = [target_commit.sha, source_sha] args << { mainline: 1 } if target_commit.merge_commit? revert_index = rugged.revert_commit(*args) @@ -1012,9 +943,8 @@ class Repository tree_id end - def check_cherry_pick_content(target_commit, branch_name) - source_sha = commit(branch_name).sha - args = [target_commit.sha, source_sha] + def check_cherry_pick_content(target_commit, source_sha) + args = [target_commit.sha, source_sha] args << 1 if target_commit.merge_commit? cherry_pick_index = rugged.cherrypick_commit(*args) @@ -1074,6 +1004,8 @@ class Repository end def with_repo_branch_commit(start_repository, start_branch_name) + return yield(nil) if start_repository.empty_repo? + branch_name_or_sha = if start_repository == self start_branch_name @@ -1170,30 +1102,6 @@ class Repository blob_data_at(sha, '.gitlab-ci.yml') end - protected - - def tree_entry_at(branch_name, path) - branch_exists?(branch_name) && - # tree_entry is private - raw_repository.send(:tree_entry, commit(branch_name), path) - end - - def check_tree_entry_for_dir(branch_name, path) - return unless branch_exists?(branch_name) - - entry = tree_entry_at(branch_name, path) - - return unless entry - - if entry[:type] == :blob - raise Gitlab::Git::Repository::InvalidBlobName.new( - "Directory already exists as a file") - else - raise Gitlab::Git::Repository::InvalidBlobName.new( - "Directory already exists") - end - end - private def blob_data_at(sha, path) @@ -1204,58 +1112,6 @@ class Repository blob.data end - def git_action(index, action) - path = normalize_path(action[:file_path]) - - if action[:action] == :move - previous_path = normalize_path(action[:previous_path]) - end - - case action[:action] - when :create, :update, :move - mode = - case action[:action] - when :update - index.get(path)[:mode] - when :move - index.get(previous_path)[:mode] - end - mode ||= 0o100644 - - index.remove(previous_path) if action[:action] == :move - - content = if action[:encoding] == 'base64' - Base64.decode64(action[:content]) - else - action[:content] - end - - detect = CharlockHolmes::EncodingDetector.new.detect(content) if content - - unless detect && detect[:type] == :binary - # When writing to the repo directly as we are doing here, - # the `core.autocrlf` config isn't taken into account. - content.gsub!("\r\n", "\n") if self.autocrlf - end - - oid = rugged.write(content, :blob) - - index.add(path: path, oid: oid, mode: mode) - when :delete - index.remove(path) - end - end - - def normalize_path(path) - pathname = Gitlab::Git::PathHelper.normalize_path(path) - - if pathname.each_filename.include?('..') - raise Gitlab::Git::Repository::InvalidBlobName.new('Invalid path') - end - - pathname.to_s - end - def refs_directory_exists? return false unless path_with_namespace diff --git a/app/models/route.rb b/app/models/route.rb index 73574a6206b..41e6eb7cb73 100644 --- a/app/models/route.rb +++ b/app/models/route.rb @@ -21,7 +21,7 @@ class Route < ActiveRecord::Base attributes[:path] = route.path.sub(path_was, path) end - if name_changed? && route.name.present? + if name_changed? && name_was.present? && route.name.present? attributes[:name] = route.name.sub(name_was, name) end diff --git a/app/models/service.rb b/app/models/service.rb index facaaf9b331..e73f7e5d1a3 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -210,12 +210,11 @@ class Service < ActiveRecord::Base end def self.available_services_names - %w[ + service_names = %w[ asana assembla bamboo buildkite - builds_email bugzilla campfire custom_issue_tracker @@ -232,12 +231,16 @@ class Service < ActiveRecord::Base mattermost pipelines_email pivotaltracker + prometheus pushover redmine slack_slash_commands slack teamcity ] + service_names << 'mock_ci' if Rails.env.development? + + service_names.sort_by(&:downcase) end def self.build_from_template(project_id, template) diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 2665a7249a3..dbd564e5e7d 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -120,7 +120,7 @@ class Snippet < ActiveRecord::Base end def visibility_level_field - visibility_level + :visibility_level end def no_highlighting? diff --git a/app/models/todo.rb b/app/models/todo.rb index 3dda7948d0b..da3fa7277c2 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -17,7 +17,7 @@ class Todo < ActiveRecord::Base APPROVAL_REQUIRED => :approval_required, UNMERGEABLE => :unmergeable, DIRECTLY_ADDRESSED => :directly_addressed - } + }.freeze belongs_to :author, class_name: "User" belongs_to :note @@ -48,8 +48,14 @@ class Todo < ActiveRecord::Base after_save :keep_around_commit class << self + # Priority sorting isn't displayed in the dropdown, because we don't show + # milestones, but still show something if the user has a URL with that + # selected. def sort(method) - method == "priority" ? order_by_labels_priority : order_by(method) + case method.to_s + when 'priority', 'label_priority' then order_by_labels_priority + else order_by(method) + end end # Order by priority depending on which issue/merge request the Todo belongs to diff --git a/app/models/upload.rb b/app/models/upload.rb new file mode 100644 index 00000000000..13987931b05 --- /dev/null +++ b/app/models/upload.rb @@ -0,0 +1,63 @@ +class Upload < ActiveRecord::Base + # Upper limit for foreground checksum processing + CHECKSUM_THRESHOLD = 100.megabytes + + belongs_to :model, polymorphic: true + + validates :size, presence: true + validates :path, presence: true + validates :model, presence: true + validates :uploader, presence: true + + before_save :calculate_checksum, if: :foreground_checksum? + after_commit :schedule_checksum, unless: :foreground_checksum? + + def self.remove_path(path) + where(path: path).destroy_all + end + + def self.record(uploader) + remove_path(uploader.relative_path) + + create( + size: uploader.file.size, + path: uploader.relative_path, + model: uploader.model, + uploader: uploader.class.to_s + ) + end + + def absolute_path + return path unless relative_path? + + uploader_class.absolute_path(self) + end + + def calculate_checksum + return unless exist? + + self.checksum = Digest::SHA256.file(absolute_path).hexdigest + end + + def exist? + File.exist?(absolute_path) + end + + private + + def foreground_checksum? + size <= CHECKSUM_THRESHOLD + end + + def schedule_checksum + UploadChecksumWorker.perform_async(id) + end + + def relative_path? + !path.start_with?('/') + end + + def uploader_class + Object.const_get(uploader) + end +end diff --git a/app/models/user.rb b/app/models/user.rb index f614eb66e1f..8c7ad5d5174 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -21,6 +21,7 @@ class User < ActiveRecord::Base default_value_for :can_create_team, false default_value_for :hide_no_ssh_key, false default_value_for :hide_no_password, false + default_value_for :project_view, :files attr_encrypted :otp_secret, key: Gitlab::Application.secrets.otp_key_base, @@ -81,7 +82,6 @@ class User < ActiveRecord::Base has_many :authorized_projects, through: :project_authorizations, source: :project has_many :snippets, dependent: :destroy, foreign_key: :author_id - has_many :issues, dependent: :destroy, foreign_key: :author_id has_many :notes, dependent: :destroy, foreign_key: :author_id has_many :merge_requests, dependent: :destroy, foreign_key: :author_id has_many :events, dependent: :destroy, foreign_key: :author_id @@ -95,16 +95,22 @@ class User < ActiveRecord::Base has_many :todos, dependent: :destroy has_many :notification_settings, dependent: :destroy has_many :award_emoji, dependent: :destroy + has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id has_many :assigned_issues, dependent: :nullify, foreign_key: :assignee_id, class_name: "Issue" has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest" + # Issues that a user owns are expected to be moved to the "ghost" user before + # the user is destroyed. If the user owns any issues during deletion, this + # should be treated as an exceptional condition. + has_many :issues, dependent: :restrict_with_exception, foreign_key: :author_id + # # Validations # # Note: devise :validatable above adds validations for :email and :password validates :name, presence: true - validates_confirmation_of :email + validates :email, confirmation: true validates :notification_email, presence: true validates :notification_email, email: true, if: ->(user) { user.notification_email != user.email } validates :public_email, presence: true, uniqueness: true, email: true, allow_blank: true @@ -184,6 +190,7 @@ class User < ActiveRecord::Base end mount_uploader :avatar, AvatarUploader + has_many :uploads, as: :model, dependent: :destroy # Scopes scope :admins, -> { where(admin: true) } @@ -317,8 +324,7 @@ class User < ActiveRecord::Base end def find_by_personal_access_token(token_string) - personal_access_token = PersonalAccessToken.active.find_by_token(token_string) if token_string - personal_access_token&.user + PersonalAccessTokensFinder.new(state: 'active').find_by(token: token_string)&.user end # Returns a user for the given SSH key. @@ -334,9 +340,34 @@ class User < ActiveRecord::Base def reference_pattern %r{ #{Regexp.escape(reference_prefix)} - (?<user>#{Gitlab::Regex::NAMESPACE_REF_REGEX_STR}) + (?<user>#{Gitlab::Regex::FULL_NAMESPACE_REGEX_STR}) }x end + + # Return (create if necessary) the ghost user. The ghost user + # owns records previously belonging to deleted users. + def ghost + unique_internal(where(ghost: true), 'ghost', 'ghost%s@example.com') do |u| + u.bio = 'This is a "Ghost User", created to hold all issues authored by users that have since been deleted. This user cannot be removed.' + u.name = 'Ghost User' + end + end + end + + def self.internal_attributes + [:ghost] + end + + def internal? + self.class.internal_attributes.any? { |a| self[a] } + end + + def self.internal + where(Hash[internal_attributes.zip([true] * internal_attributes.size)]) + end + + def self.non_internal + where(Hash[internal_attributes.zip([[false, nil]] * internal_attributes.size)]) end # @@ -457,7 +488,7 @@ class User < ActiveRecord::Base Group.member_descendants(id) end - def nested_projects + def nested_groups_projects Project.joins(:namespace).where('namespaces.parent_id IS NOT NULL'). member_descendants(id) end @@ -540,14 +571,14 @@ class User < ActiveRecord::Base end def can_create_group? - can?(:create_group, nil) + can?(:create_group) end def can_select_namespace? several_namespaces? || admin end - def can?(action, subject) + def can?(action, subject = :global) Ability.allowed?(self, action, subject) end @@ -580,8 +611,8 @@ class User < ActiveRecord::Base if project.repository.branch_exists?(event.branch_name) merge_requests = MergeRequest.where("created_at >= ?", event.created_at). - where(source_project_id: project.id, - source_branch: event.branch_name) + where(source_project_id: project.id, + source_branch: event.branch_name) merge_requests.empty? end end @@ -846,7 +877,7 @@ class User < ActiveRecord::Base def ci_authorized_runners @ci_authorized_runners ||= begin runner_ids = Ci::RunnerProject. - where("ci_runner_projects.gl_project_id IN (#{ci_projects_union.to_sql})"). + where("ci_runner_projects.project_id IN (#{ci_projects_union.to_sql})"). select(:runner_id) Ci::Runner.specific.where(id: runner_ids) end @@ -932,6 +963,14 @@ class User < ActiveRecord::Base self.admin = (new_level == 'admin') end + protected + + # override, from Devise::Validatable + def password_required? + return false if internal? + super + end + private def ci_projects_union @@ -999,4 +1038,43 @@ class User < ActiveRecord::Base super end end + + def self.unique_internal(scope, username, email_pattern, &b) + scope.first || create_unique_internal(scope, username, email_pattern, &b) + end + + def self.create_unique_internal(scope, username, email_pattern, &creation_block) + # Since we only want a single one of these in an instance, we use an + # exclusive lease to ensure than this block is never run concurrently. + lease_key = "user:unique_internal:#{username}" + lease = Gitlab::ExclusiveLease.new(lease_key, timeout: 1.minute.to_i) + + until uuid = lease.try_obtain + # Keep trying until we obtain the lease. To prevent hammering Redis too + # much we'll wait for a bit between retries. + sleep(1) + end + + # Recheck if the user is already present. One might have been + # added between the time we last checked (first line of this method) + # and the time we acquired the lock. + existing_user = uncached { scope.first } + return existing_user if existing_user.present? + + uniquify = Uniquify.new + + username = uniquify.string(username) { |s| User.find_by_username(s) } + + email = uniquify.string(-> (n) { Kernel.sprintf(email_pattern, n) }) do |s| + User.find_by_email(s) + end + + scope.create( + username: username, + email: email, + &creation_block + ) + ensure + Gitlab::ExclusiveLease.cancel(lease_key, uuid) + end end diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 2caebb496db..c771c22f46a 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -149,7 +149,13 @@ class WikiPage end # Returns boolean True or False if this instance - # has been fully saved to disk or not. + # is the latest commit version of the page. + def latest? + !historical? + end + + # Returns boolean True or False if this instance + # has been fully created on disk or not. def persisted? @persisted == true end @@ -220,6 +226,8 @@ class WikiPage end def save(method, *args) + saved = false + project_wiki = wiki if valid? && project_wiki.send(method, *args) @@ -237,10 +245,10 @@ class WikiPage set_attributes @persisted = true + saved = true else errors.add(:base, project_wiki.error_message) if project_wiki.error_message - @persisted = false end - @persisted + saved end end |