diff options
author | Phil Hughes <me@iamphill.com> | 2016-11-24 11:32:59 +0000 |
---|---|---|
committer | Phil Hughes <me@iamphill.com> | 2016-11-24 11:32:59 +0000 |
commit | 8c4f4afd6dd6d382aab2d6b992b6ffe3e60f91af (patch) | |
tree | 37d3ff76dc31e7fcfa63eb8c2f54c9d84eb9b88a /app/models | |
parent | 03a235783f697572fe201332cb82746401a01daf (diff) | |
parent | 3e44ed3e2bf75bb14a2d8b0466b3d92afd0ea067 (diff) | |
download | gitlab-ce-autocomplete-space-prefix.tar.gz |
Merge branch 'master' into autocomplete-space-prefixautocomplete-space-prefix
Diffstat (limited to 'app/models')
40 files changed, 976 insertions, 682 deletions
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index bb60cc8736c..bf463a3b6bb 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -19,6 +19,7 @@ class ApplicationSetting < ActiveRecord::Base serialize :domain_whitelist, Array serialize :domain_blacklist, Array serialize :repository_storages + serialize :sidekiq_throttling_queues, Array cache_markdown_field :sign_in_text cache_markdown_field :help_page_text @@ -85,6 +86,15 @@ class ApplicationSetting < ActiveRecord::Base presence: { message: 'Domain blacklist cannot be empty if Blacklist is enabled.' }, if: :domain_blacklist_enabled? + validates :sidekiq_throttling_factor, + numericality: { greater_than: 0, less_than: 1 }, + presence: { message: 'Throttling factor cannot be empty if Sidekiq Throttling is enabled.' }, + if: :sidekiq_throttling_enabled? + + validates :sidekiq_throttling_queues, + presence: { message: 'Queues to throttle cannot be empty if Sidekiq Throttling is enabled.' }, + if: :sidekiq_throttling_enabled? + validates :housekeeping_incremental_repack_period, presence: true, numericality: { only_integer: true, greater_than: 0 } @@ -180,6 +190,7 @@ class ApplicationSetting < ActiveRecord::Base container_registry_token_expire_delay: 5, repository_storages: ['default'], user_default_external: false, + sidekiq_throttling_enabled: false, housekeeping_enabled: true, housekeeping_bitmaps_enabled: true, housekeeping_incremental_repack_period: 10, @@ -192,6 +203,10 @@ class ApplicationSetting < ActiveRecord::Base ActiveRecord::Base.connection.column_exists?(:application_settings, :home_page_url) end + def sidekiq_throttling_column_exists? + ActiveRecord::Base.connection.column_exists?(:application_settings, :sidekiq_throttling_enabled) + end + def domain_whitelist_raw self.domain_whitelist.join("\n") unless self.domain_whitelist.nil? end @@ -245,6 +260,12 @@ class ApplicationSetting < ActiveRecord::Base ensure_health_check_access_token! end + def sidekiq_throttling_enabled? + return false unless sidekiq_throttling_column_exists? + + sidekiq_throttling_enabled + end + private def check_repository_storages diff --git a/app/models/chat_name.rb b/app/models/chat_name.rb new file mode 100644 index 00000000000..f321db75eeb --- /dev/null +++ b/app/models/chat_name.rb @@ -0,0 +1,12 @@ +class ChatName < ActiveRecord::Base + belongs_to :service + belongs_to :user + + validates :user, presence: true + validates :service, presence: true + validates :team_id, presence: true + validates :chat_id, presence: true + + validates :user_id, uniqueness: { scope: [:service_id] } + validates :chat_id, uniqueness: { scope: [:service_id, :team_id] } +end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index bf5f92f8462..e7d33bd26db 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -7,6 +7,8 @@ module Ci belongs_to :trigger_request belongs_to :erased_by, class_name: 'User' + has_many :deployments, as: :deployable + serialize :options serialize :yaml_variables @@ -68,7 +70,11 @@ module Ci environment: build.environment, status_event: 'enqueue' ) - MergeRequests::AddTodoWhenBuildFailsService.new(build.project, nil).close(new_build) + + MergeRequests::AddTodoWhenBuildFailsService + .new(build.project, nil) + .close(new_build) + build.pipeline.mark_as_processable_after_stage(build.stage_idx) new_build end @@ -125,6 +131,34 @@ module Ci !self.pipeline.statuses.latest.include?(self) end + def expanded_environment_name + ExpandVariables.expand(environment, variables) if environment + end + + def has_environment? + self.environment.present? + end + + def starts_environment? + has_environment? && self.environment_action == 'start' + end + + def stops_environment? + has_environment? && self.environment_action == 'stop' + end + + def environment_action + self.options.fetch(:environment, {}).fetch(:action, 'start') + end + + def outdated_deployment? + success? && !last_deployment.try(:last?) + end + + def last_deployment + deployments.last + end + def depends_on_builds # Get builds of the same type latest_builds = self.pipeline.builds.latest @@ -271,6 +305,7 @@ module Ci def append_trace(trace_part, offset) recreate_trace_dir + touch if needs_touch? trace_part = hide_secrets(trace_part) @@ -280,6 +315,10 @@ module Ci end end + def needs_touch? + Time.now - updated_at > 15.minutes.to_i + end + def trace_file_path if has_old_trace_file? old_path_to_trace @@ -448,6 +487,10 @@ module Ci ] end + def credentials + Gitlab::Ci::Build::Credentials::Factory.new(self).create! + end + private def update_artifacts_size diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index d159fc6c5c7..c345bf293c9 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -135,15 +135,19 @@ class CommitStatus < ActiveRecord::Base allow_failure? && (failed? || canceled?) end + def duration + calculate_duration + end + def playable? false end - def duration - calculate_duration + def stuck? + false end - def stuck? + def has_trace? false end end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 664bb594aa9..69d8afc45da 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -215,7 +215,7 @@ module Issuable end end - def subscribed_without_subscriptions?(user) + def subscribed_without_subscriptions?(user, project) participants(user).include?(user) end @@ -251,6 +251,17 @@ module Issuable self.class.to_ability_name end + # Convert this Issuable class name to a format usable by notifications. + # + # Examples: + # + # issuable.class # => MergeRequest + # issuable.human_class_name # => "merge request" + + def human_class_name + @human_class_name ||= self.class.name.titleize.downcase + end + # Returns a Hash of attributes to be used for Twitter card metadata def card_attributes { diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index eb2ff0428f6..8ab0401d288 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -1,6 +1,6 @@ # == Mentionable concern # -# Contains functionality related to objects that can mention Users, Issues, MergeRequests, or Commits by +# Contains functionality related to objects that can mention Users, Issues, MergeRequests, Commits or Snippets by # GFM references. # # Used by Issue, Note, MergeRequest, and Commit. diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index 7bcc78247ba..e65fc9eaa09 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -23,7 +23,31 @@ module Milestoneish (due_date - Date.today).to_i end + def elapsed_days + return 0 if !start_date || start_date.future? + + (Date.today - start_date).to_i + end + def issues_visible_to_user(user = nil) issues.visible_to_user(user) end + + def upcoming? + start_date && start_date.future? + end + + def expires_at + if due_date + if due_date.past? + "expired on #{due_date.to_s(:medium)}" + else + "expires on #{due_date.to_s(:medium)}" + end + end + end + + def expired? + due_date && due_date.past? + end end diff --git a/app/models/concerns/select_for_project_authorization.rb b/app/models/concerns/select_for_project_authorization.rb new file mode 100644 index 00000000000..50a1d7fc3e1 --- /dev/null +++ b/app/models/concerns/select_for_project_authorization.rb @@ -0,0 +1,9 @@ +module SelectForProjectAuthorization + extend ActiveSupport::Concern + + module ClassMethods + def select_for_project_authorization + select("members.user_id, projects.id AS project_id, members.access_level") + end + end +end diff --git a/app/models/concerns/subscribable.rb b/app/models/concerns/subscribable.rb index 083257f1005..83daa9b1a64 100644 --- a/app/models/concerns/subscribable.rb +++ b/app/models/concerns/subscribable.rb @@ -12,39 +12,71 @@ module Subscribable has_many :subscriptions, dependent: :destroy, as: :subscribable end - def subscribed?(user) - if subscription = subscriptions.find_by_user_id(user.id) + def subscribed?(user, project = nil) + if subscription = subscriptions.find_by(user: user, project: project) subscription.subscribed else - subscribed_without_subscriptions?(user) + subscribed_without_subscriptions?(user, project) end end # Override this method to define custom logic to consider a subscribable as # subscribed without an explicit subscription record. - def subscribed_without_subscriptions?(user) + def subscribed_without_subscriptions?(user, project) false end - def subscribers - subscriptions.where(subscribed: true).map(&:user) + def subscribers(project) + subscriptions_available(project). + where(subscribed: true). + map(&:user) end - def toggle_subscription(user) - subscriptions. - find_or_initialize_by(user_id: user.id). - update(subscribed: !subscribed?(user)) + def toggle_subscription(user, project = nil) + unsubscribe_from_other_levels(user, project) + + find_or_initialize_subscription(user, project). + update(subscribed: !subscribed?(user, project)) + end + + def subscribe(user, project = nil) + unsubscribe_from_other_levels(user, project) + + find_or_initialize_subscription(user, project) + .update(subscribed: true) + end + + def unsubscribe(user, project = nil) + unsubscribe_from_other_levels(user, project) + + find_or_initialize_subscription(user, project) + .update(subscribed: false) end - def subscribe(user) + private + + def unsubscribe_from_other_levels(user, project) + other_subscriptions = subscriptions.where(user: user) + + other_subscriptions = + if project.blank? + other_subscriptions.where.not(project: nil) + else + other_subscriptions.where(project: nil) + end + + other_subscriptions.update_all(subscribed: false) + end + + def find_or_initialize_subscription(user, project) subscriptions. - find_or_initialize_by(user_id: user.id). - update(subscribed: true) + find_or_initialize_by(user_id: user.id, project_id: project.try(:id)) end - def unsubscribe(user) + def subscriptions_available(project) + t = Subscription.arel_table + subscriptions. - find_or_initialize_by(user_id: user.id). - update(subscribed: false) + where(t[:project_id].eq(nil).or(t[:project_id].eq(project.try(:id)))) end end diff --git a/app/models/cycle_analytics.rb b/app/models/cycle_analytics.rb index 8ed4a56b19b..cb8e088d21d 100644 --- a/app/models/cycle_analytics.rb +++ b/app/models/cycle_analytics.rb @@ -1,103 +1,61 @@ class CycleAnalytics - include Gitlab::Database::Median - include Gitlab::Database::DateTime - - DEPLOYMENT_METRIC_STAGES = %i[production staging] + STAGES = %i[issue plan code test review staging production].freeze def initialize(project, from:) @project = project @from = from + @fetcher = Gitlab::CycleAnalytics::MetricsFetcher.new(project: project, from: from, branch: nil) end def summary @summary ||= Summary.new(@project, from: @from) end + def permissions(user:) + Gitlab::CycleAnalytics::Permissions.get(user: user, project: @project) + end + def issue - calculate_metric(:issue, + @fetcher.calculate_metric(:issue, Issue.arel_table[:created_at], [Issue::Metrics.arel_table[:first_associated_with_milestone_at], Issue::Metrics.arel_table[:first_added_to_board_at]]) end def plan - calculate_metric(:plan, + @fetcher.calculate_metric(:plan, [Issue::Metrics.arel_table[:first_associated_with_milestone_at], Issue::Metrics.arel_table[:first_added_to_board_at]], Issue::Metrics.arel_table[:first_mentioned_in_commit_at]) end def code - calculate_metric(:code, + @fetcher.calculate_metric(:code, Issue::Metrics.arel_table[:first_mentioned_in_commit_at], MergeRequest.arel_table[:created_at]) end def test - calculate_metric(:test, + @fetcher.calculate_metric(:test, MergeRequest::Metrics.arel_table[:latest_build_started_at], MergeRequest::Metrics.arel_table[:latest_build_finished_at]) end def review - calculate_metric(:review, + @fetcher.calculate_metric(:review, MergeRequest.arel_table[:created_at], MergeRequest::Metrics.arel_table[:merged_at]) end def staging - calculate_metric(:staging, + @fetcher.calculate_metric(:staging, MergeRequest::Metrics.arel_table[:merged_at], MergeRequest::Metrics.arel_table[:first_deployed_to_production_at]) end def production - calculate_metric(:production, + @fetcher.calculate_metric(:production, Issue.arel_table[:created_at], MergeRequest::Metrics.arel_table[:first_deployed_to_production_at]) end - - private - - def calculate_metric(name, start_time_attrs, end_time_attrs) - cte_table = Arel::Table.new("cte_table_for_#{name}") - - # Build a `SELECT` query. We find the first of the `end_time_attrs` that isn't `NULL` (call this end_time). - # Next, we find the first of the start_time_attrs that isn't `NULL` (call this start_time). - # We compute the (end_time - start_time) interval, and give it an alias based on the current - # cycle analytics stage. - interval_query = Arel::Nodes::As.new( - cte_table, - subtract_datetimes(base_query_for(name), end_time_attrs, start_time_attrs, name.to_s)) - - median_datetime(cte_table, interval_query, name) - end - - # Join table with a row for every <issue,merge_request> pair (where the merge request - # closes the given issue) with issue and merge request metrics included. The metrics - # are loaded with an inner join, so issues / merge requests without metrics are - # automatically excluded. - def base_query_for(name) - arel_table = MergeRequestsClosingIssues.arel_table - - # Load issues - query = arel_table.join(Issue.arel_table).on(Issue.arel_table[:id].eq(arel_table[:issue_id])). - join(Issue::Metrics.arel_table).on(Issue.arel_table[:id].eq(Issue::Metrics.arel_table[:issue_id])). - where(Issue.arel_table[:project_id].eq(@project.id)). - where(Issue.arel_table[:deleted_at].eq(nil)). - where(Issue.arel_table[:created_at].gteq(@from)) - - # Load merge_requests - query = query.join(MergeRequest.arel_table, Arel::Nodes::OuterJoin). - on(MergeRequest.arel_table[:id].eq(arel_table[:merge_request_id])). - join(MergeRequest::Metrics.arel_table). - on(MergeRequest.arel_table[:id].eq(MergeRequest::Metrics.arel_table[:merge_request_id])) - - if DEPLOYMENT_METRIC_STAGES.include?(name) - # Limit to merge requests that have been deployed to production after `@from` - query.where(MergeRequest::Metrics.arel_table[:first_deployed_to_production_at].gteq(@from)) - end - - query - end end diff --git a/app/models/environment.rb b/app/models/environment.rb index 73f415c0ef0..a7f4156fc2e 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -19,7 +19,7 @@ class Environment < ActiveRecord::Base allow_nil: true, addressable_url: true - delegate :stop_action, to: :last_deployment, allow_nil: true + delegate :stop_action, :manual_actions, to: :last_deployment, allow_nil: true scope :available, -> { with_state(:available) } scope :stopped, -> { with_state(:stopped) } @@ -37,6 +37,10 @@ class Environment < ActiveRecord::Base state :stopped end + def recently_updated_on_branch?(ref) + ref.to_s == last_deployment.try(:ref) + end + def last_deployment deployments.last end @@ -92,6 +96,15 @@ class Environment < ActiveRecord::Base def stop!(current_user) return unless stoppable? + stop stop_action.play(current_user) end + + def actions_for(environment) + return [] unless manual_actions + + manual_actions.select do |action| + action.expanded_environment_name == environment + end + end end diff --git a/app/models/event.rb b/app/models/event.rb index c76d88b1c7b..21eaca917b8 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -62,7 +62,7 @@ class Event < ActiveRecord::Base end def visible_to_user?(user = nil) - if push? + if push? || commit_note? Ability.allowed?(user, :download_code, project) elsif membership_changed? true @@ -283,7 +283,7 @@ class Event < ActiveRecord::Base end def commit_note? - target.for_commit? + note? && target && target.for_commit? end def issue_note? @@ -295,7 +295,7 @@ class Event < ActiveRecord::Base end def project_snippet_note? - target.for_snippet? + note? && target && target.for_snippet? end def note_target diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb index cde4a568577..b01607dcda9 100644 --- a/app/models/global_milestone.rb +++ b/app/models/global_milestone.rb @@ -28,26 +28,16 @@ class GlobalMilestone @title.to_slug.normalize.to_s end - def expired? - if due_date - due_date.past? - else - false - end - end - def projects @projects ||= Project.for_milestones(milestones.select(:id)) end def state - state = milestones.map { |milestone| milestone.state } - - if state.count('closed') == state.size - 'closed' - else - 'active' + milestones.each do |milestone| + return 'active' if milestone.state != 'closed' end + + 'closed' end def active? @@ -81,18 +71,15 @@ class GlobalMilestone @due_date = if @milestones.all? { |x| x.due_date == @milestones.first.due_date } @milestones.first.due_date - else - nil end end - def expires_at - if due_date - if due_date.past? - "expired on #{due_date.to_s(:medium)}" - else - "expires on #{due_date.to_s(:medium)}" + def start_date + return @start_date if defined?(@start_date) + + @start_date = + if @milestones.all? { |x| x.start_date == @milestones.first.start_date } + @milestones.first.start_date end - end end end diff --git a/app/models/group.rb b/app/models/group.rb index d9e90cd256a..4248e1162d8 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -5,6 +5,7 @@ class Group < Namespace include Gitlab::VisibilityLevel include AccessRequestable include Referable + include SelectForProjectAuthorization has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source alias_method :members, :group_members @@ -61,6 +62,16 @@ class Group < Namespace def visible_to_user(user) where(id: user.authorized_groups.select(:id).reorder(nil)) end + + def select_for_project_authorization + if current_scope.joins_values.include?(:shared_projects) + joins('INNER JOIN namespaces project_namespace ON project_namespace.id = projects.namespace_id') + .where('project_namespace.share_with_group_lock = ?', false) + .select("members.user_id, projects.id AS project_id, LEAST(project_group_links.group_access, members.access_level) AS access_level") + else + super + end + end end def to_reference(_from_project = nil) @@ -176,4 +187,8 @@ class Group < Namespace def system_hook_service SystemHooksService.new end + + def refresh_members_authorized_projects + UserProjectAccessChangedService.new(users.pluck(:id)).execute + end end diff --git a/app/models/issue.rb b/app/models/issue.rb index adbca510ef7..dd0cb75f9a8 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -93,7 +93,7 @@ class Issue < ActiveRecord::Base # Check if we are scoped to a specific project's issues if owner_project - if owner_project.authorized_for_user?(user, Gitlab::Access::REPORTER) + if owner_project.team.member?(user, Gitlab::Access::REPORTER) # If the project is authorized for the user, they can see all issues in the project return all else @@ -266,7 +266,7 @@ class Issue < ActiveRecord::Base def as_json(options = {}) super(options).tap do |json| - json[:subscribed] = subscribed?(options[:user]) if options.has_key?(:user) + json[:subscribed] = subscribed?(options[:user], project) if options.has_key?(:user) && options[:user] if options.has_key?(:labels) json[:labels] = labels.as_json( diff --git a/app/models/key.rb b/app/models/key.rb index 568a60b8af3..ff8dda2dc89 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -6,7 +6,7 @@ class Key < ActiveRecord::Base belongs_to :user - before_validation :strip_white_space, :generate_fingerprint + before_validation :generate_fingerprint validates :title, presence: true, length: { within: 0..255 } validates :key, presence: true, length: { within: 0..5000 }, format: { with: /\A(ssh|ecdsa)-.*\Z/ } @@ -21,8 +21,9 @@ class Key < ActiveRecord::Base after_destroy :remove_from_shell after_destroy :post_destroy_hook - def strip_white_space - self.key = key.strip unless key.blank? + def key=(value) + value.strip! unless value.blank? + write_attribute(:key, value) end def publishable_key diff --git a/app/models/member.rb b/app/models/member.rb index b89ba8ecbb8..df93aaee847 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -113,6 +113,8 @@ class Member < ActiveRecord::Base member.save end + UserProjectAccessChangedService.new(user.id).execute if user.is_a?(User) + member end @@ -239,17 +241,28 @@ class Member < ActiveRecord::Base end def post_create_hook + UserProjectAccessChangedService.new(user.id).execute system_hook_service.execute_hooks_for(self, :create) end def post_update_hook - # override in subclass + UserProjectAccessChangedService.new(user.id).execute if access_level_changed? end def post_destroy_hook + refresh_member_authorized_projects system_hook_service.execute_hooks_for(self, :destroy) end + def refresh_member_authorized_projects + # If user/source is being destroyed, project access are gonna be destroyed eventually + # because of DB foreign keys, so we shouldn't bother with refreshing after each + # member is destroyed through association + return if destroyed_by_association.present? + + UserProjectAccessChangedService.new(user_id).execute + end + def after_accept_invite post_create_hook end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index d76feb9680e..fdf54cc8a7e 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -494,10 +494,14 @@ class MergeRequest < ActiveRecord::Base discussions_resolvable? && diff_discussions.none?(&:to_be_resolved?) end + def discussions_to_be_resolved? + discussions_resolvable? && !discussions_resolved? + end + def mergeable_discussions_state? return true unless project.only_allow_merge_if_all_discussions_are_resolved? - discussions_resolved? + !discussions_to_be_resolved? end def hook_attrs @@ -686,18 +690,21 @@ class MergeRequest < ActiveRecord::Base def mergeable_ci_state? return true unless project.only_allow_merge_if_build_succeeds? - !pipeline || pipeline.success? + !pipeline || pipeline.success? || pipeline.skipped? end def environments return [] unless diff_head_commit - @environments ||= - begin - envs = target_project.environments_for(target_branch, diff_head_commit, with_tags: true) - envs.concat(source_project.environments_for(source_branch, diff_head_commit)) if source_project - envs.uniq - end + @environments ||= begin + target_envs = target_project.environments_for( + target_branch, commit: diff_head_commit, with_tags: true) + + source_envs = source_project.environments_for( + source_branch, commit: diff_head_commit) if source_project + + (target_envs.to_a + source_envs.to_a).uniq + end end def state_human_name diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb index 99c49a020c9..cdc408738be 100644 --- a/app/models/merge_request/metrics.rb +++ b/app/models/merge_request/metrics.rb @@ -1,5 +1,6 @@ class MergeRequest::Metrics < ActiveRecord::Base belongs_to :merge_request + belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :pipeline_id def record! if merge_request.merged? && self.merged_at.blank? diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index dd65a9a8b86..58a24eb84cb 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -11,6 +11,9 @@ class MergeRequestDiff < ActiveRecord::Base belongs_to :merge_request + serialize :st_commits + serialize :st_diffs + state_machine :state, initial: :empty do state :collected state :overflow @@ -22,8 +25,7 @@ class MergeRequestDiff < ActiveRecord::Base state :overflow_diff_lines_limit end - serialize :st_commits - serialize :st_diffs + scope :viewable, -> { without_state(:empty) } # All diff information is collected from repository after object is created. # It allows you to override variables like head_commit_sha before getting diff. diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 23aecbfa3a6..c774e69080c 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -29,6 +29,7 @@ class Milestone < ActiveRecord::Base validates :title, presence: true, uniqueness: { scope: :project_id } validates :project, presence: true + validate :start_date_should_be_less_than_due_date, if: Proc.new { |m| m.start_date.present? && m.due_date.present? } strip_attributes :title @@ -131,24 +132,6 @@ class Milestone < ActiveRecord::Base self.title end - def expired? - if due_date - due_date.past? - else - false - end - end - - def expires_at - if due_date - if due_date.past? - "expired on #{due_date.to_s(:medium)}" - else - "expires on #{due_date.to_s(:medium)}" - end - end - end - def can_be_closed? active? && issues.opened.count.zero? end @@ -212,4 +195,10 @@ class Milestone < ActiveRecord::Base def sanitize_title(value) CGI.unescape_html(Sanitize.clean(value.to_s)) end + + def start_date_should_be_less_than_due_date + if due_date <= start_date + errors.add(:start_date, "Can't be greater than due date") + end + end end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index b67049f0f55..891dffac648 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -27,6 +27,7 @@ class Namespace < ActiveRecord::Base delegate :name, to: :owner, allow_nil: true, prefix: true after_update :move_dir, if: :path_changed? + after_commit :refresh_access_of_projects_invited_groups, on: :update, if: -> { previous_changes.key?('share_with_group_lock') } # Save the storage paths before the projects are destroyed to use them on after destroy before_destroy(prepend: true) { @old_repository_storage_paths = repository_storage_paths } @@ -103,6 +104,8 @@ class Namespace < ActiveRecord::Base gitlab_shell.add_namespace(repository_storage_path, path_was) unless gitlab_shell.mv_namespace(repository_storage_path, path_was, path) + Rails.logger.error "Exception moving path #{repository_storage_path} from #{path_was} to #{path}" + # if we cannot move namespace directory we should rollback # db changes in order to prevent out of sync between db and fs raise Exception.new('namespace directory cannot be moved') @@ -175,4 +178,11 @@ class Namespace < ActiveRecord::Base end end end + + def refresh_access_of_projects_invited_groups + Group. + joins(project_group_links: :project). + where(projects: { namespace_id: id }). + find_each(&:refresh_members_authorized_projects) + end end diff --git a/app/models/note.rb b/app/models/note.rb index 2d644b03e4d..ed4224e3046 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -7,6 +7,7 @@ class Note < ActiveRecord::Base include Importable include FasterCacheKeys include CacheMarkdownField + include AfterCommitQueue cache_markdown_field :note, pipeline: :note @@ -18,6 +19,9 @@ class Note < ActiveRecord::Base # Banzai::ObjectRenderer attr_accessor :user_visible_reference_count + # Attribute used to store the attributes that have ben changed by slash commands. + attr_accessor :commands_changes + default_value_for :system, false attr_mentionable :note, pipeline: :note diff --git a/app/models/project.rb b/app/models/project.rb index bbe590b5a8a..9256e9ddd95 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -13,6 +13,7 @@ class Project < ActiveRecord::Base include CaseSensitivity include TokenAuthenticatable include ProjectFeaturesCompatibility + include SelectForProjectAuthorization extend Gitlab::ConfigHelper @@ -23,7 +24,9 @@ class Project < ActiveRecord::Base cache_markdown_field :description, pipeline: :description - delegate :feature_available?, :builds_enabled?, :wiki_enabled?, :merge_requests_enabled?, to: :project_feature, allow_nil: true + delegate :feature_available?, :builds_enabled?, :wiki_enabled?, + :merge_requests_enabled?, :issues_enabled?, to: :project_feature, + allow_nil: true default_value_for :archived, false default_value_for :visibility_level, gitlab_config_features.visibility_level @@ -35,6 +38,7 @@ class Project < ActiveRecord::Base default_value_for :builds_enabled, gitlab_config_features.builds default_value_for :wiki_enabled, gitlab_config_features.wiki default_value_for :snippets_enabled, gitlab_config_features.snippets + default_value_for :only_allow_merge_if_all_discussions_are_resolved, false after_create :ensure_dir_exist after_create :create_project_feature, unless: :project_feature @@ -74,9 +78,9 @@ class Project < ActiveRecord::Base has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event' has_many :boards, before_add: :validate_board_limit, dependent: :destroy + has_many :chat_services # Project services - has_many :services has_one :campfire_service, dependent: :destroy has_one :drone_ci_service, dependent: :destroy has_one :emails_on_push_service, dependent: :destroy @@ -89,6 +93,7 @@ class Project < ActiveRecord::Base has_one :assembla_service, dependent: :destroy has_one :asana_service, dependent: :destroy has_one :gemnasium_service, dependent: :destroy + has_one :mattermost_slash_commands_service, dependent: :destroy has_one :slack_service, dependent: :destroy has_one :buildkite_service, dependent: :destroy has_one :bamboo_service, dependent: :destroy @@ -121,6 +126,8 @@ class Project < ActiveRecord::Base has_many :hooks, dependent: :destroy, class_name: 'ProjectHook' has_many :protected_branches, dependent: :destroy + has_many :project_authorizations, dependent: :destroy + has_many :authorized_users, through: :project_authorizations, source: :user, class_name: 'User' has_many :project_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source alias_method :members, :project_members has_many :users, through: :project_members @@ -158,6 +165,7 @@ class Project < ActiveRecord::Base delegate :name, to: :owner, allow_nil: true, prefix: true delegate :members, to: :team, prefix: true delegate :add_user, to: :team + delegate :add_guest, :add_reporter, :add_developer, :add_master, to: :team # Validations validates :creator, presence: true, on: :create @@ -169,6 +177,7 @@ class Project < ActiveRecord::Base message: Gitlab::Regex.project_name_regex_message } validates :path, presence: true, + project_path: true, length: { within: 0..255 }, format: { with: Gitlab::Regex.project_path_regex, message: Gitlab::Regex.project_path_regex_message } @@ -748,27 +757,32 @@ class Project < ActiveRecord::Base update_column(:has_external_wiki, services.external_wikis.any?) end - def build_missing_services + def find_or_initialize_services services_templates = Service.where(template: true) - Service.available_services_names.each do |service_name| + Service.available_services_names.map do |service_name| service = find_service(services, service_name) - # If service is available but missing in db - if service.nil? + if service + service + else # We should check if template for the service exists template = find_service(services_templates, service_name) if template.nil? - # If no template, we should create an instance. Ex `create_gitlab_ci_service` - public_send("create_#{service_name}_service") + # If no template, we should create an instance. Ex `build_gitlab_ci_service` + public_send("build_#{service_name}_service") else - Service.create_from_template(self.id, template) + Service.build_from_template(id, template) end end end end + def find_or_initialize_service(name) + find_or_initialize_services.find { |service| service.to_param == name } + end + def create_labels Label.templates.each do |label| params = label.attributes.except('id', 'template', 'created_at', 'updated_at') @@ -878,7 +892,7 @@ class Project < ActiveRecord::Base end def empty_repo? - !repository.exists? || !repository.has_visible_content? + repository.empty_repo? end def repo @@ -1076,7 +1090,7 @@ class Project < ActiveRecord::Base "refs/heads/#{branch}", force: true) repository.copy_gitattributes(branch) - repository.expire_avatar_cache(branch) + repository.expire_avatar_cache reload_default_branch end @@ -1282,20 +1296,6 @@ class Project < ActiveRecord::Base end end - # Checks if `user` is authorized for this project, with at least the - # `min_access_level` (if given). - # - # If you change the logic of this method, please also update `User#authorized_projects` - def authorized_for_user?(user, min_access_level = nil) - return false unless user - - return true if personal? && namespace_id == user.namespace_id - - authorized_for_user_by_group?(user, min_access_level) || - authorized_for_user_by_members?(user, min_access_level) || - authorized_for_user_by_shared_projects?(user, min_access_level) - end - def append_or_update_attribute(name, value) old_values = public_send(name.to_s) @@ -1318,22 +1318,30 @@ class Project < ActiveRecord::Base Gitlab::Redis.with { |redis| redis.del(pushes_since_gc_redis_key) } end - def environments_for(ref, commit, with_tags: false) - environment_ids = deployments.group(:environment_id). - select(:environment_id) + def environments_for(ref, commit: nil, with_tags: false) + deployments_query = with_tags ? 'ref = ? OR tag IS TRUE' : 'ref = ?' - environment_ids = - if with_tags - environment_ids.where('ref=? OR tag IS TRUE', ref) - else - environment_ids.where(ref: ref) - end + environment_ids = deployments + .where(deployments_query, ref.to_s) + .group(:environment_id) + .select(:environment_id) - environments.available.where(id: environment_ids).select do |environment| + environments_found = environments.available + .where(id: environment_ids).to_a + + return environments_found unless commit + + environments_found.select do |environment| environment.includes_commit?(commit) end end + def environments_recently_updated_on_branch(branch) + environments_for(branch).select do |environment| + environment.recently_updated_on_branch?(branch) + end + end + private def pushes_since_gc_redis_key @@ -1345,30 +1353,6 @@ class Project < ActiveRecord::Base current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_MERGE end - def authorized_for_user_by_group?(user, min_access_level) - member = user.group_members.find_by(source_id: group) - - member && (!min_access_level || member.access_level >= min_access_level) - end - - def authorized_for_user_by_members?(user, min_access_level) - member = members.find_by(user_id: user) - - member && (!min_access_level || member.access_level >= min_access_level) - end - - def authorized_for_user_by_shared_projects?(user, min_access_level) - shared_projects = user.group_members.joins(group: :shared_projects). - where(project_group_links: { project_id: self }) - - if min_access_level - members_scope = { access_level: Gitlab::Access.values.select { |access| access >= min_access_level } } - shared_projects = shared_projects.where(members: members_scope) - end - - shared_projects.any? - end - # Similar to the normal callbacks that hook into the life cycle of an # Active Record object, you can also define callbacks that get triggered # when you add an object to an association collection. If any of these diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb new file mode 100644 index 00000000000..a00d43773d9 --- /dev/null +++ b/app/models/project_authorization.rb @@ -0,0 +1,8 @@ +class ProjectAuthorization < ActiveRecord::Base + belongs_to :user + belongs_to :project + + validates :project, presence: true + validates :access_level, inclusion: { in: Gitlab::Access.all_values }, presence: true + validates :user, uniqueness: { scope: [:project, :access_level] }, presence: true +end diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index 34fd5a57b5e..03194fc2141 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -49,23 +49,21 @@ class ProjectFeature < ActiveRecord::Base end def builds_enabled? - return true unless builds_access_level - builds_access_level > DISABLED end def wiki_enabled? - return true unless wiki_access_level - wiki_access_level > DISABLED end def merge_requests_enabled? - return true unless merge_requests_access_level - merge_requests_access_level > DISABLED end + def issues_enabled? + issues_access_level > DISABLED + end + private # Validates builds and merge requests access level diff --git a/app/models/project_group_link.rb b/app/models/project_group_link.rb index db46def11eb..6149c35cc61 100644 --- a/app/models/project_group_link.rb +++ b/app/models/project_group_link.rb @@ -16,6 +16,9 @@ class ProjectGroupLink < ActiveRecord::Base validates :group_access, inclusion: { in: Gitlab::Access.values }, presence: true validate :different_group + after_create :refresh_group_members_authorized_projects + after_destroy :refresh_group_members_authorized_projects + def self.access_options Gitlab::Access.options end @@ -35,4 +38,8 @@ class ProjectGroupLink < ActiveRecord::Base errors.add(:base, "Project cannot be shared with the project it is in.") end end + + def refresh_group_members_authorized_projects + group.refresh_members_authorized_projects + end end diff --git a/app/models/project_services/chat_service.rb b/app/models/project_services/chat_service.rb new file mode 100644 index 00000000000..d36beff5fa6 --- /dev/null +++ b/app/models/project_services/chat_service.rb @@ -0,0 +1,21 @@ +# Base class for Chat services +# This class is not meant to be used directly, but only to inherrit from. +class ChatService < Service + default_value_for :category, 'chat' + + has_many :chat_names, foreign_key: :service_id + + def valid_token?(token) + self.respond_to?(:token) && + self.token.present? && + ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.token) + end + + def supported_events + [] + end + + def trigger(params) + raise NotImplementedError + end +end diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index 2dbe0075465..70bbbbcda85 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -1,24 +1,3 @@ -# == Schema Information -# -# Table name: services -# -# id :integer not null, primary key -# type :string(255) -# title :string(255) -# project_id :integer -# created_at :datetime -# updated_at :datetime -# active :boolean default(FALSE), not null -# properties :text -# template :boolean default(FALSE) -# push_events :boolean default(TRUE) -# issues_events :boolean default(TRUE) -# merge_requests_events :boolean default(TRUE) -# tag_push_events :boolean default(TRUE) -# note_events :boolean default(TRUE), not null -# build_events :boolean default(FALSE), not null -# - class JiraService < IssueTrackerService include Gitlab::Routing.url_helpers @@ -30,6 +9,10 @@ class JiraService < IssueTrackerService before_update :reset_password + def supported_events + %w(commit merge_request) + end + # {PROJECT-KEY}-{NUMBER} Examples: JIRA-1, PROJECT-1 def reference_pattern @reference_pattern ||= %r{(?<issue>\b([A-Z][A-Z0-9_]+-)\d+)} @@ -70,13 +53,13 @@ class JiraService < IssueTrackerService end def jira_project - @jira_project ||= client.Project.find(project_key) + @jira_project ||= jira_request { client.Project.find(project_key) } end def help - 'See the ' \ - '[integration doc](http://doc.gitlab.com/ce/integration/external-issue-tracker.html) '\ - 'for details.' + 'You need to configure JIRA before enabling this service. For more details + read the + [JIRA service documentation](https://docs.gitlab.com/ce/project_services/jira.html).' end def title @@ -128,21 +111,26 @@ class JiraService < IssueTrackerService # we just want to test settings test_settings else - close_issue(push, issue) + jira_issue = jira_request { client.Issue.find(issue.iid) } + + return false unless jira_issue.present? + + close_issue(push, jira_issue) end end def create_cross_reference_note(mentioned, noteable, author) - issue_key = mentioned.id - project = self.project - noteable_name = noteable.class.name.underscore.downcase - noteable_id = if noteable.is_a?(Commit) - noteable.id - else - noteable.iid - end + unless can_cross_reference?(noteable) + return "Events for #{noteable.model_name.plural.humanize(capitalize: false)} are disabled." + end + + jira_issue = jira_request { client.Issue.find(mentioned.id) } + + return unless jira_issue.present? - entity_url = build_entity_url(noteable_name.to_sym, noteable_id) + noteable_id = noteable.respond_to?(:iid) ? noteable.iid : noteable.id + noteable_type = noteable_name(noteable) + entity_url = build_entity_url(noteable_type, noteable_id) data = { user: { @@ -150,17 +138,17 @@ class JiraService < IssueTrackerService url: resource_url(user_path(author)), }, project: { - name: project.path_with_namespace, - url: resource_url(namespace_project_path(project.namespace, project)) + name: self.project.path_with_namespace, + url: resource_url(namespace_project_path(project.namespace, self.project)) }, entity: { - name: noteable_name.humanize.downcase, + name: noteable_type.humanize.downcase, url: entity_url, title: noteable.title } } - add_comment(data, issue_key) + add_comment(data, jira_issue) end # reason why service cannot be tested @@ -181,16 +169,22 @@ class JiraService < IssueTrackerService def test_settings return unless url.present? # Test settings by getting the project - jira_project - - rescue Errno::ECONNREFUSED, JIRA::HTTPError => e - Rails.logger.info "#{self.class.name} ERROR: #{e.message}. API URL: #{url}." - false + jira_request { jira_project.present? } end private + def can_cross_reference?(noteable) + case noteable + when Commit then commit_events + when MergeRequest then merge_requests_events + else true + end + end + def close_issue(entity, issue) + return if issue.nil? || issue.resolution.present? || !jira_issue_transition_id.present? + commit_id = if entity.is_a?(Commit) entity.id elsif entity.is_a?(MergeRequest) @@ -200,72 +194,117 @@ class JiraService < IssueTrackerService commit_url = build_entity_url(:commit, commit_id) # Depending on the JIRA project's workflow, a comment during transition - # may or may not be allowed. Split the operation in to two calls so the - # comment always works. - transition_issue(issue) - add_issue_solved_comment(issue, commit_id, commit_url) + # may or may not be allowed. Refresh the issue after transition and check + # if it is closed, so we don't have one comment for every commit. + issue = jira_request { client.Issue.find(issue.key) } if transition_issue(issue) + add_issue_solved_comment(issue, commit_id, commit_url) if issue.resolution end def transition_issue(issue) - issue = client.Issue.find(issue.iid) issue.transitions.build.save(transition: { id: jira_issue_transition_id }) end def add_issue_solved_comment(issue, commit_id, commit_url) - comment = "Issue solved with [#{commit_id}|#{commit_url}]." - send_message(issue.iid, comment) + link_title = "GitLab: Solved by commit #{commit_id}." + comment = "Issue solved with [#{commit_id}|#{commit_url}]." + link_props = build_remote_link_props(url: commit_url, title: link_title, resolved: true) + send_message(issue, comment, link_props) end - def add_comment(data, issue_key) - user_name = data[:user][:name] - user_url = data[:user][:url] - entity_name = data[:entity][:name] - entity_url = data[:entity][:url] + def add_comment(data, issue) + user_name = data[:user][:name] + user_url = data[:user][:url] + entity_name = data[:entity][:name] + entity_url = data[:entity][:url] entity_title = data[:entity][:title] project_name = data[:project][:name] - message = "[#{user_name}|#{user_url}] mentioned this issue in [a #{entity_name} of #{project_name}|#{entity_url}]:\n'#{entity_title}'" + message = "[#{user_name}|#{user_url}] mentioned this issue in [a #{entity_name} of #{project_name}|#{entity_url}]:\n'#{entity_title}'" + link_title = "GitLab: Mentioned on #{entity_name} - #{entity_title}" + link_props = build_remote_link_props(url: entity_url, title: link_title) - unless comment_exists?(issue_key, message) - send_message(issue_key, message) + unless comment_exists?(issue, message) + send_message(issue, message, link_props) end end - def comment_exists?(issue_key, message) - comments = client.Issue.find(issue_key).comments - comments.map { |comment| comment.body.include?(message) }.any? + def comment_exists?(issue, message) + comments = jira_request { issue.comments } + + comments.present? && comments.any? { |comment| comment.body.include?(message) } end - def send_message(issue_key, message) + def send_message(issue, message, remote_link_props) return unless url.present? - issue = client.Issue.find(issue_key) + jira_request do + if issue.comments.build.save!(body: message) + remote_link = issue.remotelink.build + remote_link.save!(remote_link_props) + result_message = "#{self.class.name} SUCCESS: Successfully posted to #{url}." + end - if issue.comments.build.save!(body: message) - result_message = "#{self.class.name} SUCCESS: Successfully posted to #{url}." + Rails.logger.info(result_message) + result_message end + end - Rails.logger.info(result_message) - result_message - rescue URI::InvalidURIError, Errno::ECONNREFUSED, JIRA::HTTPError => e - Rails.logger.info "#{self.class.name} Send message ERROR: #{url} - #{e.message}" + # Build remote link on JIRA properties + # Icons here must be available on WEB so JIRA can read the URL + # We are using a open word graphics icon which have LGPL license + def build_remote_link_props(url:, title:, resolved: false) + status = { + resolved: resolved + } + + if resolved + status[:icon] = { + title: 'Closed', + url16x16: 'http://www.openwebgraphics.com/resources/data/1768/16x16_apply.png' + } + end + + { + GlobalID: 'GitLab', + object: { + url: url, + title: title, + status: status, + icon: { title: 'GitLab', url16x16: 'https://gitlab.com/favicon.ico' } + } + } end def resource_url(resource) "#{Settings.gitlab.base_url.chomp("/")}#{resource}" end - def build_entity_url(entity_name, entity_id) - resource_url( - polymorphic_url( - [ - self.project.namespace.becomes(Namespace), - self.project, - entity_name - ], - id: entity_id, - routing_type: :path - ) + def build_entity_url(noteable_type, entity_id) + polymorphic_url( + [ + self.project.namespace.becomes(Namespace), + self.project, + noteable_type.to_sym + ], + id: entity_id, + host: Settings.gitlab.base_url ) end + + def noteable_name(noteable) + name = noteable.model_name.singular + + # ProjectSnippet inherits from Snippet class so it causes + # routing error building the URL. + name == "project_snippet" ? "snippet" : name + end + + # Handle errors when doing JIRA API calls + def jira_request + yield + + rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError => e + Rails.logger.info "#{self.class.name} Send message ERROR: #{url} - #{e.message}" + nil + end end diff --git a/app/models/project_services/mattermost_slash_commands_service.rb b/app/models/project_services/mattermost_slash_commands_service.rb new file mode 100644 index 00000000000..33431f41dc2 --- /dev/null +++ b/app/models/project_services/mattermost_slash_commands_service.rb @@ -0,0 +1,49 @@ +class MattermostSlashCommandsService < ChatService + include TriggersHelper + + prop_accessor :token + + def can_test? + false + end + + def title + 'Mattermost Command' + end + + def description + "Perform common operations on GitLab in Mattermost" + end + + def to_param + 'mattermost_slash_commands' + end + + def fields + [ + { type: 'text', name: 'token', placeholder: '' } + ] + end + + def trigger(params) + return nil unless valid_token?(params[:token]) + + user = find_chat_user(params) + unless user + url = authorize_chat_name_url(params) + return Mattermost::Presenter.authorize_chat_name(url) + end + + Gitlab::ChatCommands::Command.new(project, user, params).execute + end + + private + + def find_chat_user(params) + ChatNames::FindUserService.new(self, params).execute + end + + def authorize_chat_name_url(params) + ChatNames::AuthorizeUserService.new(self, params).execute + end +end diff --git a/app/models/project_services/slack_service/note_message.rb b/app/models/project_services/slack_service/note_message.rb index 9e84e90f38c..797c5937f09 100644 --- a/app/models/project_services/slack_service/note_message.rb +++ b/app/models/project_services/slack_service/note_message.rb @@ -46,25 +46,25 @@ class SlackService commit_sha = commit[:id] commit_sha = Commit.truncate_sha(commit_sha) commented_on_message( - "[commit #{commit_sha}](#{@note_url})", + "commit #{commit_sha}", format_title(commit[:message])) end def create_issue_note(issue) commented_on_message( - "[issue ##{issue[:iid]}](#{@note_url})", + "issue ##{issue[:iid]}", format_title(issue[:title])) end def create_merge_note(merge_request) commented_on_message( - "[merge request !#{merge_request[:iid]}](#{@note_url})", + "merge request !#{merge_request[:iid]}", format_title(merge_request[:title])) end def create_snippet_note(snippet) commented_on_message( - "[snippet ##{snippet[:id]}](#{@note_url})", + "snippet ##{snippet[:id]}", format_title(snippet[:title])) end @@ -76,8 +76,8 @@ class SlackService "[#{@project_name}](#{@project_url})" end - def commented_on_message(target_link, title) - @message = "#{@user_name} commented on #{target_link} in #{project_link}: *#{title}*" + def commented_on_message(target, title) + @message = "#{@user_name} [commented on #{target}](#{@note_url}) in #{project_link}: *#{title}*" end end end diff --git a/app/models/project_services/slack_service/pipeline_message.rb b/app/models/project_services/slack_service/pipeline_message.rb index f06b3562965..f8d03c0e2fa 100644 --- a/app/models/project_services/slack_service/pipeline_message.rb +++ b/app/models/project_services/slack_service/pipeline_message.rb @@ -1,11 +1,10 @@ class SlackService class PipelineMessage < BaseMessage - attr_reader :sha, :ref_type, :ref, :status, :project_name, :project_url, + attr_reader :ref_type, :ref, :status, :project_name, :project_url, :user_name, :duration, :pipeline_id def initialize(data) pipeline_attributes = data[:object_attributes] - @sha = pipeline_attributes[:sha] @ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch' @ref = pipeline_attributes[:ref] @status = pipeline_attributes[:status] @@ -14,7 +13,7 @@ class SlackService @project_name = data[:project][:path_with_namespace] @project_url = data[:project][:web_url] - @user_name = data[:commit] && data[:commit][:author_name] + @user_name = data[:user] && data[:user][:name] end def pretext @@ -73,7 +72,7 @@ class SlackService end def pipeline_link - "[#{Commit.truncate_sha(sha)}](#{pipeline_url})" + "[##{pipeline_id}](#{pipeline_url})" end end end diff --git a/app/models/project_team.rb b/app/models/project_team.rb index a6e911df9bd..8a53e974b6f 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -21,6 +21,22 @@ class ProjectTeam end end + def add_guest(user, current_user: nil) + self << [user, :guest, current_user] + end + + def add_reporter(user, current_user: nil) + self << [user, :reporter, current_user] + end + + def add_developer(user, current_user: nil) + self << [user, :developer, current_user] + end + + def add_master(user, current_user: nil) + self << [user, :master, current_user] + end + def find_member(user_id) member = project.members.find_by(user_id: user_id) @@ -64,19 +80,19 @@ class ProjectTeam alias_method :users, :members def guests - @guests ||= fetch_members(:guests) + @guests ||= fetch_members(Gitlab::Access::GUEST) end def reporters - @reporters ||= fetch_members(:reporters) + @reporters ||= fetch_members(Gitlab::Access::REPORTER) end def developers - @developers ||= fetch_members(:developers) + @developers ||= fetch_members(Gitlab::Access::DEVELOPER) end def masters - @masters ||= fetch_members(:masters) + @masters ||= fetch_members(Gitlab::Access::MASTER) end def import(source_project, current_user = nil) @@ -125,8 +141,12 @@ class ProjectTeam max_member_access(user.id) == Gitlab::Access::MASTER end - def member?(user, min_member_access = Gitlab::Access::GUEST) - max_member_access(user.id) >= min_member_access + # Checks if `user` is authorized for this project, with at least the + # `min_access_level` (if given). + def member?(user, min_access_level = Gitlab::Access::GUEST) + return false unless user + + user.authorized_project?(project, min_access_level) end def human_max_access(user_id) @@ -149,112 +169,29 @@ class ProjectTeam # Lookup only the IDs we need user_ids = user_ids - access.keys + users_access = project.project_authorizations. + where(user: user_ids). + group(:user_id). + maximum(:access_level) - if user_ids.present? - user_ids.each { |id| access[id] = Gitlab::Access::NO_ACCESS } - - member_access = project.members.access_for_user_ids(user_ids) - merge_max!(access, member_access) - - if group - group_access = group.members.access_for_user_ids(user_ids) - merge_max!(access, group_access) - end - - # Each group produces a list of maximum access level per user. We take the - # max of the values produced by each group. - if project_shared_with_group? - project.project_group_links.each do |group_link| - invited_access = max_invited_level_for_users(group_link, user_ids) - merge_max!(access, invited_access) - end - end - end - + access.merge!(users_access) access end def max_member_access(user_id) - max_member_access_for_user_ids([user_id])[user_id] + max_member_access_for_user_ids([user_id])[user_id] || Gitlab::Access::NO_ACCESS end private - # For a given group, return the maximum access level for the user. This is the min of - # the invited access level of the group and the access level of the user within the group. - # For example, if the group has been given DEVELOPER access but the member has MASTER access, - # the user should receive only DEVELOPER access. - def max_invited_level_for_users(group_link, user_ids) - invited_group = group_link.group - capped_access_level = group_link.group_access - access = invited_group.group_members.access_for_user_ids(user_ids) - - # If the user is not in the list, assume he/she does not have access - missing_users = user_ids - access.keys - missing_users.each { |id| access[id] = Gitlab::Access::NO_ACCESS } - - # Cap the maximum access by the invited level access - access.each { |key, value| access[key] = [value, capped_access_level].min } - end - def fetch_members(level = nil) - project_members = project.members - group_members = group ? group.members : [] - - if level - project_members = project_members.public_send(level) - group_members = group_members.public_send(level) if group - end - - user_ids = project_members.pluck(:user_id) - - invited_members = fetch_invited_members(level) - user_ids.push(*invited_members.map(&:user_id)) if invited_members.any? + members = project.authorized_users + members = members.where(project_authorizations: { access_level: level }) if level - user_ids.push(*group_members.pluck(:user_id)) if group - - User.where(id: user_ids) + members end def group project.group end - - def merge_max!(first_hash, second_hash) - first_hash.merge!(second_hash) { |_key, old, new| old > new ? old : new } - end - - def project_shared_with_group? - project.invited_groups.any? && project.allowed_to_share_with_group? - end - - def fetch_invited_members(level = nil) - invited_members = [] - - return invited_members unless project_shared_with_group? - - project.project_group_links.includes(group: [:group_members]).each do |link| - invited_group_members = link.group.members - - if level - numeric_level = GroupMember.access_level_roles[level.to_s.singularize.titleize] - - # If we're asked for a level that's higher than the group's access, - # there's nothing left to do - next if numeric_level > link.group_access - - # Make sure we include everyone _above_ the requested level as well - invited_group_members = - if numeric_level == link.group_access - invited_group_members.where("access_level >= ?", link.group_access) - else - invited_group_members.public_send(level) - end - end - - invited_members << invited_group_members - end - - invited_members.flatten.compact - end end diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index 46f70da2452..9db96347322 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -127,7 +127,7 @@ class ProjectWiki end def search_files(query) - repository.search_files(query, default_branch) + repository.search_files_by_content(query, default_branch) end def repository diff --git a/app/models/repository.rb b/app/models/repository.rb index 063dc74021d..bf136ccdb6c 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -1,28 +1,56 @@ require 'securerandom' class Repository - class CommitError < StandardError; end - - # Files to use as a project avatar in case no avatar was uploaded via the web - # UI. - AVATAR_FILES = %w{logo.png logo.jpg logo.gif} - include Gitlab::ShellAdapter attr_accessor :path_with_namespace, :project - def self.storages - Gitlab.config.repositories.storages - end + class CommitError < StandardError; end - def self.remove_storage_from_path(repo_path) - storages.find do |_, storage_path| - if repo_path.start_with?(storage_path) - return repo_path.sub(storage_path, '') - end + # Methods that cache data from the Git repository. + # + # Each entry in this Array should have a corresponding method with the exact + # same name. The cache key used by those methods must also match method's + # name. + # + # For example, for entry `:readme` there's a method called `readme` which + # stores its data in the `readme` cache key. + 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) + + # Certain method caches should be refreshed when certain types of files are + # changed. This Hash maps file types (as returned by Gitlab::FileDetector) to + # the corresponding methods to call for refreshing caches. + METHOD_CACHES_FOR_FILE_TYPES = { + readme: :readme, + changelog: :changelog, + license: %i(license_blob license_key), + contributing: :contribution_guide, + version: :version, + gitignore: :gitignore, + koding: :koding_yml, + gitlab_ci: :gitlab_ci_yml, + avatar: :avatar + } + + # Wraps around the given method and caches its output in Redis and an instance + # variable. + # + # This only works for methods that do not take any arguments. + def self.cache_method(name, fallback: nil) + original = :"_uncached_#{name}" + + alias_method(original, name) + + define_method(name) do + cache_method_output(name, fallback: fallback) { __send__(original) } end + end - repo_path + def self.storages + Gitlab.config.repositories.storages end def initialize(path_with_namespace, project) @@ -47,24 +75,6 @@ class Repository ) end - def exists? - return @exists unless @exists.nil? - - @exists = cache.fetch(:exists?) do - begin - raw_repository && raw_repository.rugged ? true : false - rescue Gitlab::Git::Repository::NoRepository - false - end - end - end - - def empty? - return @empty unless @empty.nil? - - @empty = cache.fetch(:empty?) { raw_repository.empty? } - end - # # Git repository can contains some hidden refs like: # /refs/notes/* @@ -186,11 +196,18 @@ class Repository options = { message: message, tagger: user_to_committer(user) } if message - GitHooksService.new.execute(user, path_to_repo, oldrev, target, ref) do - rugged.tags.create(tag_name, target, options) + rugged.tags.create(tag_name, target, options) + tag = find_tag(tag_name) + + GitHooksService.new.execute(user, path_to_repo, oldrev, tag.target, ref) do + # we already created a tag, because we need tag SHA to pass correct + # values to hooks end - find_tag(tag_name) + tag + rescue GitHooksService::PreReceiveError + rugged.tags.delete(tag_name) + raise end def rm_branch(user, branch_name) @@ -224,10 +241,6 @@ class Repository branch_names + tag_names end - def branch_names - @branch_names ||= cache.fetch(:branch_names) { branches.map(&:name) } - end - def branch_exists?(branch_name) branch_names.include?(branch_name) end @@ -277,34 +290,6 @@ class Repository ref_exists?(keep_around_ref_name(sha)) end - def tag_names - cache.fetch(:tag_names) { raw_repository.tag_names } - end - - def commit_count - cache.fetch(:commit_count) do - begin - raw_repository.commit_count(self.root_ref) - rescue - 0 - end - end - end - - def branch_count - @branch_count ||= cache.fetch(:branch_count) { branches.size } - end - - def tag_count - @tag_count ||= cache.fetch(:tag_count) { raw_repository.rugged.tags.count } - end - - # Return repo size in megabytes - # Cached in redis - def size - cache.fetch(:size) { raw_repository.size } - end - def diverging_commit_counts(branch) root_ref_hash = raw_repository.rev_parse_target(root_ref).oid cache.fetch(:"diverging_commit_counts_#{branch.name}") do @@ -320,48 +305,55 @@ class Repository end end - # Keys for data that can be affected for any commit push. - def cache_keys - %i(size commit_count - readme version contribution_guide changelog - license_blob license_key gitignore koding_yml) + def expire_tags_cache + expire_method_caches(%i(tag_names tag_count)) + @tags = nil end - # Keys for data on branch/tag operations. - def cache_keys_for_branches_and_tags - %i(branch_names tag_names branch_count tag_count) + def expire_branches_cache + expire_method_caches(%i(branch_names branch_count)) + @local_branches = nil end - def build_cache - (cache_keys + cache_keys_for_branches_and_tags).each do |key| - unless cache.exist?(key) - send(key) - end - end + def expire_statistics_caches + expire_method_caches(%i(size commit_count)) end - def expire_tags_cache - cache.expire(:tag_names) - @tags = nil + def expire_all_method_caches + expire_method_caches(CACHED_METHODS) end - def expire_branches_cache - cache.expire(:branch_names) - @branch_names = nil - @local_branches = nil + # Expires the caches of a specific set of methods + def expire_method_caches(methods) + methods.each do |key| + cache.expire(key) + + ivar = cache_instance_variable_name(key) + + remove_instance_variable(ivar) if instance_variable_defined?(ivar) + end end - def expire_cache(branch_name = nil, revision = nil) - cache_keys.each do |key| - cache.expire(key) + def expire_avatar_cache + expire_method_caches(%i(avatar)) + end + + # Refreshes the method caches of this repository. + # + # types - An Array of file types (e.g. `:readme`) used to refresh extra + # caches. + def refresh_method_caches(types) + to_refresh = [] + + types.each do |type| + methods = METHOD_CACHES_FOR_FILE_TYPES[type.to_sym] + + to_refresh.concat(Array(methods)) if methods end - expire_branch_cache(branch_name) - expire_avatar_cache(branch_name, revision) + expire_method_caches(to_refresh) - # This ensures this particular cache is flushed after the first commit to a - # new repository. - expire_emptiness_caches if empty? + to_refresh.each { |method| send(method) } end def expire_branch_cache(branch_name = nil) @@ -380,15 +372,14 @@ class Repository end def expire_root_ref_cache - cache.expire(:root_ref) - @root_ref = nil + expire_method_caches(%i(root_ref)) end # Expires the cache(s) used to determine if a repository is empty or not. def expire_emptiness_caches - cache.expire(:empty?) - @empty = nil + return unless empty? + expire_method_caches(%i(empty?)) expire_has_visible_content_cache end @@ -397,51 +388,22 @@ class Repository @has_visible_content = nil end - def expire_branch_count_cache - cache.expire(:branch_count) - @branch_count = nil - end - - def expire_tag_count_cache - cache.expire(:tag_count) - @tag_count = nil - end - def lookup_cache @lookup_cache ||= {} end - def expire_avatar_cache(branch_name = nil, revision = nil) - # Avatars are pulled from the default branch, thus if somebody pushes to a - # different branch there's no need to expire anything. - return if branch_name && branch_name != root_ref - - # We don't want to flush the cache if the commit didn't actually make any - # changes to any of the possible avatar files. - if revision && commit = self.commit(revision) - return unless commit.raw_diffs(deltas_only: true). - any? { |diff| AVATAR_FILES.include?(diff.new_path) } - end - - cache.expire(:avatar) - - @avatar = nil - end - def expire_exists_cache - cache.expire(:exists?) - @exists = nil + expire_method_caches(%i(exists?)) end # expire cache that doesn't depend on repository data (when expiring) def expire_content_cache expire_tags_cache - expire_tag_count_cache expire_branches_cache - expire_branch_count_cache expire_root_ref_cache expire_emptiness_caches expire_exists_cache + expire_statistics_caches end # Runs code after a repository has been created. @@ -456,9 +418,8 @@ class Repository # Runs code just before a repository is deleted. def before_delete expire_exists_cache - - expire_cache if exists? - + expire_all_method_caches + expire_branch_cache if exists? expire_content_cache repository_event(:remove_repository) @@ -475,9 +436,9 @@ class Repository # Runs code before pushing (= creating or removing) a tag. def before_push_tag - expire_cache + expire_statistics_caches + expire_emptiness_caches expire_tags_cache - expire_tag_count_cache repository_event(:push_tag) end @@ -485,7 +446,7 @@ class Repository # Runs code before removing a tag. def before_remove_tag expire_tags_cache - expire_tag_count_cache + expire_statistics_caches repository_event(:remove_tag) end @@ -497,12 +458,14 @@ class Repository # Runs code after a repository has been forked/imported. def after_import expire_content_cache - build_cache + expire_tags_cache + expire_branches_cache end # Runs code after a new commit has been pushed. - def after_push_commit(branch_name, revision) - expire_cache(branch_name, revision) + def after_push_commit(branch_name) + expire_statistics_caches + expire_branch_cache(branch_name) repository_event(:push_commit, branch: branch_name) end @@ -511,7 +474,6 @@ class Repository def after_create_branch expire_branches_cache expire_has_visible_content_cache - expire_branch_count_cache repository_event(:push_branch) end @@ -526,7 +488,6 @@ class Repository # Runs code after an existing branch has been removed. def after_remove_branch expire_has_visible_content_cache - expire_branch_count_cache expire_branches_cache end @@ -553,86 +514,127 @@ class Repository Gitlab::Git::Blob.raw(self, oid) end + def root_ref + if raw_repository + raw_repository.root_ref + else + # When the repo does not exist we raise this error so no data is cached. + raise Rugged::ReferenceError + end + end + cache_method :root_ref + + def exists? + refs_directory_exists? + end + cache_method :exists? + + def empty? + raw_repository.empty? + end + cache_method :empty? + + # The size of this repository in megabytes. + def size + exists? ? raw_repository.size : 0.0 + end + cache_method :size, fallback: 0.0 + + def commit_count + root_ref ? raw_repository.commit_count(root_ref) : 0 + end + cache_method :commit_count, fallback: 0 + + def branch_names + branches.map(&:name) + end + cache_method :branch_names, fallback: [] + + def tag_names + raw_repository.tag_names + end + cache_method :tag_names, fallback: [] + + def branch_count + branches.size + end + cache_method :branch_count, fallback: 0 + + def tag_count + raw_repository.rugged.tags.count + end + cache_method :tag_count, fallback: 0 + + def avatar + if tree = file_on_head(:avatar) + tree.path + end + end + cache_method :avatar + def readme - cache.fetch(:readme) { tree(:head).readme } + if head = tree(:head) + head.readme + end end + cache_method :readme def version - cache.fetch(:version) do - tree(:head).blobs.find do |file| - file.name.casecmp('version').zero? - end - end + file_on_head(:version) end + cache_method :version def contribution_guide - cache.fetch(:contribution_guide) do - tree(:head).blobs.find do |file| - file.contributing? - end - end + file_on_head(:contributing) end + cache_method :contribution_guide def changelog - cache.fetch(:changelog) do - file_on_head(/\A(changelog|history|changes|news)/i) - end + file_on_head(:changelog) end + cache_method :changelog def license_blob - return nil unless head_exists? - - cache.fetch(:license_blob) do - file_on_head(/\A(licen[sc]e|copying)(\..+|\z)/i) - end + file_on_head(:license) end + cache_method :license_blob def license_key - return nil unless head_exists? + return unless exists? - cache.fetch(:license_key) do - Licensee.license(path).try(:key) - end + Licensee.license(path).try(:key) end + cache_method :license_key def gitignore - return nil if !exists? || empty? - - cache.fetch(:gitignore) do - file_on_head(/\A\.gitignore\z/) - end + file_on_head(:gitignore) end + cache_method :gitignore def koding_yml - return nil unless head_exists? - - cache.fetch(:koding_yml) do - file_on_head(/\A\.koding\.yml\z/) - end + file_on_head(:koding) end + cache_method :koding_yml def gitlab_ci_yml - return nil unless head_exists? - - @gitlab_ci_yml ||= tree(:head).blobs.find do |file| - file.name == '.gitlab-ci.yml' - end - rescue Rugged::ReferenceError - # For unknow reason spinach scenario "Scenario: I change project path" - # lead to "Reference 'HEAD' not found" exception from Repository#empty? - nil + file_on_head(:gitlab_ci) end + cache_method :gitlab_ci_yml def head_commit @head_commit ||= commit(self.root_ref) end def head_tree - @head_tree ||= Tree.new(self, head_commit.sha, nil) + if head_commit + @head_tree ||= Tree.new(self, head_commit.sha, nil) + end end - def tree(sha = :head, path = nil) + def tree(sha = :head, path = nil, recursive: false) if sha == :head + return unless head_commit + if path.nil? return head_tree else @@ -640,7 +642,7 @@ class Repository end end - Tree.new(self, sha, path) + Tree.new(self, sha, path, recursive: recursive) end def blob_at_branch(branch_name, path) @@ -782,10 +784,6 @@ class Repository @tags ||= raw_repository.tags end - def root_ref - @root_ref ||= cache.fetch(:root_ref) { raw_repository.root_ref } - end - def commit_dir(user, path, message, branch, author_email: nil, author_name: nil) update_branch_with_hooks(user, branch) do |ref| options = { @@ -1063,16 +1061,25 @@ class Repository merge_base(ancestor_id, descendant_id) == ancestor_id end - def search_files(query, ref) - unless exists? && has_visible_content? && query.present? - return [] - end + def empty_repo? + !exists? || !has_visible_content? + end + + def search_files_by_content(query, ref) + return [] if empty_repo? || query.blank? offset = 2 args = %W(#{Gitlab.config.git.bin_path} grep -i -I -n --before-context #{offset} --after-context #{offset} -E -e #{Regexp.escape(query)} #{ref || root_ref}) Gitlab::Popen.popen(args, path_to_repo).first.scrub.split(/^--$/) end + def search_files_by_name(query, ref) + return [] if empty_repo? || query.blank? + + args = %W(#{Gitlab.config.git.bin_path} ls-tree --full-tree -r #{ref || root_ref} --name-status | #{Regexp.escape(query)}) + Gitlab::Popen.popen(args, path_to_repo).first.lines.map(&:strip) + end + def fetch_ref(source_path, source_ref, target_ref) args = %W(#{Gitlab.config.git.bin_path} fetch --no-tags -f #{source_path} #{source_ref}:#{target_ref}) Gitlab::Popen.popen(args, path_to_repo) @@ -1134,28 +1141,55 @@ class Repository end end - def avatar - return nil unless exists? + # Caches the supplied block both in a cache and in an instance variable. + # + # The cache key and instance variable are named the same way as the value of + # the `key` argument. + # + # This method will return `nil` if the corresponding instance variable is also + # set to `nil`. This ensures we don't keep yielding the block when it returns + # `nil`. + # + # key - The name of the key to cache the data in. + # fallback - A value to fall back to in the event of a Git error. + def cache_method_output(key, fallback: nil, &block) + ivar = cache_instance_variable_name(key) - @avatar ||= cache.fetch(:avatar) do - AVATAR_FILES.find do |file| - blob_at_branch(root_ref, file) + if instance_variable_defined?(ivar) + instance_variable_get(ivar) + else + begin + instance_variable_set(ivar, cache.fetch(key, &block)) + rescue Rugged::ReferenceError, Gitlab::Git::Repository::NoRepository + # if e.g. HEAD or the entire repository doesn't exist we want to + # gracefully handle this and not cache anything. + fallback end end end - private + def cache_instance_variable_name(key) + :"@#{key.to_s.tr('?!', '')}" + end - def cache - @cache ||= RepositoryCache.new(path_with_namespace, @project.id) + def file_on_head(type) + if head = tree(:head) + head.blobs.find do |file| + Gitlab::FileDetector.type_of(file.name) == type + end + end end - def head_exists? - exists? && !empty? && !rugged.head_unborn? + private + + def refs_directory_exists? + return false unless path_with_namespace + + File.exist?(File.join(path_to_repo, 'refs')) end - def file_on_head(regex) - tree(:head).blobs.find { |file| file.name =~ regex } + def cache + @cache ||= RepositoryCache.new(path_with_namespace, @project.id) end def tags_sorted_by_committed_date diff --git a/app/models/service.rb b/app/models/service.rb index 625fbc48302..0c36acfc1b7 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -8,6 +8,7 @@ class Service < ActiveRecord::Base default_value_for :push_events, true default_value_for :issues_events, true default_value_for :confidential_issues_events, true + default_value_for :commit_events, true default_value_for :merge_requests_events, true default_value_for :tag_push_events, true default_value_for :note_events, true @@ -202,7 +203,6 @@ class Service < ActiveRecord::Base bamboo buildkite builds_email - pipelines_email bugzilla campfire custom_issue_tracker @@ -214,6 +214,8 @@ class Service < ActiveRecord::Base hipchat irker jira + mattermost_slash_commands + pipelines_email pivotaltracker pushover redmine @@ -222,11 +224,11 @@ class Service < ActiveRecord::Base ] end - def self.create_from_template(project_id, template) + def self.build_from_template(project_id, template) service = template.dup service.template = false service.project_id = project_id - service if service.save + service end private diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 2373b445009..8ff4e7ae718 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -6,6 +6,7 @@ class Snippet < ActiveRecord::Base include Referable include Sortable include Awardable + include Mentionable cache_markdown_field :title, pipeline: :single_line cache_markdown_field :content diff --git a/app/models/subscription.rb b/app/models/subscription.rb index 3b8aa1eb866..17869c8bac2 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -1,8 +1,9 @@ class Subscription < ActiveRecord::Base belongs_to :user + belongs_to :project belongs_to :subscribable, polymorphic: true - validates :user_id, - uniqueness: { scope: [:subscribable_id, :subscribable_type] }, - presence: true + validates :user, :subscribable, presence: true + + validates :project_id, uniqueness: { scope: [:subscribable_id, :subscribable_type, :user_id] } end diff --git a/app/models/tree.rb b/app/models/tree.rb index 7c4ed6e393b..fe148b0ec65 100644 --- a/app/models/tree.rb +++ b/app/models/tree.rb @@ -3,21 +3,24 @@ class Tree attr_accessor :repository, :sha, :path, :entries - def initialize(repository, sha, path = '/') + def initialize(repository, sha, path = '/', recursive: false) path = '/' if path.blank? @repository = repository @sha = sha @path = path + @recursive = recursive git_repo = @repository.raw_repository - @entries = Gitlab::Git::Tree.where(git_repo, @sha, @path) + @entries = get_entries(git_repo, @sha, @path, recursive: @recursive) end def readme return @readme if defined?(@readme) - available_readmes = blobs.select(&:readme?) + available_readmes = blobs.select do |blob| + Gitlab::FileDetector.type_of(blob.name) == :readme + end previewable_readmes = available_readmes.select do |blob| previewable?(blob.name) @@ -58,4 +61,21 @@ class Tree def sorted_entries trees + blobs + submodules end + + private + + def get_entries(git_repo, sha, path, recursive: false) + current_path_entries = Gitlab::Git::Tree.where(git_repo, sha, path) + ordered_entries = [] + + current_path_entries.each do |entry| + ordered_entries << entry + + if recursive && entry.dir? + ordered_entries.concat(get_entries(git_repo, sha, entry.path, recursive: true)) + end + end + + ordered_entries + end end diff --git a/app/models/user.rb b/app/models/user.rb index 3813df6684e..513a19d81d2 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -56,6 +56,7 @@ class User < ActiveRecord::Base has_many :personal_access_tokens, dependent: :destroy has_many :identities, dependent: :destroy, autosave: true has_many :u2f_registrations, dependent: :destroy + has_many :chat_names, dependent: :destroy # Groups has_many :members, dependent: :destroy @@ -72,6 +73,8 @@ class User < ActiveRecord::Base has_many :created_projects, foreign_key: :creator_id, class_name: 'Project' has_many :users_star_projects, dependent: :destroy has_many :starred_projects, through: :users_star_projects, source: :project + has_many :project_authorizations, dependent: :destroy + 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 @@ -173,7 +176,7 @@ class User < ActiveRecord::Base scope :external, -> { where(external: true) } scope :active, -> { with_state(:active) } scope :not_in_project, ->(project) { project.users.present? ? where("id not in (:ids)", ids: project.users.map(&:id) ) : all } - scope :without_projects, -> { where('id NOT IN (SELECT DISTINCT(user_id) FROM members)') } + scope :without_projects, -> { where('id NOT IN (SELECT DISTINCT(user_id) FROM members WHERE user_id IS NOT NULL AND requested_at IS NULL)') } scope :todo_authors, ->(user_id, state) { where(id: Todo.where(user_id: user_id, state: state).select(:author_id)) } def self.with_two_factor @@ -226,19 +229,19 @@ class User < ActiveRecord::Base def filter(filter_name) case filter_name when 'admins' - self.admins + admins when 'blocked' - self.blocked + blocked when 'two_factor_disabled' - self.without_two_factor + without_two_factor when 'two_factor_enabled' - self.with_two_factor + with_two_factor when 'wop' - self.without_projects + without_projects when 'external' - self.external + external else - self.active + active end end @@ -288,8 +291,12 @@ class User < ActiveRecord::Base end end + def find_by_username(username) + iwhere(username: username).take + end + def find_by_username!(username) - find_by!('lower(username) = ?', username.downcase) + iwhere(username: username).take! end def find_by_personal_access_token(token_string) @@ -336,7 +343,7 @@ class User < ActiveRecord::Base end def generate_password - if self.force_random_password + if force_random_password self.password = self.password_confirmation = Devise.friendly_token.first(Devise.password_length.min) end end @@ -377,56 +384,55 @@ class User < ActiveRecord::Base end def two_factor_otp_enabled? - self.otp_required_for_login? + otp_required_for_login? end def two_factor_u2f_enabled? - self.u2f_registrations.exists? + u2f_registrations.exists? end def namespace_uniq # Return early if username already failed the first uniqueness validation - return if self.errors.key?(:username) && - self.errors[:username].include?('has already been taken') + return if errors.key?(:username) && + errors[:username].include?('has already been taken') - namespace_name = self.username - existing_namespace = Namespace.by_path(namespace_name) - if existing_namespace && existing_namespace != self.namespace - self.errors.add(:username, 'has already been taken') + existing_namespace = Namespace.by_path(username) + if existing_namespace && existing_namespace != namespace + errors.add(:username, 'has already been taken') end end def avatar_type - unless self.avatar.image? - self.errors.add :avatar, "only images allowed" + unless avatar.image? + errors.add :avatar, "only images allowed" end end def unique_email - if !self.emails.exists?(email: self.email) && Email.exists?(email: self.email) - self.errors.add(:email, 'has already been taken') + if !emails.exists?(email: email) && Email.exists?(email: email) + errors.add(:email, 'has already been taken') end end def owns_notification_email - return if self.temp_oauth_email? + return if temp_oauth_email? - self.errors.add(:notification_email, "is not an email you own") unless self.all_emails.include?(self.notification_email) + errors.add(:notification_email, "is not an email you own") unless all_emails.include?(notification_email) end def owns_public_email - return if self.public_email.blank? + return if public_email.blank? - self.errors.add(:public_email, "is not an email you own") unless self.all_emails.include?(self.public_email) + errors.add(:public_email, "is not an email you own") unless all_emails.include?(public_email) end def update_emails_with_primary_email - primary_email_record = self.emails.find_by(email: self.email) + primary_email_record = emails.find_by(email: email) if primary_email_record primary_email_record.destroy - self.emails.create(email: self.email_was) + emails.create(email: email_was) - self.update_secondary_emails! + update_secondary_emails! end end @@ -438,11 +444,44 @@ class User < ActiveRecord::Base Group.where("namespaces.id IN (#{union.to_sql})") end - # Returns projects user is authorized to access. - # - # If you change the logic of this method, please also update `Project#authorized_for_user` + def refresh_authorized_projects + loop do + begin + Gitlab::Database.serialized_transaction do + project_authorizations.delete_all + + # project_authorizations_union can return multiple records for the same project/user with + # different access_level so we take row with the maximum access_level + project_authorizations.connection.execute <<-SQL + INSERT INTO project_authorizations (user_id, project_id, access_level) + SELECT user_id, project_id, MAX(access_level) AS access_level + FROM (#{project_authorizations_union.to_sql}) sub + GROUP BY user_id, project_id + SQL + + update_column(:authorized_projects_populated, true) unless authorized_projects_populated + end + + break + # In the event of a concurrent modification Rails raises StatementInvalid. + # In this case we want to keep retrying until the transaction succeeds + rescue ActiveRecord::StatementInvalid + end + end + end + def authorized_projects(min_access_level = nil) - Project.where("projects.id IN (#{projects_union(min_access_level).to_sql})") + refresh_authorized_projects unless authorized_projects_populated + + # We're overriding an association, so explicitly call super with no arguments or it would be passed as `force_reload` to the association + projects = super() + projects = projects.where('project_authorizations.access_level >= ?', min_access_level) if min_access_level + + projects + end + + def authorized_project?(project, min_access_level = nil) + authorized_projects(min_access_level).exists?({ id: project.id }) end # Returns the projects this user has reporter (or greater) access to, limited @@ -456,8 +495,9 @@ class User < ActiveRecord::Base end def viewable_starred_projects - starred_projects.where("projects.visibility_level IN (?) OR projects.id IN (#{projects_union.to_sql})", - [Project::PUBLIC, Project::INTERNAL]) + starred_projects.where("projects.visibility_level IN (?) OR projects.id IN (?)", + [Project::PUBLIC, Project::INTERNAL], + authorized_projects.select(:project_id)) end def owned_projects @@ -580,7 +620,7 @@ class User < ActiveRecord::Base end def project_deploy_keys - DeployKey.unscoped.in_projects(self.authorized_projects.pluck(:id)).distinct(:id) + DeployKey.unscoped.in_projects(authorized_projects.pluck(:id)).distinct(:id) end def accessible_deploy_keys @@ -596,38 +636,38 @@ class User < ActiveRecord::Base end def sanitize_attrs - %w(name username skype linkedin twitter).each do |attr| - value = self.send(attr) - self.send("#{attr}=", Sanitize.clean(value)) if value.present? + %w[name username skype linkedin twitter].each do |attr| + value = public_send(attr) + public_send("#{attr}=", Sanitize.clean(value)) if value.present? end end def set_notification_email - if self.notification_email.blank? || !self.all_emails.include?(self.notification_email) - self.notification_email = self.email + if notification_email.blank? || !all_emails.include?(notification_email) + self.notification_email = email end end def set_public_email - if self.public_email.blank? || !self.all_emails.include?(self.public_email) + if public_email.blank? || !all_emails.include?(public_email) self.public_email = '' end end def update_secondary_emails! - self.set_notification_email - self.set_public_email - self.save if self.notification_email_changed? || self.public_email_changed? + set_notification_email + set_public_email + save if notification_email_changed? || public_email_changed? end def set_projects_limit # `User.select(:id)` raises # `ActiveModel::MissingAttributeError: missing attribute: projects_limit` # without this safeguard! - return unless self.has_attribute?(:projects_limit) + return unless has_attribute?(:projects_limit) connection_default_value_defined = new_record? && !projects_limit_changed? - return unless self.projects_limit.nil? || connection_default_value_defined + return unless projects_limit.nil? || connection_default_value_defined self.projects_limit = current_application_settings.default_projects_limit end @@ -657,7 +697,7 @@ class User < ActiveRecord::Base def with_defaults User.defaults.each do |k, v| - self.send("#{k}=", v) + public_send("#{k}=", v) end self @@ -677,7 +717,7 @@ class User < ActiveRecord::Base # Thus it will automatically generate a new fragment # when the event is updated because the key changes. def reset_events_cache - Event.where(author_id: self.id). + Event.where(author_id: id). order('id DESC').limit(1000). update_all(updated_at: Time.now) end @@ -710,8 +750,8 @@ class User < ActiveRecord::Base def all_emails all_emails = [] - all_emails << self.email unless self.temp_oauth_email? - all_emails.concat(self.emails.map(&:email)) + all_emails << email unless temp_oauth_email? + all_emails.concat(emails.map(&:email)) all_emails end @@ -725,21 +765,21 @@ class User < ActiveRecord::Base def ensure_namespace_correct # Ensure user has namespace - self.create_namespace!(path: self.username, name: self.username) unless self.namespace + create_namespace!(path: username, name: username) unless namespace - if self.username_changed? - self.namespace.update_attributes(path: self.username, name: self.username) + if username_changed? + namespace.update_attributes(path: username, name: username) end end def post_create_hook - log_info("User \"#{self.name}\" (#{self.email}) was created") - notification_service.new_user(self, @reset_token) if self.created_by_id + log_info("User \"#{name}\" (#{email}) was created") + notification_service.new_user(self, @reset_token) if created_by_id system_hook_service.execute_hooks_for(self, :create) end def post_destroy_hook - log_info("User \"#{self.name}\" (#{self.email}) was removed") + log_info("User \"#{name}\" (#{email}) was removed") system_hook_service.execute_hooks_for(self, :destroy) end @@ -783,7 +823,7 @@ class User < ActiveRecord::Base end def oauth_authorized_tokens - Doorkeeper::AccessToken.where(resource_owner_id: self.id, revoked_at: nil) + Doorkeeper::AccessToken.where(resource_owner_id: id, revoked_at: nil) end # Returns the projects a user contributed to in the last year. @@ -887,16 +927,14 @@ class User < ActiveRecord::Base private - def projects_union(min_access_level = nil) - relations = [personal_projects.select(:id), - groups_projects.select(:id), - projects.select(:id), - groups.joins(:shared_projects).select(:project_id)] - - if min_access_level - scope = { access_level: Gitlab::Access.all_values.select { |access| access >= min_access_level } } - relations = [relations.shift] + relations.map { |relation| relation.where(members: scope) } - end + # Returns a union query of projects that the user is authorized to access + def project_authorizations_union + relations = [ + personal_projects.select("#{id} AS user_id, projects.id AS project_id, #{Gitlab::Access::MASTER} AS access_level"), + groups_projects.select_for_project_authorization, + projects.select_for_project_authorization, + groups.joins(:shared_projects).select_for_project_authorization + ] Gitlab::SQL::Union.new(relations) end @@ -916,7 +954,7 @@ class User < ActiveRecord::Base end def ensure_external_user_rights - return unless self.external? + return unless external? self.can_create_group = false self.projects_limit = 0 @@ -928,7 +966,7 @@ class User < ActiveRecord::Base if current_application_settings.domain_blacklist_enabled? blocked_domains = current_application_settings.domain_blacklist - if domain_matches?(blocked_domains, self.email) + if domain_matches?(blocked_domains, email) error = 'is not from an allowed domain.' valid = false end @@ -936,7 +974,7 @@ class User < ActiveRecord::Base allowed_domains = current_application_settings.domain_whitelist unless allowed_domains.blank? - if domain_matches?(allowed_domains, self.email) + if domain_matches?(allowed_domains, email) valid = true else error = "domain is not authorized for sign-up" @@ -944,7 +982,7 @@ class User < ActiveRecord::Base end end - self.errors.add(:email, error) unless valid + errors.add(:email, error) unless valid valid end |