diff --git a/spec/code_keeper/corpus_spec.rb b/spec/code_keeper/corpus_spec.rb new file mode 100644 index 0000000..bf00c27 --- /dev/null +++ b/spec/code_keeper/corpus_spec.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +require "json" +require "spec_helper" +require "support/corpus_report_runner" + +RSpec.describe "GitLab corpus verification" do + describe "vendored corpus" do + it "contains the approved Ruby file list" do + expect(Dir[File.join(CorpusReportRunner::ROOT, "**/*.rb")].sort).to eq CorpusReportRunner.file_paths.sort + end + + it "does not include excluded GitLab license scopes" do + vendored_paths = Dir[File.join(CorpusReportRunner::ROOT, "**", "*")].select { |path| File.file?(path) } + + expect(vendored_paths.grep(%r{\A#{CorpusReportRunner::ROOT}/(?:ee|jh|doc)/})).to be_empty + end + end + + describe "per-file runner" do + CorpusReportRunner.file_paths.each do |path| + it "measures #{path} without crashing" do + expect { CorpusReportRunner.report_for(path, number_of_threads: 1).to_h }.not_to raise_error + end + end + end + + describe "report invariants" do + it "returns valid report-shaped data for every file" do + CorpusReportRunner.file_paths.each do |path| + aggregate_failures path do + expect_valid_report_shape(CorpusReportRunner.report_for(path, number_of_threads: 1).to_h) + end + end + end + + it "derives every summary from measurements" do + CorpusReportRunner.file_paths.each do |path| + report_hash = CorpusReportRunner.report_for(path, number_of_threads: 1).to_h + + aggregate_failures path do + expect(report_hash.fetch(:summary).fetch(:metrics)).to eq summary_from(report_hash.fetch(:measurements)) + end + end + end + + it "keeps ABC and cyclomatic measurement counts aligned" do + CorpusReportRunner.file_paths.each do |path| + counts = CorpusReportRunner.report_for(path, number_of_threads: 1).measurements.group_by(&:metric).transform_values(&:size) + + aggregate_failures path do + expect(counts.fetch(:abc_metric, 0)).to eq counts.fetch(:cyclomatic_complexity, 0) + end + end + end + + it "keeps every measurement line range inside its file" do + CorpusReportRunner.file_paths.each do |path| + line_count = File.readlines(path).size + measurements = CorpusReportRunner.report_for(path, number_of_threads: 1).measurements + + aggregate_failures path do + measurements.each do |measurement| + expect_measurement_lines_inside_file(measurement, line_count) + end + end + end + end + + it "returns identical output with sequential and parallel file runs" do + sequential_report = CorpusReportRunner.report_for(CorpusReportRunner.file_paths, number_of_threads: 1).to_h + parallel_report = CorpusReportRunner.report_for( + CorpusReportRunner.file_paths, + number_of_threads: CorpusReportRunner::PARALLEL_THREAD_COUNT + ).to_h + + expect(parallel_report).to eq sequential_report + end + + it "omits metrics with no measurements from the summary" do + report_hash = CorpusReportRunner.report_for( + File.join(CorpusReportRunner::ROOT, "config/routes.rb"), + number_of_threads: 1 + ).to_h + + expect(report_hash.fetch(:summary).fetch(:metrics)).not_to have_key(:class_length) + end + end + + def expect_valid_report_shape(report_hash) + expect { JSON.generate(report_hash) }.not_to raise_error + expect_report_root_shape(report_hash) + expect_summary_shape(report_hash.fetch(:summary).fetch(:metrics)) + expect_measurement_shapes(report_hash.fetch(:measurements)) + end + + def expect_report_root_shape(report_hash) + expect(report_hash.keys).to contain_exactly(:summary, :measurements) + expect(report_hash.fetch(:summary).keys).to contain_exactly(:metrics) + expect(report_hash.fetch(:summary).fetch(:metrics)).to be_a(Hash) + expect(report_hash.fetch(:measurements)).to be_an(Array) + end + + def expect_summary_shape(metrics) + metrics.each do |metric, summary| + expect_metric_summary_shape(metric, summary) + end + end + + def expect_metric_summary_shape(metric, summary) + expect_metric_summary_identity(metric, summary) + expect_metric_summary_values(summary) + expect_measurement_shapes(summary.fetch(:top_hotspots)) + end + + def expect_metric_summary_identity(metric, summary) + expect(CodeKeeper::Metrics::MAPPINGS).to include(metric) + expect(summary.keys).to contain_exactly(:count, :max, :top_hotspots) + end + + def expect_metric_summary_values(summary) + expect(summary.fetch(:count)).to be_a(Integer) + expect(summary.fetch(:count)).to be_positive + expect(summary.fetch(:max)).to be_a(Numeric) + expect(summary.fetch(:top_hotspots)).to be_an(Array) + expect(summary.fetch(:top_hotspots).size).to be <= CodeKeeper::MetricReport::TOP_HOTSPOTS_LIMIT + end + + def expect_measurement_shapes(measurements) + measurements.each do |measurement| + expect_measurement_shape(measurement) + end + end + + def expect_measurement_shape(measurement) + expect_measurement_identity_shape(measurement) + expect_measurement_location_shape(measurement) + expect(measurement.fetch(:value)).to be_a(Numeric) + end + + def expect_measurement_identity_shape(measurement) + expect(measurement.keys).to contain_exactly(:metric, :scope_type, :scope_name, :path, :start_line, :end_line, :value) + expect(CodeKeeper::Metrics::MAPPINGS).to include(measurement.fetch(:metric)) + expect(%i[class method module singleton_class]).to include(measurement.fetch(:scope_type)) + expect(measurement.fetch(:scope_name)).to be_a(String) + end + + def expect_measurement_location_shape(measurement) + expect(measurement.fetch(:path)).to be_a(String) + expect(measurement.fetch(:start_line)).to be_a(Integer) + expect(measurement.fetch(:end_line)).to be_a(Integer) + end + + def summary_from(measurements) + measurements.group_by { |measurement| measurement.fetch(:metric) }.transform_values do |group| + { + count: group.size, + max: group.map { |measurement| measurement.fetch(:value) }.max, + top_hotspots: top_hotspots_from(group) + } + end + end + + def top_hotspots_from(measurements) + measurements + .sort_by { |measurement| [-measurement.fetch(:value), measurement.fetch(:path), measurement.fetch(:start_line), measurement.fetch(:scope_name)] } + .first(CodeKeeper::MetricReport::TOP_HOTSPOTS_LIMIT) + end + + def expect_measurement_lines_inside_file(measurement, line_count) + expect(measurement.start_line).to be_between(1, line_count) + expect(measurement.end_line).to be_between(measurement.start_line, line_count) + end +end diff --git a/spec/fixtures/corpus/gitlab/LICENSE b/spec/fixtures/corpus/gitlab/LICENSE new file mode 100644 index 0000000..722a1cb --- /dev/null +++ b/spec/fixtures/corpus/gitlab/LICENSE @@ -0,0 +1,28 @@ +Copyright (c) 2011-present GitLab Inc. + +Portions of this software are licensed as follows: + +* All content residing under the "doc/" directory of this repository is licensed under "Creative Commons: CC BY-SA 4.0 license". +* All content that resides under the "ee/" directory of this repository, if that directory exists, is licensed under the license defined in "ee/LICENSE". +* All content that resides under the "jh/" directory of this repository, if that directory exists, is licensed under the license defined in "jh/LICENSE". +* All client-side JavaScript (when served directly or after being compiled, arranged, augmented, or combined), is licensed under the "MIT Expat" license. +* All third party components incorporated into the GitLab Software are licensed under the original license provided by the owner of the applicable component. +* Content outside of the above mentioned directories or restrictions above is available under the "MIT Expat" license as defined below. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/spec/fixtures/corpus/gitlab/README.md b/spec/fixtures/corpus/gitlab/README.md new file mode 100644 index 0000000..7396efd --- /dev/null +++ b/spec/fixtures/corpus/gitlab/README.md @@ -0,0 +1,38 @@ +# GitLab Corpus + +This directory vendors selected Ruby files from `gitlab-org/gitlab` for offline +corpus verification in the CodeKeeper test suite. + +- Source repository: https://gitlab.com/gitlab-org/gitlab +- Source revision: `85ed8239cfc6fe3cf5d5bde5975fe2f6d687ff6d` +- Location in this repository: `spec/fixtures/corpus/gitlab/` +- License notice: see `LICENSE`, copied from the same source revision. + +The selected files are outside GitLab's `ee/`, `jh/`, and `doc/` directories. +Do not add files from those directories to this corpus. At the recorded source +revision, `ee/` and `jh/` are covered by proprietary licenses and `doc/` is +covered by CC BY-SA 4.0. Files outside those directories are covered by the +MIT Expat license notice included here. + +## File List + +- `app/models/project.rb` +- `app/models/merge_request.rb` +- `app/models/concerns/has_repository.rb` +- `app/services/merge_requests/create_service.rb` +- `app/services/ci/create_pipeline_service.rb` +- `app/graphql/types/project_type.rb` +- `app/policies/project_policy.rb` +- `lib/gitlab/ci/config/entry/job.rb` +- `lib/gitlab/ci/config/entry/rules.rb` +- `lib/gitlab/ci/pipeline/chain/validate/abilities.rb` +- `lib/gitlab/background_migration/backfill_award_emoji_sharding_key.rb` +- `lib/gitlab/database/migrations/runner_backoff/communicator.rb` +- `config/routes.rb` +- `app/models/ability.rb` +- `app/models/concerns/issuable.rb` +- `app/models/release_highlight.rb` +- `app/models/concerns/ignorable_columns.rb` +- `app/models/concerns/from_set_operator.rb` +- `app/models/concerns/ci/partitionable.rb` +- `spec/models/ability_spec.rb` diff --git a/spec/fixtures/corpus/gitlab/app/graphql/types/project_type.rb b/spec/fixtures/corpus/gitlab/app/graphql/types/project_type.rb new file mode 100644 index 0000000..e63123e --- /dev/null +++ b/spec/fixtures/corpus/gitlab/app/graphql/types/project_type.rb @@ -0,0 +1,1255 @@ +# frozen_string_literal: true + +module Types + class ProjectType < BaseObject + graphql_name 'Project' + + include ::Namespaces::DeletableHelper + include Gitlab::Graphql::Authorize::AuthorizeResource + + connection_type_class Types::CountableConnectionType + + authorize :read_project + authorize_granular_token permissions: :read_project, boundary: :itself, boundary_type: :project + + def self.authorization_scopes + super + [:ai_workflows] + end + + expose_permissions Types::PermissionTypes::Project + + implements Types::TodoableInterface + implements ::Types::Projects::ProjectInterface + + field :id, GraphQL::Types::ID, + null: false, + description: 'ID of the project.', + scopes: [:api, :read_api, :ai_workflows] + + field :wiki_pages, Types::Wikis::WikiPageType.connection_type, + null: true, + resolver: Resolvers::Wikis::WikiPagesResolver, + description: 'Wiki pages of the project.', + experiment: { milestone: '19.2' }, + connection_extension: Gitlab::Graphql::Extensions::ForwardOnlyExternallyPaginatedArrayExtension, + calls_gitaly: true + + field :ci_config_path_or_default, GraphQL::Types::String, + null: false, + description: 'Path of the CI configuration file.' + + field :ci_config_variables, [Types::Ci::ConfigVariableType], + null: true, + calls_gitaly: true, + authorize: :create_pipeline, + experiment: { milestone: '15.3' }, + description: 'CI/CD config variable.' do + argument :fail_on_cache_miss, GraphQL::Types::Boolean, + required: false, + default_value: false, + description: 'Whether to throw an error if cache is not ready.' + argument :ref, GraphQL::Types::String, + required: true, + description: 'Ref.' + end + + field :ci_pipeline_creation_request, Types::Ci::PipelineCreation::RequestType, + authorize: :create_pipeline, + description: 'Get information about an asynchronous pipeline creation request.', + experiment: { milestone: '17.9' } do + argument :request_id, GraphQL::Types::String, + required: true, + description: 'ID of the pipeline creation request.' + end + + field :ci_pipeline_creation_inputs, [Types::Ci::Inputs::SpecType], + authorize: :create_pipeline, + null: true, + calls_gitaly: true, + experiment: { milestone: '17.10' }, + description: 'Inputs to create a pipeline.' do + argument :fail_on_cache_miss, GraphQL::Types::Boolean, + required: false, + default_value: false, + description: 'Whether to throw an error for a cache miss.' + argument :ref, GraphQL::Types::String, + required: true, + description: 'Ref where to create the pipeline.' + end + + field :full_path, GraphQL::Types::ID, + null: false, + scopes: [:api, :read_api, :ai_workflows], + description: 'Full path of the project.' + + field :path, GraphQL::Types::String, + null: false, + description: 'Path of the project.' + + field :organization_edit_path, GraphQL::Types::String, + null: true, + description: 'Path for editing project at the organization level.', + experiment: { milestone: '16.11' } + + field :incident_management_timeline_event_tags, [Types::IncidentManagement::TimelineEventTagType], + null: true, + description: 'Timeline event tags for the project.' + + field :sast_ci_configuration, Types::CiConfiguration::Sast::Type, + null: true, + calls_gitaly: true, + description: 'SAST CI configuration for the project.' + + field :name, GraphQL::Types::String, + null: false, + description: 'Name of the project without the namespace.', + scopes: [:api, :read_api, :ai_workflows] + + field :name_with_namespace, GraphQL::Types::String, + null: false, + description: 'Name of the project including the namespace.' + + field :description, GraphQL::Types::String, + null: true, + description: 'Short description of the project.', + scopes: [:api, :read_api, :ai_workflows] + + field :tag_list, GraphQL::Types::String, + null: true, + deprecated: { reason: 'Use `topics`', milestone: '13.12' }, + description: 'List of project topics (not Git tags).', + method: :topic_list + + field :topics, [GraphQL::Types::String], + null: true, + description: 'List of project topics.', + method: :topic_list + + field :http_url_to_repo, GraphQL::Types::String, + null: true, + description: 'URL to connect to the project via HTTPS.', + scopes: [:api, :read_api, :ai_workflows] + + field :ssh_url_to_repo, GraphQL::Types::String, + null: true, + description: 'URL to connect to the project via SSH.', + scopes: [:api, :read_api, :ai_workflows] + + field :web_url, GraphQL::Types::String, + null: true, + description: 'Web URL of the project.', + scopes: [:api, :read_api, :ai_workflows] + + field :web_path, + GraphQL::Types::String, + null: false, + description: 'Web path of the project.' + + field :edit_path, GraphQL::Types::String, + null: false, + description: 'Path for editing project.' + + field :admin_edit_path, GraphQL::Types::String, + null: true, + description: 'Admin path for editing project. Only available to admins.', + authorize: :admin_all_resources + + field :admin_show_path, GraphQL::Types::String, + null: true, + description: 'Admin path of the project. Only available to admins.', + authorize: :admin_all_resources + + field :custom_attributes, [Types::CustomAttributeType], + null: true, + description: 'Custom attributes of the project. Only available to admins.', + authorize: :read_custom_attribute + + field :forks_count, GraphQL::Types::Int, + null: false, + calls_gitaly: true, # 4 times + description: 'Number of times the project has been forked.' + + field :star_count, GraphQL::Types::Int, + null: false, + description: 'Number of times the project has been starred.' + + field :created_at, Types::TimeType, + null: true, + description: 'Timestamp of the project creation.' + + field :updated_at, Types::TimeType, + null: true, + description: 'Timestamp of when the project was last updated.' + + field :last_activity_at, Types::TimeType, + null: true, + description: 'Timestamp of the project last activity.' + + field :archived, GraphQL::Types::Boolean, + null: true, + description: 'Indicates if the project or any ancestor is archived.', + method: :self_or_ancestors_archived? + + field :is_self_archived, GraphQL::Types::Boolean, + null: true, + description: 'Indicates if the project is archived.', + method: :self_archived?, + experiment: { milestone: '18.6' } + + field :is_self_deletion_in_progress, GraphQL::Types::Boolean, + null: false, + description: 'Indicates if project deletion is in progress.', + method: :self_deletion_in_progress?, + experiment: { milestone: '18.3' } + + field :is_self_deletion_scheduled, GraphQL::Types::Boolean, + null: false, + description: 'Indicates if project deletion is scheduled.', + method: :self_deletion_scheduled?, + experiment: { milestone: '18.3' } + + field :marked_for_deletion, GraphQL::Types::Boolean, + null: true, + description: 'Indicates if the project or any ancestor is scheduled for deletion.', + method: :scheduled_for_deletion_in_hierarchy_chain?, + experiment: { milestone: '18.1' } + + field :marked_for_deletion_on, ::Types::TimeType, + null: true, + description: 'Date when project was scheduled to be deleted.', + experiment: { milestone: '16.10' } + + field :permanent_deletion_date, GraphQL::Types::String, + null: true, + description: "For projects pending deletion, returns the project's scheduled deletion date. " \ + 'For projects not pending deletion, returns a theoretical date based on current settings ' \ + 'if marked for deletion today.', + experiment: { milestone: '16.11' } + + field :visibility, GraphQL::Types::String, + null: true, + description: 'Visibility of the project.' + + field :lfs_enabled, GraphQL::Types::Boolean, + null: true, + description: 'Indicates if the project has Large File Storage (LFS) enabled.' + + field :max_access_level, Types::AccessLevelType, + null: false, + description: 'Maximum access level of the current user in the project.' + + field :merge_requests_ff_only_enabled, GraphQL::Types::Boolean, + null: true, + description: 'Indicates if no merge commits should be created and all merges should instead be ' \ + 'fast-forwarded, which means that merging is only allowed if the branch could be fast-forwarded.' + + field :shared_runners_enabled, GraphQL::Types::Boolean, + null: true, + description: 'Indicates if shared runners are enabled for the project.' + + field :service_desk_enabled, GraphQL::Types::Boolean, + null: true, + description: 'Indicates if the project has Service Desk enabled.' + + field :service_desk_address, GraphQL::Types::String, + null: true, + description: 'E-mail address of the Service Desk.' + + field :avatar_url, GraphQL::Types::String, + null: true, + calls_gitaly: true, + description: 'Avatar URL of the project.' + + field :jobs_enabled, GraphQL::Types::Boolean, + null: true, + description: 'Indicates if CI/CD pipeline jobs are enabled for the current user.' + + field :is_catalog_resource, GraphQL::Types::Boolean, + experiment: { milestone: '15.11' }, + null: true, + description: 'Indicates if a project is a catalog resource.' + + field :explore_catalog_path, GraphQL::Types::String, + experiment: { milestone: '17.6' }, + null: true, + description: 'Path to the project catalog resource.' + + field :is_published, GraphQL::Types::Boolean, + experiment: { milestone: '18.6' }, + null: true, + description: 'Indicates if a project\'s catalog resource is published.' + + field :public_jobs, GraphQL::Types::Boolean, + null: true, + description: 'Indicates if there is public access to pipelines and job details of the project, ' \ + 'including output logs and artifacts.', + method: :public_builds + + field :open_issues_count, GraphQL::Types::Int, + null: true, + description: 'Number of open issues for the project.' + + field :open_merge_requests_count, GraphQL::Types::Int, + null: true, + description: 'Number of open merge requests for the project.' + + field :allow_merge_on_skipped_pipeline, GraphQL::Types::Boolean, + null: true, + description: 'If `only_allow_merge_if_pipeline_succeeds` is true, indicates if merge requests of ' \ + 'the project can also be merged with skipped jobs.' + + field :autoclose_referenced_issues, GraphQL::Types::Boolean, + null: true, + description: 'Indicates if issues referenced by merge requests and commits within the default branch ' \ + 'are closed automatically.' + + field :import_status, GraphQL::Types::String, + null: true, + description: 'Status of import background job of the project.' + + field :jira_import_status, GraphQL::Types::String, + null: true, + description: 'Status of Jira import background job of the project.' + + field :only_allow_merge_if_all_discussions_are_resolved, GraphQL::Types::Boolean, + null: true, + description: 'Indicates if merge requests of the project can only be merged ' \ + 'when all the discussions are resolved.' + + field :only_allow_merge_if_pipeline_succeeds, GraphQL::Types::Boolean, + null: true, + description: 'Indicates if merge requests of the project can only be merged with successful jobs.' + + field :printing_merge_request_link_enabled, GraphQL::Types::Boolean, + null: true, + description: 'Indicates if a link to create or view a merge request should display after a push to Git ' \ + 'repositories of the project from the command line.' + + field :remove_source_branch_after_merge, GraphQL::Types::Boolean, + null: true, + description: 'Indicates if `Delete source branch` option should be enabled by default for all ' \ + 'new merge requests of the project.' + + field :request_access_enabled, GraphQL::Types::Boolean, + null: true, + description: 'Indicates if users can request member access to the project.' + + field :squash_read_only, GraphQL::Types::Boolean, + null: false, + description: 'Indicates if `squashReadOnly` is enabled.', + method: :squash_readonly? + + field :suggestion_commit_message, GraphQL::Types::String, + null: true, + description: 'Commit message used to apply merge request suggestions.' + + # No, the quotes are not a typo. Used to get around circular dependencies. + # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27536#note_871009675 + field :group, 'Types::GroupType', + null: true, + scopes: [:api, :read_api, :ai_workflows], + description: 'Group of the project.' + + field :root_group, 'Types::GroupType', + null: true, + scopes: [:api, :read_api, :ai_workflows], + description: 'Top-level group of the project.' + + field :namespace, Types::NamespaceType, + null: true, + scopes: [:api, :read_api, :ai_workflows], + description: 'Namespace of the project.' + + field :statistics, Types::ProjectStatisticsType, + null: true, + description: 'Statistics of the project.' + + field :statistics_details_paths, Types::ProjectStatisticsRedirectType, + null: true, + description: 'Redirects for Statistics of the project.', + scopes: [:api, :read_api, :ai_workflows], + calls_gitaly: true + + field :repository, Types::RepositoryType, + null: true, + description: 'Git repository of the project.' + + field :merge_requests, + Types::MergeRequestType.connection_type, + null: true, + description: 'Merge requests of the project.', + extras: [:lookahead], + resolver: Resolvers::ProjectMergeRequestsResolver + + field :merge_request, + Types::MergeRequestType, + null: true, + description: 'A single merge request of the project.', + resolver: Resolvers::MergeRequestsResolver.single + + field :issues, + Types::IssueType.connection_type, + null: true, + description: 'Issues of the project.', + resolver: Resolvers::ProjectIssuesResolver + + field :work_items, + Types::WorkItemType.connection_type, + null: true, + experiment: { milestone: '15.1' }, + scopes: [:api, :read_api, :ai_workflows], + description: 'Work items of the project.', + extras: [:lookahead], + resolver: Resolvers::WorkItemsResolver + + field :work_item_state_counts, + Types::WorkItemStateCountsType, + null: true, + experiment: { milestone: '16.7' }, + description: 'Counts of work items by state for the project.', + resolver: Resolvers::WorkItemStateCountsResolver + + field :issue_status_counts, + Types::IssueStatusCountsType, + null: true, + description: 'Counts of issues by status for the project.', + resolver: Resolvers::IssueStatusCountsResolver + + field :milestones, Types::MilestoneType.connection_type, + null: true, + description: 'Milestones of the project.', + resolver: Resolvers::ProjectMilestonesResolver + + field :project_members, + description: 'Members of the project.', + resolver: Resolvers::ProjectMembersResolver + + field :environments, + Types::EnvironmentType.connection_type, + null: true, + description: 'Environments of the project. ' \ + 'This field can only be resolved for one project in any single request.', + resolver: Resolvers::EnvironmentsResolver do + extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1 + end + + field :environment, + Types::EnvironmentType, + null: true, + description: 'A single environment of the project.', + resolver: Resolvers::EnvironmentsResolver.single + + field :nested_environments, + Types::NestedEnvironmentType.connection_type, + null: true, + calls_gitaly: true, + description: 'Environments for this project with nested folders, ' \ + 'can only be resolved for one project in any single request', + resolver: Resolvers::Environments::NestedEnvironmentsResolver do + extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1 + end + + field :deployment, + Types::DeploymentType, + null: true, + description: 'Details of the deployment of the project.', + resolver: Resolvers::DeploymentResolver.single + + field :issue, + Types::IssueType, + null: true, + description: 'A single issue of the project.', + resolver: Resolvers::ProjectIssuesResolver.single + + field :packages, + description: 'Packages of the project.', + resolver: Resolvers::ProjectPackagesResolver + + field :packages_cleanup_policy, + Types::Packages::Cleanup::PolicyType, + null: true, + description: 'Packages cleanup policy for the project.' + + field :packages_protection_rules, + Types::Packages::Protection::RuleType.connection_type, + null: true, + description: 'Packages protection rules for the project.', + experiment: { milestone: '16.6' }, + resolver: Resolvers::ProjectPackagesProtectionRulesResolver + + field :jobs, + type: Types::Ci::JobType.connection_type, + null: true, + authorize: :read_build, + description: 'Jobs of a project. This field can only be resolved for one project in any single request.', + resolver: Resolvers::ProjectJobsResolver, + connection_extension: ::Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension + + field :job, + type: Types::Ci::JobType, + null: true, + authorize: :read_build, + description: 'One job belonging to the project, selected by ID.' do + argument :id, Types::GlobalIDType[::CommitStatus], + required: true, + description: 'ID of the job.' + end + + field :job_analytics, + resolver: Resolvers::Ci::JobAnalyticsResolver, + description: 'CI/CD job analytics for the project. Returns an error if ClickHouse is not configured.', + experiment: { milestone: '18.5' } + + field :pipelines, + null: true, + calls_gitaly: true, + description: 'Pipelines of the project.', + extras: [:lookahead], + scopes: [:api, :read_api, :ai_workflows], + resolver: Resolvers::Ci::ProjectPipelinesResolver + + field :pipeline_schedules, + type: Types::Ci::PipelineScheduleType.connection_type, + null: true, + description: 'Pipeline schedules of the project. This field can only be resolved for one project per request.', + resolver: Resolvers::Ci::ProjectPipelineSchedulesResolver + + field :pipeline_triggers, + Types::Ci::PipelineTriggerType.connection_type, + null: true, + description: 'List of pipeline trigger tokens.', + resolver: Resolvers::Ci::PipelineTriggersResolver, + experiment: { milestone: '16.3' } + + field :pipeline, Types::Ci::PipelineType, + null: true, + description: 'Pipeline of the project. If no arguments are provided, returns the latest pipeline for the ' \ + 'head commit on the default branch', + extras: [:lookahead], + scopes: [:api, :read_api, :ai_workflows], + resolver: Resolvers::Ci::ProjectPipelineResolver + + field :pipeline_counts, Types::Ci::PipelineCountsType, + null: true, + description: 'Pipeline counts of the project.', + resolver: Resolvers::Ci::ProjectPipelineCountsResolver + + field :ci_variables, Types::Ci::ProjectVariableType.connection_type, + null: true, + description: "List of the project's CI/CD variables.", + authorize: :admin_cicd_variables, + resolver: Resolvers::Ci::VariablesResolver + + field :inherited_ci_variables, Types::Ci::InheritedCiVariableType.connection_type, + null: true, + description: "List of CI/CD variables the project inherited from its parent group and ancestors.", + authorize: :admin_cicd_variables, + resolver: Resolvers::Ci::InheritedVariablesResolver + + field :ci_cd_settings, Types::Ci::CiCdSettingType, + null: true, + description: 'CI/CD settings for the project.' + + field :sentry_detailed_error, Types::ErrorTracking::SentryDetailedErrorType, + null: true, + description: 'Detailed version of a Sentry error on the project.', + resolver: Resolvers::ErrorTracking::SentryDetailedErrorResolver + + field :grafana_integration, Types::GrafanaIntegrationType, + null: true, + description: 'Grafana integration details for the project.', + deprecated: { + reason: 'Feature was removed in 16.0. Always returns null', + milestone: '18.3' + } + + field :snippets, Types::SnippetType.connection_type, + null: true, + description: 'Snippets of the project.', + resolver: Resolvers::Projects::SnippetsResolver + + field :sentry_errors, Types::ErrorTracking::SentryErrorCollectionType, + null: true, + description: 'Paginated collection of Sentry errors on the project.', + resolver: Resolvers::ErrorTracking::SentryErrorCollectionResolver + + field :boards, Types::BoardType.connection_type, + null: true, + description: 'Boards of the project.', + max_page_size: 2000, + resolver: Resolvers::BoardsResolver + + field :recent_issue_boards, Types::BoardType.connection_type, + null: true, + description: 'List of recently visited boards of the project. Maximum size is 4.', + resolver: Resolvers::RecentBoardsResolver + + field :board, Types::BoardType, + null: true, + description: 'A single board of the project.', + resolver: Resolvers::BoardResolver + + field :jira_imports, Types::JiraImportType.connection_type, + null: true, + description: 'Jira imports into the project.' + + field :services, Types::Projects::ServiceType.connection_type, + null: true, + description: 'Project services.', + resolver: Resolvers::Projects::ServicesResolver + + field :alert_management_alerts, Types::AlertManagement::AlertType.connection_type, + null: true, + description: 'Alert Management alerts of the project.', + extras: [:lookahead], + resolver: Resolvers::AlertManagement::AlertResolver + + field :alert_management_alert, Types::AlertManagement::AlertType, + null: true, + description: 'A single Alert Management alert of the project.', + resolver: Resolvers::AlertManagement::AlertResolver.single + + field :alert_management_alert_status_counts, Types::AlertManagement::AlertStatusCountsType, + null: true, + description: 'Counts of alerts by status for the project.', + resolver: Resolvers::AlertManagement::AlertStatusCountsResolver + + field :alert_management_integrations, Types::AlertManagement::IntegrationType.connection_type, + null: true, + description: 'Integrations which can receive alerts for the project.', + resolver: Resolvers::AlertManagement::IntegrationsResolver, + deprecated: { reason: 'Use `alertManagementHttpIntegrations`', milestone: '18.2' } + + field :alert_management_http_integrations, Types::AlertManagement::HttpIntegrationType.connection_type, + null: true, + description: 'HTTP Integrations which can receive alerts for the project.', + resolver: Resolvers::AlertManagement::HttpIntegrationsResolver + + field :incident_management_timeline_events, Types::IncidentManagement::TimelineEventType.connection_type, + null: true, + description: 'Incident Management Timeline events associated with the incident.', + extras: [:lookahead], + resolver: Resolvers::IncidentManagement::TimelineEventsResolver + + field :incident_management_timeline_event, Types::IncidentManagement::TimelineEventType, + null: true, + description: 'Incident Management Timeline event associated with the incident.', + resolver: Resolvers::IncidentManagement::TimelineEventsResolver.single + + field :releases, Types::ReleaseType.connection_type, + null: true, + description: 'Releases of the project.', + resolver: Resolvers::ReleasesResolver + + field :release, Types::ReleaseType, + null: true, + description: 'A single release of the project.', + resolver: Resolvers::ReleasesResolver.single, + authorize: :read_release + + field :container_tags_expiration_policy, Types::ContainerRegistry::ContainerTagsExpirationPolicyType, + null: true, + description: 'Container tags expiration policy of the project.', + method: :container_expiration_policy, + authorize: :read_container_image + + field :container_expiration_policy, Types::ContainerExpirationPolicyType, + null: true, + deprecated: { reason: 'Use `container_tags_expiration_policy`', milestone: '17.5' }, + description: 'Container expiration policy of the project.' + + field :container_protection_repository_rules, + Types::ContainerRegistry::Protection::RuleType.connection_type, + null: true, + description: 'Container protection rules for the project.', + resolver: Resolvers::ProjectContainerRegistryProtectionRulesResolver + + field :container_protection_tag_rules, + Types::ContainerRegistry::Protection::TagRuleType.connection_type, + null: true, + description: 'Container repository tag protection rules for the project.' + + field :container_repositories, Types::ContainerRegistry::ContainerRepositoryType.connection_type, + null: true, + description: 'Container repositories of the project.', + resolver: Resolvers::ContainerRepositoriesResolver + + field :container_repositories_count, GraphQL::Types::Int, + null: false, + description: 'Number of container repositories in the project.' + + field :label, Types::LabelType, + null: true, + description: 'Label available on the project.' do + argument :title, GraphQL::Types::String, + required: true, + description: 'Title of the label.' + end + + field :terraform_state, Types::Terraform::StateType, + null: true, + description: 'Find a single Terraform state by name.', + resolver: Resolvers::Terraform::StatesResolver.single + + field :terraform_states, Types::Terraform::StateType.connection_type, + null: true, + description: 'Terraform states associated with the project.', + resolver: Resolvers::Terraform::StatesResolver + + field :terraform_state_protection_rules, + Types::Terraform::StateProtectionRuleType.connection_type, + null: true, + experiment: { milestone: '18.11' }, + description: 'Terraform state protection rules for the project.', + resolver: Resolvers::Terraform::StateProtectionRulesResolver + + field :pipeline_analytics, Types::Ci::AnalyticsType, + null: true, + description: 'Pipeline analytics.', + resolver: Resolvers::Ci::PipelineAnalyticsResolver + + field :ci_template, Types::Ci::TemplateType, + null: true, + description: 'Find a single CI/CD template by name.', + resolver: Resolvers::Ci::TemplateResolver + + field :ci_job_token_scope, Types::Ci::JobTokenScopeType, + null: true, + description: 'The CI Job Tokens scope of access.', + resolver: Resolvers::Ci::JobTokenScopeResolver + + field :ci_job_token_scope_allowlist, Types::Ci::JobTokenScope::AllowlistType, + null: true, + experiment: { milestone: '17.6' }, + description: 'List of CI job token scopes where the project is the source.', + resolver: Resolvers::Ci::JobTokenScopeAllowlistResolver + + field :ci_job_token_auth_logs, Types::Ci::JobTokenAuthLogType.connection_type, + null: true, + experiment: { milestone: '17.6' }, + description: 'The CI Job Tokens authorization logs.', + extras: [:lookahead], + resolver: Resolvers::Ci::JobTokenAuthLogsResolver + + field :timelogs, Types::TimelogType.connection_type, + null: true, + description: 'Time logged on issues and merge requests in the project.', + extras: [:lookahead], + complexity: 5, + resolver: ::Resolvers::TimelogResolver + + field :agent_configurations, + null: true, + description: 'Agent configurations defined by the project', + resolver: ::Resolvers::Kas::AgentConfigurationsResolver + + field :cluster_agent, ::Types::Clusters::AgentType, + null: true, + description: 'Find a single cluster agent by name.', + resolver: ::Resolvers::Clusters::AgentsResolver.single + + field :cluster_agents, ::Types::Clusters::AgentType.connection_type, + extras: [:lookahead], + null: true, + description: 'Cluster agents associated with the project.', + resolver: ::Resolvers::Clusters::AgentsResolver + + field :ci_access_authorized_agents, ::Types::Clusters::Agents::Authorizations::CiAccessType.connection_type, + null: true, + description: 'Authorized cluster agents for the project through ci_access keyword.', + resolver: ::Resolvers::Clusters::Agents::Authorizations::CiAccessResolver, + authorize: :read_cluster_agent + + field :user_access_authorized_agents, ::Types::Clusters::Agents::Authorizations::UserAccessType.connection_type, + null: true, + description: 'Authorized cluster agents for the project through user_access keyword.', + resolver: ::Resolvers::Clusters::Agents::Authorizations::UserAccessResolver, + authorize: :read_cluster_agent + + field :merge_commit_template, GraphQL::Types::String, + null: true, + description: 'Template used to create merge commit message in merge requests.' + + field :squash_commit_template, GraphQL::Types::String, + null: true, + description: 'Template used to create squash commit message in merge requests.' + + field :labels, Types::LabelType.connection_type, + null: true, + description: 'Labels available on this project.', + resolver: Resolvers::LabelsResolver + + field :work_item_types, Types::WorkItems::TypeType.connection_type, + resolver: Resolvers::WorkItems::TypesResolver, + description: 'Work item types available to the project.' + + field :timelog_categories, Types::TimeTracking::TimelogCategoryType.connection_type, + null: true, + description: "Timelog categories for the project.", + experiment: { milestone: '15.3' } + + field :fork_targets, Types::NamespaceType.connection_type, + resolver: Resolvers::Projects::ForkTargetsResolver, + description: 'Namespaces in which the current user can fork the project into.' + + field :fork_details, Types::Projects::ForkDetailsType, + calls_gitaly: true, + experiment: { milestone: '15.7' }, + authorize: :read_code, + resolver: Resolvers::Projects::ForkDetailsResolver, + description: 'Details of the fork project compared to its upstream project.' + + field :forked_from, Types::ProjectType, + null: true, + description: 'Project the project was forked from.', + method: :forked_from_project + + field :branch_rules, Types::Projects::BranchRuleType.connection_type, + null: true, + description: "Branch rules configured for the project.", + resolver: Resolvers::Projects::BranchRulesResolver, + connection_extension: Gitlab::Graphql::Extensions::ForwardOnlyExternallyPaginatedArrayExtension, + max_page_size: 100, + calls_gitaly: true + + field :languages, [Types::Projects::RepositoryLanguageType], + null: true, + description: "Programming languages used in the project.", + scopes: [:api, :read_api, :ai_workflows], + calls_gitaly: true + + field :runners, Types::Ci::RunnerType.connection_type, + null: true, + resolver: ::Resolvers::Ci::ProjectRunnersResolver, + description: "Find runners visible to the current user." + + field :data_transfer, Types::DataTransfer::ProjectDataTransferType, + null: true, # disallow null once data_transfer_monitoring feature flag is rolled-out! https://gitlab.com/gitlab-org/gitlab/-/issues/391682 + resolver: Resolvers::DataTransfer::ProjectDataTransferResolver, + description: 'Data transfer data point for a specific period. ' \ + 'This is mocked data under a development feature flag.' + + field :visible_forks, Types::ProjectType.connection_type, + null: true, + experiment: { milestone: '15.10' }, + description: "Visible forks of the project." do + argument :minimum_access_level, + type: ::Types::AccessLevelEnum, + required: false, + description: 'Minimum access level.' + end + + field :flow_metrics, + ::Types::Analytics::CycleAnalytics::FlowMetrics[:project], + null: true, + description: 'Flow metrics for value stream analytics.', + method: :project_namespace, + authorize: :read_cycle_analytics, + experiment: { milestone: '15.10' } + + field :commit_references, ::Types::CommitReferencesType, + null: true, + resolver: Resolvers::Projects::CommitReferencesResolver, + experiment: { milestone: '16.0' }, + description: "Get tag names containing a given commit." + + field :autocomplete_users, + null: true, + resolver: Resolvers::AutocompleteUsersResolver, + description: 'Search users for autocompletion', + extras: [:lookahead] + + field :detailed_import_status, + ::Types::Projects::DetailedImportStatusType, + null: true, + description: 'Detailed import status of the project.', + method: :import_state + + field :value_streams, + description: 'Value streams available to the project.', + null: true, + resolver: Resolvers::Analytics::CycleAnalytics::ValueStreamsResolver + + field :ml_models, ::Types::Ml::ModelType.connection_type, + null: true, + experiment: { milestone: '16.8' }, + description: 'Finds machine learning models', + resolver: Resolvers::Ml::FindModelsResolver + + field :ml_experiments, ::Types::Ml::ExperimentType.connection_type, + null: true, + description: 'Find machine learning experiments', + resolver: ::Resolvers::Ml::FindExperimentsResolver + + field :allows_multiple_merge_request_assignees, + GraphQL::Types::Boolean, + method: :allows_multiple_merge_request_assignees?, + description: 'Project allows assigning multiple users to a merge request.', + null: false + + field :allows_multiple_merge_request_reviewers, + GraphQL::Types::Boolean, + method: :allows_multiple_merge_request_reviewers?, + description: 'Project allows assigning multiple reviewers to a merge request.', + null: false + + field :is_forked, + GraphQL::Types::Boolean, + resolver: Resolvers::Projects::IsForkedResolver, + description: 'Project is forked.', + null: false + + field :protectable_branches, + [GraphQL::Types::String], + description: 'List of unprotected branches, ignoring any wildcard branch rules.', + null: true, + calls_gitaly: true, + experiment: { milestone: '16.9' }, + authorize: :read_code + + field :project_plan_limits, Types::ProjectPlanLimitsType, + resolver: Resolvers::Projects::PlanLimitsResolver, + description: 'Plan limits for the current project.', + experiment: { milestone: '16.9' }, + null: true + + field :available_deploy_keys, Types::AccessLevels::DeployKeyType.connection_type, + resolver: Resolvers::Projects::DeployKeyResolver, + description: 'List of available deploy keys', + extras: [:lookahead], + null: true, + authorize: :admin_project do + argument :title_query, GraphQL::Types::String, + required: false, + description: 'Term by which to search deploy key titles.' + end + + field :pages_deployments, Types::PagesDeploymentType.connection_type, null: true, + resolver: Resolvers::PagesDeploymentsResolver, + connection: true, + description: "List of the project's Pages Deployments." + + field :pages_force_https, GraphQL::Types::Boolean, + null: false, + description: "Project's Pages site redirects unsecured connections to HTTPS." + + field :pages_use_unique_domain, GraphQL::Types::Boolean, + null: false, + description: "Project's Pages site uses a unique subdomain." + + field :webhook, ::Types::WebHooks::ProjectHookType, + null: true, + resolver: Resolvers::WebHooks::ProjectHooksResolver.single, + experiment: { milestone: '18.5' }, + description: 'A single project webhook.' + + field :pipeline_schedule_status_counts, Types::Ci::PipelineScheduleStatusCountType, + resolver: Resolvers::Ci::ProjectPipelineScheduleStatusCountsResolver, + description: 'Counts of pipeline schedules by status.' + + def ci_pipeline_creation_request(request_id:) + ::Ci::PipelineCreation::Requests.get_request(object, request_id) + end + + def pages_force_https + project.pages_https_only? + end + + def pages_use_unique_domain + lazy_project_settings = BatchLoader::GraphQL.for(object.id).batch do |project_ids, loader| + ::ProjectSetting.for_projects(project_ids).each do |project_setting| + loader.call(project_setting.project_id, project_setting) + end + end + + Gitlab::Graphql::Lazy.with_value(lazy_project_settings) do |settings| + (settings || object.project_setting).pages_unique_domain_enabled? + end + end + + def protectable_branches + ProtectableDropdown.new(project, :branches).protectable_ref_names + end + + def timelog_categories + object.project_namespace.timelog_categories if Feature.enabled?(:timelog_categories) + end + + def label(title:) + BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args| + LabelsFinder + .new(current_user, project: args[:key], title: titles) + .execute + .each { |label| loader.call(label.title, label) } + end + end + + def container_protection_tag_rules + # Immutable tag rules are added in EE extension + object.container_registry_protection_tag_rules.mutable + end + + { + issues: "Issues are", + merge_requests: "Merge requests are", + wiki: 'Wikis are', + snippets: 'Snippets are', + container_registry: 'Container registry is' + }.each do |feature, name_string| + field "#{feature}_enabled", GraphQL::Types::Boolean, null: true, + description: "Indicates if #{name_string} enabled for the current user" + + define_method "#{feature}_enabled" do # rubocop:disable Performance/StringIdentifierArgument + object.feature_available?(feature, context[:current_user]) # rubocop:disable Gitlab/FeatureAvailableUsage + end + end + + [:issues, :forking, :merge_requests].each do |feature| + field_name = "#{feature}_access_level" + feature_name = feature.to_s.tr("_", " ") + + field field_name, Types::ProjectFeatureAccessLevelType, + null: true, + description: "Access level required for #{feature_name} access." + + define_method field_name do + project.project_feature&.access_level(feature) + end + end + + markdown_field :description_html, null: true + + def jobs_enabled + object.feature_available?(:builds, context[:current_user]) + end + + def open_issues_count + BatchLoader::GraphQL.wrap(object.open_issues_count) if object.feature_available?(:issues, context[:current_user]) + end + + def open_merge_requests_count + return unless object.feature_available?(:merge_requests, context[:current_user]) + + BatchLoader::GraphQL.wrap(object.open_merge_requests_count) + end + + def forks_count + BatchLoader::GraphQL.wrap(object.forks_count) + end + + def is_catalog_resource # rubocop:disable Naming/PredicatePrefix + lazy_catalog_resource = BatchLoader::GraphQL.for(object.id).batch do |project_ids, loader| + ::Ci::Catalog::Resource.for_projects(project_ids).each do |catalog_resource| + loader.call(catalog_resource.project_id, catalog_resource) + end + end + + Gitlab::Graphql::Lazy.with_value(lazy_catalog_resource, &:present?) + end + + def explore_catalog_path + return unless project.catalog_resource + + Gitlab::Routing.url_helpers.explore_catalog_path(project.catalog_resource) + end + + def is_published # rubocop:disable Naming/PredicatePrefix -- disabled to match the field name. + project&.catalog_resource&.published? + end + + def statistics + Gitlab::Graphql::Loaders::BatchProjectStatisticsLoader.new(object.id).find + end + + def container_repositories_count + project.container_repositories.size + end + + def ci_pipeline_creation_inputs(ref:, fail_on_cache_miss: false) + response = ::Ci::PipelineCreation::FindPipelineInputsService.new( + current_user: context[:current_user], + project: object, + ref: ref).execute + + if response.nil? + raise_resource_not_available_error! "Failed to retrieve pipeline inputs from cache." if fail_on_cache_miss + + return + end + + raise Gitlab::Graphql::Errors::ArgumentError, response.message if response.error? + + response.payload[:inputs].all_inputs.map do |input| + { + name: input.name, + type: input.type, + default: input.default, + description: input.description, + regex: input.regex, + required?: input.required?, + options: input.options, + rules: input.rules + } + end + end + + def ci_config_variables(ref:, fail_on_cache_miss: false) + return unless ref.present? && ref_belongs_to_project?(ref) + + result = ::Ci::ListConfigVariablesService.new(object, context[:current_user]).execute(ref) + + if result.nil? && fail_on_cache_miss + raise_resource_not_available_error! "Failed to retrieve CI/CD variables from cache." + end + + return if result.nil? + + result.map do |var_key, var_config| + { key: var_key, **var_config } + end + end + + def ref_belongs_to_project?(ref) + return true if object.repository.branch_or_tag?(ref) + + return false unless object.commit(ref) + + repo = object.repository + repo.branch_names_contains(ref, limit: 1).any? || repo.tag_names_contains(ref, limit: 1).any? + end + + def job(id:) + object.commit_statuses.find(id.model_id) + rescue ActiveRecord::RecordNotFound + end + + def sast_ci_configuration + return unless Ability.allowed?(current_user, :read_code, object) + + if project.repository.empty? + raise Gitlab::Graphql::Errors::MutationError, + ApplicationController.helpers.safe_format( + _('You must %{docs_link} before using Security features.'), + docs_link: add_file_docs_link) + end + + ::Security::CiConfiguration::SastParserService.new(object).configuration + end + + def service_desk_address + return unless Ability.allowed?(current_user, :admin_issue, project) + + ::ServiceDesk::Emails.new(object).address + end + + def service_desk_enabled + ::ServiceDesk.enabled?(project) + end + + def languages + ::Projects::RepositoryLanguagesService.new(project, current_user).execute + end + + def visible_forks(minimum_access_level: nil) + if minimum_access_level.nil? + object.forks.public_or_visible_to_user(current_user) + else + return [] if current_user.nil? + + object.forks.visible_to_user_and_access_level(current_user, minimum_access_level) + end + end + + def statistics_details_paths + root_ref = project.repository.root_ref || project.default_branch_or_main + + { + repository: Gitlab::Routing.url_helpers.project_tree_url(project, root_ref), + wiki: Gitlab::Routing.url_helpers.project_wikis_pages_url(project), + build_artifacts: Gitlab::Routing.url_helpers.project_artifacts_url(project), + packages: Gitlab::Routing.url_helpers.project_packages_url(project), + snippets: Gitlab::Routing.url_helpers.project_snippets_url(project), + container_registry: Gitlab::Routing.url_helpers.project_container_registry_index_url(project) + } + end + + def max_access_level + return Gitlab::Access::NO_ACCESS if current_user.nil? + + BatchLoader::GraphQL.for(object.id).batch do |project_ids, loader| + current_user.max_member_access_for_project_ids(project_ids).each do |project_id, max_access_level| + loader.call(project_id, max_access_level) + end + end + end + + def organization_edit_path + return if project.organization.nil? + + ::Gitlab::Routing.url_helpers.edit_namespace_projects_organization_path( + project.organization, + id: project.to_param, + namespace_id: project.namespace.to_param + ) + end + + # marked_for_deletion_at is deprecated in our v5 REST API in favor of marked_for_deletion_on + # https://docs.gitlab.com/ee/api/projects.html#removals-in-api-v5 + def marked_for_deletion_on + project.marked_for_deletion_at + end + + def permanent_deletion_date + permanent_deletion_date_formatted(project) || permanent_deletion_date_formatted + end + + def web_path + ::Gitlab::Routing.url_helpers.project_path(project) + end + + def edit_path + ::Gitlab::Routing.url_helpers.edit_project_path(project) + end + + def admin_show_path + ::Gitlab::Routing.url_helpers.admin_namespace_project_path( + { id: project.to_param, namespace_id: project.namespace.to_param } + ) + end + + def admin_edit_path + ::Gitlab::Routing.url_helpers.edit_admin_namespace_project_path( + { id: project.to_param, namespace_id: project.namespace.to_param } + ) + end + + def grafana_integration + nil + end + + def only_allow_merge_if_pipeline_succeeds + project.only_allow_merge_if_pipeline_succeeds?(inherit_group_setting: true) + end + + def allow_merge_on_skipped_pipeline + project.allow_merge_on_skipped_pipeline?(inherit_group_setting: true) + end + + private + + def project + @project ||= object.respond_to?(:sync) ? object.sync : object + end + + def add_file_docs_link + ActionController::Base.helpers.link_to _('add at least one file to the repository'), + Rails.application.routes.url_helpers.help_page_url( + 'user/project/repository/_index.md', + anchor: 'add-files-to-a-repository'), + target: '_blank', + rel: 'noopener noreferrer' + end + end +end + +Types::ProjectType.prepend_mod_with('Types::ProjectType') diff --git a/spec/fixtures/corpus/gitlab/app/models/ability.rb b/spec/fixtures/corpus/gitlab/app/models/ability.rb new file mode 100644 index 0000000..880aa50 --- /dev/null +++ b/spec/fixtures/corpus/gitlab/app/models/ability.rb @@ -0,0 +1,211 @@ +# frozen_string_literal: true + +class Ability + class << self + # Given a list of users and a project this method returns the users that can + # read the given project. + def users_that_can_read_project(users, project) + DeclarativePolicy.subject_scope do + users.select { |u| allowed?(u, :read_project, project) } + end + end + + # Given a list of users and a group this method returns the users that can + # read the given group. + def users_that_can_read_group(users, group) + DeclarativePolicy.subject_scope do + users.select { |u| allowed?(u, :read_group, group) } + end + end + + # Given a list of users and a snippet this method returns the users that can + # read the given snippet. + def users_that_can_read_personal_snippet(users, snippet) + DeclarativePolicy.subject_scope do + users.select { |u| allowed?(u, :read_snippet, snippet) } + end + end + + # A list of users that can read confidential notes in a project + def users_that_can_read_internal_notes(users, note_parent) + DeclarativePolicy.subject_scope do + users.select { |u| allowed?(u, :read_internal_note, note_parent) } + end + end + + def users_that_can_read_confidential_issues(users, container) + DeclarativePolicy.subject_scope do + users.select { |u| allowed?(u, :read_confidential_issues, container) } + end + end + + # Returns an Array of Issues that can be read by the given user. + # + # issues - The issues to reduce down to those readable by the user. + # user - The User for which to check the issues + # filters - A hash of abilities and filters to apply if the user lacks this + # ability + def issues_readable_by_user(issues, user = nil, filters: {}) + issues = apply_filters_if_needed(issues, user, filters) + + DeclarativePolicy.user_scope do + issues.select { |issue| allowed?(user, :read_issue, issue) } + end + end + alias_method :work_items_readable_by_user, :issues_readable_by_user + + # Returns an Array of MergeRequests that can be read by the given user. + # + # merge_requests - MRs out of which to collect MRs readable by the user. + # user - The User for which to check the merge_requests + # filters - A hash of abilities and filters to apply if the user lacks this + # ability + def merge_requests_readable_by_user(merge_requests, user = nil, filters: {}) + merge_requests = apply_filters_if_needed(merge_requests, user, filters) + + DeclarativePolicy.user_scope do + merge_requests.select { |mr| allowed?(user, :read_merge_request, mr) } + end + end + + def feature_flags_readable_by_user(feature_flags, user = nil, filters: {}) + feature_flags = apply_filters_if_needed(feature_flags, user, filters) + + DeclarativePolicy.user_scope do + feature_flags.select { |flag| allowed?(user, :read_feature_flag, flag) } + end + end + + def allowed?(user, ability, subject = :global, **opts) + if subject.is_a?(Hash) + opts = subject + subject = :global + end + + policy = policy_for(user, subject, **opts.slice(:cache)) + + before_check(policy, ability.to_sym, user, subject, opts) + + result = case opts[:scope] + when :user + DeclarativePolicy.user_scope { policy.allowed?(ability) } + when :subject + DeclarativePolicy.subject_scope { policy.allowed?(ability) } + else + policy.allowed?(ability) + end + + if opts[:composite_identity_check] == false || + !user.respond_to?(:composite_identity_enforced?) || + !user&.composite_identity_enforced? + return result + end + + result && with_composite_identity_check(user, ability, subject, **opts) + + ensure + # TODO: replace with runner invalidation: + # See: https://gitlab.com/gitlab-org/declarative-policy/-/merge_requests/24 + # See: https://gitlab.com/gitlab-org/declarative-policy/-/merge_requests/25 + forget_runner_result(policy.runner(ability)) if policy && ability_forgetting? + end + + # Hook call right before ability check. + def before_check(policy, ability, user, subject, opts) + # See Support::AbilityCheck and Support::PermissionsCheck. + end + + # We cache in the request store by default. This can lead to unexpected + # results if abilities are re-checked after objects are modified and the + # check depends on the modified attributes. In such cases, you should pass + # `cache: false` for the second check to ensure all rules get re-evaluated. + def policy_for(user, subject = :global, cache: true) + policy_cache = cache ? ::Gitlab::SafeRequestStore.storage : {} + + DeclarativePolicy.policy_for(user, subject, cache: policy_cache) + end + + # This method is something of a band-aid over the problem. The problem is + # that some conditions may not be re-entrant, if facts change. + # (`BasePolicy#admin?` is a known offender, due to the effects of + # `admin_mode`) + # + # To deal with this we need to clear two elements of state: the offending + # conditions (selected by 'pattern') and the cached ability checks (cached + # on the `policy#runner(ability)`). + # + # Clearing the conditions (see `forget_all_but`) is fairly robust, provided + # the pattern is not _under_-selective. Clearing the runners is harder, + # since there is not good way to know which abilities any given condition + # may affect. The approach taken here (see `forget_runner_result`) is to + # discard all runner results generated during a `forgetting` block. This may + # be _under_-selective if a runner prior to this block cached a state value + # that might now be invalid. + # + # TODO: add some kind of reverse-dependency mapping in DeclarativePolicy + # See: https://gitlab.com/gitlab-org/declarative-policy/-/issues/14 + def forgetting(pattern, &block) + was_forgetting = ability_forgetting? + ::Gitlab::SafeRequestStore[:ability_forgetting] = true + keys_before = ::Gitlab::SafeRequestStore.storage.keys + + yield + ensure + ::Gitlab::SafeRequestStore[:ability_forgetting] = was_forgetting + forget_all_but(keys_before, matching: pattern) + end + + private + + def ability_forgetting? + ::Gitlab::SafeRequestStore[:ability_forgetting] + end + + def forget_all_but(keys_before, matching:) + keys_after = ::Gitlab::SafeRequestStore.storage.keys + + added_keys = keys_after - keys_before + added_keys.each do |key| + ::Gitlab::SafeRequestStore.delete(key) if key.is_a?(String) && key.start_with?('/dp') && key =~ matching + end + end + + def forget_runner_result(runner) + # TODO: add support in DP for this + # See: https://gitlab.com/gitlab-org/declarative-policy/-/issues/15 + runner.instance_variable_set(:@state, nil) + end + + def apply_filters_if_needed(elements, user, filters) + filters.each do |ability, filter| + elements = filter.call(elements) unless allowed?(user, ability) + end + + elements + end + + def with_composite_identity_check(user, ability, subject = :global, **opts) + check_user = find_user_for_composite_identity_check(user) + + return false if check_user.nil? + + allowed?( + check_user, + ability, + subject, + **opts.merge(composite_identity_check: false) + ) + end + + def find_user_for_composite_identity_check(user) + return Gitlab::Auth::Identity.find_primary_user_by_scoped_user_id(user.id) unless user.service_account? + + identity = ::Gitlab::Auth::Identity.fabricate(user) + return unless identity&.valid? + + identity.scoped_user + end + end +end + +Ability.prepend_mod_with('AbilityPrepend') diff --git a/spec/fixtures/corpus/gitlab/app/models/concerns/ci/partitionable.rb b/spec/fixtures/corpus/gitlab/app/models/concerns/ci/partitionable.rb new file mode 100644 index 0000000..10c8403 --- /dev/null +++ b/spec/fixtures/corpus/gitlab/app/models/concerns/ci/partitionable.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +module Ci + ## + # This module implements a way to set the `partition_id` value on a dependent + # resource from a parent record. + # Usage: + # + # class PipelineVariable < Ci::ApplicationRecord + # include Ci::Partitionable + # + # belongs_to :pipeline + # partitionable scope: :pipeline + # # Or + # partitionable scope: ->(record) { record.partition_value } + # + # + module Partitionable + extend ActiveSupport::Concern + include ::Gitlab::Utils::StrongMemoize + + included do + Partitionable::Testing.check_inclusion(self) + + before_validation :set_partition_id, on: :create + validates :partition_id, presence: true + + scope :in_partition, ->(id, partition_foreign_key: :partition_id) do + where(partition_id: (id.respond_to?(partition_foreign_key) ? id.try(partition_foreign_key) : id)) + end + + scope :id_and_partition_in, ->(pairs) do + return none if pairs.empty? + + where([:id, :partition_id] => pairs) + end + + def set_partition_id + return if partition_id_changed? && partition_id.present? + return unless partition_scope_value + + self.partition_id = partition_scope_value + end + + def id_and_partition_pair + [[id, partition_id]] + end + end + + def self.registered_models + Gitlab::Database::Partitioning + .registered_models + .select { |model| model < Ci::ApplicationRecord && model < Ci::Partitionable } + end + + class_methods do + def partitionable(scope:, through: nil, partitioned: false) + handle_partitionable_through(through) + handle_partitionable_scope(scope) + handle_partitionable_ddl(partitioned) + end + + private + + def handle_partitionable_through(options) + return unless options + return if Gitlab::Utils.to_boolean(ENV['DISABLE_PARTITIONABLE_SWITCH'], default: false) + + define_singleton_method(:routing_table_name) { options[:table] } + define_singleton_method(:routing_table_name_flag) { options[:flag] } + + include Partitionable::Switch + end + + def handle_partitionable_scope(scope) + define_method(:partition_scope_value) do + strong_memoize(:partition_scope_value) do + next Ci::Pipeline.current_partition_value if respond_to?(:importing?) && importing? + + record = scope.to_proc.call(self) + record.respond_to?(:partition_id) ? record.partition_id : record + end + end + end + + def handle_partitionable_ddl(partitioned) + return unless partitioned + + include ::PartitionedTable + + partitioned_by :partition_id, + strategy: :ci_sliding_list, + next_partition_if: ->(latest_partition) do + latest_partition.blank? || create_database_partition?(latest_partition) + end, + detach_partition_if: proc { false }, + analyze_interval: 3.days + end + + def create_database_partition?(database_partition) + return true if database_partition.before?(Ci::Partition::LAST_STATIC_PARTITION_VALUE) + + Ci::Partition.provisioning(database_partition.values.max).present? + end + end + end +end diff --git a/spec/fixtures/corpus/gitlab/app/models/concerns/from_set_operator.rb b/spec/fixtures/corpus/gitlab/app/models/concerns/from_set_operator.rb new file mode 100644 index 0000000..27230d1 --- /dev/null +++ b/spec/fixtures/corpus/gitlab/app/models/concerns/from_set_operator.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module FromSetOperator + # Define a high level method to more easily work with the SQL set operations + # of UNION, INTERSECT, and EXCEPT as defined by Gitlab::SQL::Union, + # Gitlab::SQL::Intersect, and Gitlab::SQL::Except respectively. + def define_set_operator(operator) + method_name = "from_#{operator.name.demodulize.downcase}" + method_name = method_name.to_sym + + raise "Trying to redefine method '#{method(method_name)}'" if methods.include?(method_name) + + define_method(method_name) do |*members, remove_duplicates: true, remove_order: true, alias_as: table_name| + members = flatten_ar_array(members) + + operator_sql = + if members.any? + operator.new(members, remove_duplicates: remove_duplicates, remove_order: remove_order).to_sql + else + where("1=0").to_sql + end + + from(Arel.sql("(#{operator_sql}) #{alias_as}")) + end + + # Array#flatten with ActiveRecord::Relation items will load the ActiveRecord::Relation. + # Therefore we need to roll our own flatten method. + unless method_defined?(:flatten_ar_array) # rubocop:disable Style/GuardClause + define_method :flatten_ar_array do |ary| + arrays = ary.dup + result = [] + + until arrays.empty? + item = arrays.shift + if item.is_a?(Array) + arrays.concat(item.dup) + else + result.push(item) + end + end + + result + end + private :flatten_ar_array + end + end +end diff --git a/spec/fixtures/corpus/gitlab/app/models/concerns/has_repository.rb b/spec/fixtures/corpus/gitlab/app/models/concerns/has_repository.rb new file mode 100644 index 0000000..30ced83 --- /dev/null +++ b/spec/fixtures/corpus/gitlab/app/models/concerns/has_repository.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +# This concern is created to handle repository actions. +# It should be include inside any object capable +# of directly having a repository, like project or snippet. +# +# It also includes `Referable`, therefore the method +# `to_reference` should be overridden in case the object +# needs any special behavior. +module HasRepository + extend ActiveSupport::Concern + include Referable + include Gitlab::ShellAdapter + include Gitlab::Utils::StrongMemoize + + delegate :base_dir, :disk_path, to: :storage + delegate :change_head, to: :repository + + def valid_repo? + repository.exists? + rescue StandardError + errors.add(:base, _('Invalid repository path')) + false + end + + def repo_exists? + repository.exists? + rescue StandardError + false + end + strong_memoize_attr :repo_exists? + + def repository_exists? + !!repository.exists? + end + + def root_ref?(branch) + repository.root_ref == branch + end + + def commit(ref = 'HEAD') + repository.commit(ref) + end + + def branch_exists?(branch) + repository.branch_exists?(branch) + end + + def ref_exists?(ref) + repository.ref_exists?(ref) + end + + def commit_by(oid:) + repository.commit_by(oid: oid) + end + + def commits_by(oids:) + repository.commits_by(oids: oids) + end + + def repository + raise NotImplementedError + end + + def storage + raise NotImplementedError + end + + def full_path + raise NotImplementedError + end + + def lfs_enabled? + false + end + + def empty_repo? + repository.empty? + end + + def default_branch + @default_branch ||= repository.empty? ? default_branch_from_preferences : repository.root_ref + end + + def default_branch=(branch_name) + return if branch_name.blank? + + return unless instance_of?(Project) && importing? + + # Store the desired default branch for later application + # This is used during project import to restore the default branch + @desired_default_branch = branch_name # rubocop:disable Gitlab/ModuleWithInstanceVariables -- no alternative without disabling cop + + # Try to apply it immediately if the repository is ready + apply_desired_default_branch + end + + # rubocop:disable Gitlab/ModuleWithInstanceVariables -- no alternative without disabling cop + def apply_desired_default_branch + return unless @desired_default_branch + return if repository.empty? + return if repository.root_ref == @desired_default_branch + + # Only change HEAD if the branch exists + if repository.branch_exists?(@desired_default_branch) + repository.change_head(@desired_default_branch) + reload_default_branch + @desired_default_branch = nil + end + rescue StandardError => e + # Log the error but don't fail the import + Import::Framework::Logger.warn("Failed to set default branch to #{@desired_default_branch}: #{e.message}") + end + # rubocop:enable Gitlab/ModuleWithInstanceVariables + + def default_branch_from_preferences + (default_branch_from_group_preferences || Gitlab::CurrentSettings.default_branch_name).presence + end + + def default_branch_from_group_preferences + return unless respond_to?(:group) + return unless group + + group.default_branch_name || group.root_ancestor.default_branch_name + end + + def reload_default_branch + @default_branch = nil # rubocop:disable Gitlab/ModuleWithInstanceVariables -- no alternative without disabling cop + + default_branch + end + + def url_to_repo + ssh_url_to_repo + end + + def ssh_url_to_repo + Gitlab::RepositoryUrlBuilder.build(repository.full_path, protocol: :ssh) + end + + def http_url_to_repo + Gitlab::RepositoryUrlBuilder.build(repository.full_path, protocol: :http) + end + + # Is overridden in EE::Project for Geo support + def lfs_http_url_to_repo(_operation = nil) + http_url_to_repo + end + + def web_url(only_path: nil) + Gitlab::UrlBuilder.build(self, only_path: only_path) + end + + def repository_size_checker + raise NotImplementedError + end + + def after_repository_change_head + reload_default_branch + + container_type = self.class.name + + run_after_commit_or_now do + Gitlab::EventStore.publish( + ::Repositories::DefaultBranchChangedEvent.new(data: { container_id: id, container_type: container_type })) + end + end + + def after_create_repository + container_type = self.class.name + container_id = id + + run_after_commit_or_now do + Gitlab::EventStore.publish( + ::Repositories::RepositoryCreatedEvent.new(data: { container_id: container_id, + container_type: container_type })) + end + end + + def after_change_head_branch_does_not_exist(branch) + # No-op (by default) + end +end diff --git a/spec/fixtures/corpus/gitlab/app/models/concerns/ignorable_columns.rb b/spec/fixtures/corpus/gitlab/app/models/concerns/ignorable_columns.rb new file mode 100644 index 0000000..fb114ee --- /dev/null +++ b/spec/fixtures/corpus/gitlab/app/models/concerns/ignorable_columns.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module IgnorableColumns + extend ActiveSupport::Concern + + ColumnIgnore = Struct.new(:remove_after, :remove_with, :remove_never) do + def safe_to_remove? + return false if remove_never + + Date.today > remove_after + end + end + + class_methods do + # Ignore database columns in a model + # + # Indicate the earliest date and release we can stop ignoring the column with +remove_after+ (a date string) and +remove_with+ (a release) + def ignore_columns(*columns, remove_after: nil, remove_with: nil, remove_never: false) + unless remove_never + raise ArgumentError, 'Please indicate when we can stop ignoring columns with remove_after (date string YYYY-MM-DD), example: ignore_columns(:name, remove_after: \'2019-12-01\', remove_with: \'12.6\')' unless remove_after && Gitlab::Regex.utc_date_regex.match?(remove_after) + raise ArgumentError, 'Please indicate in which release we can stop ignoring columns with remove_with, example: ignore_columns(:name, remove_after: \'2019-12-01\', remove_with: \'12.6\')' unless remove_with + end + + self.ignored_columns += columns.flatten # rubocop:disable Cop/IgnoredColumns + + columns.flatten.each do |column| + remove_after_date = remove_after ? Date.parse(remove_after) : nil + self.ignored_columns_details[column.to_sym] = ColumnIgnore.new(remove_after_date, remove_with, remove_never) + end + end + + alias_method :ignore_column, :ignore_columns + + def ignored_columns_details + return @ignored_columns_details if defined?(@ignored_columns_details) + + IGNORE_COLUMN_MONITOR.synchronize do + @ignored_columns_details ||= superclass.try(:ignored_columns_details)&.dup || {} + end + end + + IGNORE_COLUMN_MONITOR = Monitor.new + end +end diff --git a/spec/fixtures/corpus/gitlab/app/models/concerns/issuable.rb b/spec/fixtures/corpus/gitlab/app/models/concerns/issuable.rb new file mode 100644 index 0000000..e8710c0 --- /dev/null +++ b/spec/fixtures/corpus/gitlab/app/models/concerns/issuable.rb @@ -0,0 +1,743 @@ +# frozen_string_literal: true + +# == Issuable concern +# +# Contains common functionality shared between Issues and MergeRequests +# +# Used by Issue, MergeRequest, Epic +# +module Issuable + extend ActiveSupport::Concern + include Gitlab::SQL::Pattern + include Redactable + include CacheMarkdownField + include Participable + include Mentionable + include Milestoneable + include Subscribable + include StripAttribute + include Awardable + include Taskable + include Importable + include Transitionable + include Editable + include AfterCommitQueue + include Sortable + include CreatedAtFilterable + include UpdatedAtFilterable + include ClosedAtFilterable + include VersionedDescription + include SortableTitle + include Exportable + include ReportableChanges + include Import::HasImportSource + + TITLE_LENGTH_MAX = 255 + SEARCHABLE_FIELDS = %w[title description].freeze + MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS = 200 + + STATE_ID_MAP = { + opened: 1, + closed: 2, + merged: 3, + locked: 4 + }.with_indifferent_access.freeze + + included do + cache_markdown_field :title, pipeline: :issuable_title + cache_markdown_field :description, issuable_reference_expansion_enabled: true + + redact_field :description + + belongs_to :author, class_name: 'User' + belongs_to :updated_by, class_name: 'User' + belongs_to :last_edited_by, class_name: 'User' + + has_many :notes, -> { extending Notes::ParticipantAssociationsExtension }, as: :noteable, inverse_of: :noteable, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :notes_with_possible_mentions, -> { + Note.from_union( + [ + user, + system.with_possible_mentions + ], remove_duplicates: false + ).extending(Notes::ParticipantAssociationsExtension) + }, class_name: 'Note', as: :noteable, inverse_of: :noteable + + has_many :note_authors, -> { distinct }, through: :notes, source: :author + has_many :user_note_authors, -> { distinct.where("notes.system = false") }, through: :notes, source: :author + + has_many :label_links, as: :target, inverse_of: :target + has_many :labels, through: :label_links + has_many :todos, as: :target + + delegate :name, + :email, + :public_email, + to: :author, + allow_nil: true, + prefix: true + + validates :author, presence: true + validates :title, presence: true, length: { maximum: TITLE_LENGTH_MAX } + # we validate the description against the limit only for Issuables being created and on updates if + # the description changes to avoid breaking the existing Issuables which may have their descriptions longer + validates :description, bytesize: { maximum: -> { Gitlab::CurrentSettings.description_and_note_max_size } }, + if: :validate_description_length? + validate :validate_assignee_size_length, unless: :importing? + + before_validation :truncate_description_on_import! + + scope :authored, ->(user) { where(author_id: user) } + scope :not_authored, ->(user) { where.not(author_id: user) } + scope :recent, -> { reorder(id: :desc) } + scope :of_projects, ->(ids) { where(project_id: ids) } + scope :with_state, ->(*states) { where(state_id: states.flatten.map { |s| STATE_ID_MAP[s] }) } + scope :opened, -> { with_state(:opened) } + scope :closed, -> { with_state(:closed) } + + # rubocop:disable GitlabSecurity/SqlInjection + # The `assignee_association_name` method is not an user input. + scope :assigned, -> do + where("EXISTS (SELECT TRUE FROM #{assignee_association_name}_assignees \ + WHERE #{assignee_association_name}_id = #{assignee_association_name}s.id)") + end + scope :unassigned, -> do + where("NOT EXISTS (SELECT TRUE FROM #{assignee_association_name}_assignees \ + WHERE #{assignee_association_name}_id = #{assignee_association_name}s.id)") + end + scope :assigned_to, ->(users) do + assignees_class = reflect_on_association("#{assignee_association_name}_assignees").klass + + condition = assignees_class.where(user_id: users) + .where(Arel.sql("#{assignee_association_name}_id = #{assignee_association_name}s.id")) + where(condition.arel.exists) + end + scope :not_assigned_to, ->(users) do + assignees_class = reflect_on_association("#{assignee_association_name}_assignees").klass + + condition = assignees_class.where(user_id: users) + .where(Arel.sql("#{assignee_association_name}_id = #{assignee_association_name}s.id")) + where(condition.arel.exists.not) + end + # rubocop:enable GitlabSecurity/SqlInjection + + 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 :with_label_ids, ->(label_ids) { joins(:label_links).where(label_links: { label_id: label_ids }) } + scope :join_project, -> { joins(:project) } + scope :inc_notes_with_associations, -> { includes(notes: [:project, :author, :award_emoji]) } + scope :references_project, -> { references(:project) } + scope :non_archived, -> { join_project.merge(Project.self_and_ancestors_non_archived) } + + scope :includes_for_bulk_update, -> do + associations = %i[ + author assignees epic group labels metrics project source_project target_project + ].select do |association| + reflect_on_association(association) + end + + includes(*associations) + end + + attr_mentionable :title, pipeline: :issuable_title + attr_mentionable :description + + participant :author + participant :system_note_authors + participant :notes_for_participants + participant :assignees + + strip_attributes! :title + + class << self + def labels_hash + issue_labels = Hash.new { |h, k| h[k] = [] } + + relation = unscoped.where(id: self.select(:id)).eager_load(:labels) + relation.pluck(:id, 'labels.title').each do |issue_id, label| + issue_labels[issue_id] << label if label.present? + end + + issue_labels + end + + def locking_enabled? + false + end + + def max_number_of_assignees_or_reviewers_message + # Assignees will be included in https://gitlab.com/gitlab-org/gitlab/-/issues/368936 + format(_("total must be less than or equal to %{size}"), size: MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS) + end + end + + def issuable_type + self.class.name.underscore + end + + # We want to use optimistic lock for cases when only title or description are involved + # http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html + def locking_enabled? + will_save_change_to_title? || will_save_change_to_description? + end + + def allows_multiple_assignees? + false + end + + def allows_reviewers? + false + end + + def supports_time_tracking? + is_a?(TimeTrackable) + end + + def supports_severity? + incident_type_issue? + end + + def supports_escalation? + incident_type_issue? + end + + def incident_type_issue? + is_a?(Issue) && work_item_type&.incident? + end + + def supports_assignee? + false + end + + def supports_confidentiality? + false + end + + def supports_lock_on_merge? + false + end + + def severity + return IssuableSeverity::DEFAULT unless supports_severity? + + issuable_severity&.severity || IssuableSeverity::DEFAULT + end + + def exportable_restricted_associations + super + [:notes] + end + + def importing_or_transitioning? + importing? || transitioning? + end + + private + + def validate_description_length? + return false unless description_changed? + + previous_description = changes['description'].first + # previous_description will be nil for new records + return true if previous_description.blank? + + previous_description.bytesize <= Gitlab::CurrentSettings.description_and_note_max_size + end + + def truncate_description_on_import! + self.description = description&.slice(0, Gitlab::CurrentSettings.description_and_note_max_size) if importing? + end + + def validate_assignee_size_length + return true unless assignees.size > MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS + + errors.add :assignees, + ->(_object, _data) { self.class.max_number_of_assignees_or_reviewers_message } + end + end + + class_methods do + def participant_includes + [:author, :award_emoji, { notes_with_possible_mentions: [:author, :award_emoji] }] + end + + # Searches for records with a matching title. + # + # This method uses ILIKE on PostgreSQL. + # + # query - The search query as a String + # + # Returns an ActiveRecord::Relation. + def search(query) + fuzzy_search(query, [:title]) + end + + def gfm_autocomplete_search(query) + issuables_cte = Gitlab::SQL::CTE.new(table_name, without_order) + + search_conditions = unscoped.where( + 'title ILIKE :pattern', + pattern: "%#{sanitize_sql_like(query)}%" + ) + + if query.match?(/\A\d+\z/) + search_conditions = search_conditions.or( + unscoped.where('iid::text LIKE :pattern', pattern: "#{query}%") + ) + end + + unscoped + .with(issuables_cte.to_arel) + .from(issuables_cte.table) + .merge(search_conditions) + .order(issuables_cte.table[:id].desc) + end + + def available_states + @available_states ||= STATE_ID_MAP.slice(*available_state_names) + end + + # Available state names used to persist state_id column using state machine + # + # Override this on subclasses if different states are needed + # + # Check MergeRequest.available_states_names for example + def available_state_names + [:opened, :closed] + end + + # Searches for records with a matching title or description. + # + # This method uses ILIKE on PostgreSQL. + # + # query - The search query as a String + # matched_columns - Modify the scope of the query. 'title', 'description' or joining them with a comma. + # + # Returns an ActiveRecord::Relation. + def full_search(query, matched_columns: nil, use_minimum_char_limit: true) + if matched_columns + matched_columns = matched_columns.to_s.split(',') + matched_columns &= SEARCHABLE_FIELDS + matched_columns.map!(&:to_sym) + end + + search_columns = matched_columns.presence || [:title, :description] + + fuzzy_search(query, search_columns, use_minimum_char_limit: use_minimum_char_limit) + end + + def simple_sorts + super.except('name_asc', 'name_desc') + end + + def sort_by_attribute(method, excluded_labels: []) + sorted = + case method.to_s + when 'downvotes_desc' + then order_downvotes_desc + when 'label_priority', 'label_priority_asc' + then order_labels_priority(excluded_labels: excluded_labels) + when 'label_priority_desc' + then order_labels_priority('DESC', excluded_labels: excluded_labels) + when 'milestone', 'milestone_due_asc' + then order_milestone_due_asc + when 'milestone_due_desc' + then order_milestone_due_desc + when 'popularity_asc' + then order_upvotes_asc + when 'popularity', 'popularity_desc', 'upvotes_desc' + then order_upvotes_desc + when 'priority', 'priority_asc' + then order_due_date_and_labels_priority(excluded_labels: excluded_labels) + when 'priority_desc' + then order_due_date_and_labels_priority('DESC', excluded_labels: excluded_labels) + when 'title_asc' + then order_title_asc.with_order_id_desc + when 'title_desc' + then order_title_desc.with_order_id_desc + else order_by(method) + end + + # Break ties with the ID column for pagination + sorted.with_order_id_desc + end + + def order_due_date_and_labels_priority(direction = 'ASC', 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 = Milestone.arel_table[:due_date].minimum + milestones_due_date_with_direction = direction == 'ASC' ? milestones_due_date.asc : milestones_due_date.desc + + highest_priority_arel = Arel.sql('highest_priority') + highest_priority_arel_with_direction = direction == 'ASC' ? highest_priority_arel.asc : highest_priority_arel.desc + + order_milestone_due_asc + .order_labels_priority(excluded_labels: excluded_labels, extra_select_columns: [milestones_due_date]) + .reorder(milestones_due_date_with_direction.nulls_last, highest_priority_arel_with_direction.nulls_last) + end + + def order_labels_priority(direction = 'ASC', excluded_labels: [], extra_select_columns: [], with_cte: false) + highest_priority = highest_label_priority( + target_type: name == "WorkItem" ? "Issue" : name, + target_column: "#{table_name}.id", + project_column: "#{table_name}.#{project_foreign_key}", + excluded_labels: excluded_labels + ).to_sql + + # When using CTE make sure to select the same columns that are on the group_by clause. + # This prevents errors when ignored columns are present in the database. + issuable_columns = with_cte ? issue_grouping_columns(use_cte: with_cte) : "#{table_name}.*" + group_columns = issue_grouping_columns(use_cte: with_cte) + ["highest_priorities.label_priority"] + + extra_select_columns.unshift("highest_priorities.label_priority as highest_priority") + + highest_priority_arel = Arel.sql('highest_priority') + highest_priority_arel_with_direction = direction == 'ASC' ? highest_priority_arel.asc : highest_priority_arel.desc + + select(issuable_columns) + .select(extra_select_columns) + .from(table_name.to_s) + .joins("JOIN LATERAL(#{highest_priority}) as highest_priorities ON TRUE") + .group(group_columns) + .reorder(highest_priority_arel_with_direction.nulls_last) + end + + def with_label(title, sort = nil) + if title.is_a?(Array) && title.size > 1 + joins(:labels).where(labels: { title: title }).group(*grouping_columns(sort)) + .having("COUNT(DISTINCT labels.title) = #{title.size}") + else + joins(:labels).where(labels: { title: title }) + end + end + + def any_label(sort = nil) + if sort + joins(:label_links).group(*grouping_columns(sort)) + else + joins(:label_links).distinct + end + end + + # Includes table keys in group by clause when sorting + # preventing errors in Postgres + # + # Returns an array of Arel columns + # + def grouping_columns(sort, use_cte: false) + sort = sort.to_s + + # When the relation has been wrapped in a CTE by the search optimisation, + # Postgres cannot infer functional dependency on the CTE's columns even + # though CTE aliases `merge_requests`. Grouping by every issuable column + # satisfies PG without changing query semantics. + grouping_columns = if use_cte + attribute_names.map { |attr| arel_table[attr.to_sym] } + else + [arel_table[:id]] + end + + if %w[milestone_due_desc milestone_due_asc milestone].include?(sort) + milestone_table = Milestone.arel_table + grouping_columns << milestone_table[:id] + grouping_columns << milestone_table[:due_date] + elsif %w[merged_at_desc merged_at_asc merged_at].include?(sort) + grouping_columns << MergeRequest::Metrics.arel_table[:id] + grouping_columns << MergeRequest::Metrics.arel_table[:merged_at] + elsif %w[closed_at_desc closed_at_asc closed_at].include?(sort) + grouping_columns << MergeRequest::Metrics.arel_table[:id] + grouping_columns << MergeRequest::Metrics.arel_table[:latest_closed_at] + end + + grouping_columns + end + + # Includes all table keys in group by clause when sorting + # preventing errors in Postgres when using CTE search optimization + # + # Returns an array of Arel columns + # + def issue_grouping_columns(use_cte: false) + if use_cte + attribute_names.map { |attr| arel_table[attr.to_sym] } + else + [arel_table[:id]] + end + end + + def to_ability_name + model_name.singular + end + + def parent_class + ::Project + end + + def assignee_association_name + to_ability_name + end + end + + def state + self.class.available_states.key(state_id) + end + + def state=(value) + self.state_id = self.class.available_states[value] + end + + def resource_parent + project + end + + def assignee_or_author?(user) + author_id == user.id || assignee?(user) + end + + def assignee?(user) + # Necessary so we can preload the association and avoid N + 1 queries + if assignees.loaded? + assignees.to_a.include?(user) + else + assignees.exists?(user.id) + end + end + + def open? + opened? + end + + def overdue? + return false unless respond_to?(:due_date) + + due_date.try(:past?) || false + end + + def user_notes_count + if notes.loaded? + # Use the in-memory association to select and count to avoid hitting the db + notes.to_a.count { |note| !note.system? } + else + # do the count query + notes.user.count + end + end + + def subscribed_without_subscriptions?(user, _project) + participant?(user) + end + + # rubocop:disable Metrics/PerceivedComplexity -- Related issue: https://gitlab.com/gitlab-org/gitlab/-/issues/437679 + def hook_association_changes(old_associations) + changes = {} + + if old_assignees(old_associations) != assignees + changes[:assignees] = [old_assignees(old_associations).map(&:hook_attrs), assignees.map(&:hook_attrs)] + end + + if old_labels(old_associations) != labels + changes[:labels] = [old_labels(old_associations).map(&:hook_attrs), labels.map(&:hook_attrs)] + end + + if supports_severity? && old_severity(old_associations) != severity + changes[:severity] = [old_severity(old_associations), severity] + end + + if is_a?(MergeRequest) && old_target_branch(old_associations) != target_branch + changes[:target_branch] = [old_target_branch(old_associations), target_branch] + end + + if supports_escalation? && escalation_status && + old_escalation_status(old_associations) != escalation_status.status_name + changes[:escalation_status] = [old_escalation_status(old_associations), escalation_status.status_name] + end + + if respond_to?(:total_time_spent) && old_total_time_spent(old_associations) != total_time_spent + changes[:total_time_spent] = [old_total_time_spent(old_associations), total_time_spent] + changes[:time_change] = [old_time_change(old_associations), time_change] + end + + changes + end + # rubocop:enable Metrics/PerceivedComplexity + + def reviewers_hook_attrs(re_requested_reviewer_id: nil) + return [] unless allows_reviewers? + + merge_request_reviewers.includes(:reviewer).map do |mr_reviewer| + attrs = mr_reviewer.reviewer.hook_attrs.merge(state: mr_reviewer.state) + attrs[:re_requested] = !!(re_requested_reviewer_id && mr_reviewer.reviewer.id == re_requested_reviewer_id) + attrs + end + end + + def hook_reviewer_changes(old_associations) + changes = {} + + re_requested_reviewer_id = old_associations.fetch(:re_requested_reviewer_id, nil) + old_reviewer_attrs = old_associations.fetch(:reviewers_hook_attrs, []) + current_reviewer_attrs = reviewers_hook_attrs(re_requested_reviewer_id: re_requested_reviewer_id) + + changes[:reviewers] = [old_reviewer_attrs, current_reviewer_attrs] if old_reviewer_attrs != current_reviewer_attrs + + changes + end + + def to_hook_data(user, old_associations: {}, action: nil, actioned_at: nil) + changes = reportable_changes + + if old_associations.present? + changes.merge!(hook_association_changes(old_associations)) + changes.merge!(hook_reviewer_changes(old_associations)) if allows_reviewers? + end + + Gitlab::DataBuilder::Issuable.new(self).build( + user: user, + changes: changes, + action: action, + actioned_at: actioned_at) + end + + def labels_array + labels.to_a + end + + def label_names + labels.order('title ASC').pluck(:title) + end + + def labels_hook_attrs + labels.map(&:hook_attrs) + end + + def allows_scoped_labels? + false + end + + # Convert this Issuable class name to a format usable by Ability definitions + # + # Examples: + # + # issuable.class # => MergeRequest + # issuable.to_ability_name # => "merge_request" + def to_ability_name + self.class.to_ability_name + end + + # Returns a Hash of attributes to be used for Twitter card metadata + def card_attributes + { + 'Author' => author.try(:name), + 'Assignee' => assignee_list + } + end + + def assignee_list + assignees.map(&:name).to_sentence + end + + def assignee_username_list + assignees.map(&:username).join(',') + end + + def system_note_authors + User.id_in(notes.system.select(:author_id).distinct) + end + + def notes_for_participants + notes_with_associations(notes_with_possible_mentions).limit(Noteable::MAX_NOTES_LIMIT * 2) + end + + def notes_with_associations(relation = notes) + # If A has_many Bs, and B has_many Cs, and you do + # `A.includes(b: :c).each { |a| a.b.includes(:c) }`, sadly ActiveRecord + # will do the inclusion again. So, we check if all notes in the relation + # already have their authors loaded (possibly because the scope + # `inc_notes_with_associations` was used) and skip the inclusion if that's + # the case. + includes = [] + includes << :author unless relation.authors_loaded? + includes << :award_emoji unless relation.award_emojis_loaded? + + if persisted? && includes.any? + relation.includes(includes) + else + relation + end + end + + def updated_tasks + Taskable.get_updated_tasks( + old_content: previous_changes['description'].first, + new_content: description + ) + end + + ## + # Method that checks if issuable can be moved to another project. + # + # Should be overridden if issuable can be moved. + # + def can_move?(*) + false + end + + ## + # Override in issuable specialization + # + def first_contribution? + false + end + + ## + # Overridden in MergeRequest + # + def draftless_title_changed(old_title) + old_title != title + end + + def supports_health_status? + false + end + + def old_assignees(assoc) + @_old_assignees ||= assoc.fetch(:assignees, assignees) + end + + def old_labels(assoc) + @_old_labels ||= assoc.fetch(:labels, labels) + end + + def old_severity(assoc) + @_old_severity ||= assoc.fetch(:severity, severity) + end + + def old_target_branch(assoc) + @_old_target_branch ||= assoc.fetch(:target_branch, target_branch) + end + + def old_escalation_status(assoc) + @_old_escalation_status ||= assoc.fetch(:escalation_status, escalation_status.status_name) + end + + def old_total_time_spent(assoc) + @_old_total_time_spent ||= assoc.fetch(:total_time_spent, total_time_spent) + end + + def old_time_change(assoc) + @_old_time_change ||= assoc.fetch(:time_change, time_change) + end +end + +Issuable.prepend_mod_with('Issuable') diff --git a/spec/fixtures/corpus/gitlab/app/models/merge_request.rb b/spec/fixtures/corpus/gitlab/app/models/merge_request.rb new file mode 100644 index 0000000..abdee38 --- /dev/null +++ b/spec/fixtures/corpus/gitlab/app/models/merge_request.rb @@ -0,0 +1,3304 @@ +# frozen_string_literal: true + +class MergeRequest < ApplicationRecord + extend Gitlab::Cache::RequestCache + + include AtomicInternalId + include IidRoutes + include Issuable + include Noteable + include Referable + include Presentable + include TimeTrackable + include ManualInverseAssociation + include EachBatch + include ThrottledTouch + include Gitlab::Utils::StrongMemoize + include LabelEventable + include ReactiveCaching + include FromUnion + include DeprecatedAssignee + include ShaAttribute + include MilestoneEventable + include StateEventable + include Approvable + include IdInOrdered + include Todoable + include Spammable + include Ci::Partitionable::AssociationFinder + + extend ::Gitlab::Utils::Override + + sha_attribute :squash_commit_sha + sha_attribute :merge_ref_sha + + self.reactive_cache_key = ->(model) { [model.project.id, model.iid] } + self.reactive_cache_refresh_interval = 10.minutes + self.reactive_cache_lifetime = 10.minutes + self.reactive_cache_work_type = :no_dependency + + SORTING_PREFERENCE_FIELD = :merge_requests_sort + CI_MERGE_REQUEST_DESCRIPTION_MAX_LENGTH = 2700 + MERGE_LEASE_TIMEOUT = 15.minutes.to_i + + belongs_to :target_project, class_name: "Project" + belongs_to :source_project, class_name: "Project" + belongs_to :merge_user, class_name: "User" + + has_internal_id :iid, scope: :target_project, track_if: -> { !importing? }, + init: ->(mr, scope) do + if mr + mr.target_project&.merge_requests&.maximum(:iid) + elsif scope[:project] + where(target_project: scope[:project]).maximum(:iid) + end + end + + has_many :merge_request_diffs, + -> { regular }, inverse_of: :merge_request + has_many :merge_request_context_commits, inverse_of: :merge_request + has_many :merge_request_context_commit_diff_files, through: :merge_request_context_commits, source: :diff_files + + has_many :generated_ref_commits, + primary_key: [:iid, :target_project_id], + class_name: 'MergeRequests::GeneratedRefCommit', + foreign_key: [:merge_request_iid, :project_id], + inverse_of: :merge_request + + has_one :merge_request_diff, + -> { regular.order('merge_request_diffs.id DESC') }, inverse_of: :merge_request + # Enabling inverse relationship causes previously cached merge_head_diff to be returned as merge_request_diff + # MR: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/214072 + has_one :merge_head_diff, + -> { merge_head }, inverse_of: false, class_name: 'MergeRequestDiff' + has_one :cleanup_schedule, inverse_of: :merge_request + has_one :predictions, inverse_of: :merge_request + delegate :suggested_reviewers, to: :predictions + + has_one :merge_schedule, class_name: 'MergeRequests::MergeSchedule', inverse_of: :merge_request + has_one :metrics, inverse_of: :merge_request, autosave: true + # NOTE: We do not want to autosave merge_data as this is saved manually + has_one :merge_data, class_name: 'MergeRequests::MergeData', inverse_of: :merge_request, foreign_key: :merge_request_id, autosave: false + + belongs_to :latest_merge_request_diff, class_name: 'MergeRequestDiff' + manual_inverse_association :latest_merge_request_diff, :merge_request + + # method overridden in EE + def suggested_reviewer_users + User.none + end + + # This is the same as latest_merge_request_diff unless: + # 1. There are arguments - in which case we might be trying to force-reload. + # 2. This association is already loaded. + # 3. The latest diff does not exist. + # 4. It doesn't have any merge_request_diffs - it returns an empty MergeRequestDiff + # + # The second one in particular is important - MergeRequestDiff#merge_request + # is the inverse of MergeRequest#merge_request_diff, which means it may not be + # the latest diff, because we could have loaded any diff from this particular + # MR. If we haven't already loaded a diff, then it's fine to load the latest. + def merge_request_diff + fallback = latest_merge_request_diff unless association(:merge_request_diff).loaded? + + fallback || super || MergeRequestDiff.new(merge_request_id: id) + end + + belongs_to :head_pipeline, class_name: "Ci::Pipeline", inverse_of: :merge_requests_as_head_pipeline + partitionable_belongs_to_loader :head_pipeline + + has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent + + has_many :merge_request_issues, + class_name: 'MergeRequestsClosingIssues', + inverse_of: :merge_request, + dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent + + has_many :merge_request_closing_issues, + -> { link_type_closes }, + class_name: 'MergeRequestsClosingIssues', + inverse_of: :merge_request + + has_one :approval_metrics, + class_name: 'MergeRequest::ApprovalMetrics', + inverse_of: :merge_request + + has_many :cached_closes_issues, + through: :merge_request_closing_issues, + source: :issue + has_many :pipelines_for_merge_request, foreign_key: 'merge_request_id', class_name: 'Ci::Pipeline', inverse_of: :merge_request + has_many :suggestions, through: :notes + has_many :unresolved_notes, -> { unresolved }, as: :noteable, class_name: 'Note', inverse_of: :noteable + + has_many :merge_request_assignees + has_many :assignees, class_name: "User", through: :merge_request_assignees + has_many :merge_request_reviewers + has_many :reviewers, class_name: "User", through: :merge_request_reviewers + has_many :user_mentions, class_name: "MergeRequestUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent + + has_many :deployment_merge_requests + + # These are deployments created after the merge request has been merged, and + # the merge request was tracked explicitly (instead of implicitly using a CI + # build). + has_many :deployments, + through: :deployment_merge_requests + + has_many :draft_notes + has_many :reviews, inverse_of: :merge_request + has_many :reviewed_by_users, -> { distinct }, through: :reviews, source: :author + has_many :created_environments, class_name: 'Environment', foreign_key: :merge_request_id, inverse_of: :merge_request + has_many :assignment_events, class_name: 'ResourceEvents::MergeRequestAssignmentEvent', inverse_of: :merge_request + + KNOWN_MERGE_PARAMS = [ + :auto_merge_strategy, + :should_remove_source_branch, + :force_remove_source_branch, + :commit_message, + :squash_commit_message, + :sha, + :skip_ci + ].freeze + serialize :merge_params, type: Hash # rubocop:disable Cop/ActiveRecordSerialize + + before_validation :set_draft_status + + after_create :ensure_merge_request_diff, unless: :skip_ensure_merge_request_diff + after_update :clear_memoized_shas + after_update :reload_diff_if_branch_changed + after_save :keep_around_commit, unless: :importing? + after_save :save_merge_data_changes + after_commit :enqueue_keep_around_commit, unless: :importing? + after_commit :ensure_metrics!, on: [:create, :update], unless: :importing? + after_commit :expire_etag_cache, unless: :importing? + + after_find :preload_branches + + # When this attribute is true some MR validation is ignored + # It allows us to close or modify broken merge requests + attr_accessor :allow_broken, :skip_branch_existence_check + + # Temporary flag to skip merge_request_diff creation on create. + # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/100390 + attr_accessor :skip_ensure_merge_request_diff + + # Temporary fields to store compare vars + # when creating new merge request + attr_accessor :can_be_created, :compare_commits, :diff_options, :compare + + # Flag to skip triggering mergeRequestMergeStatusUpdated GraphQL subscription. + attr_accessor :skip_merge_status_trigger + + attr_spammable :title, spam_title: true + attr_spammable :description, spam_description: true + + participant :reviewers + + # Keep states definition to be evaluated before the state_machine block to + # avoid spec failures. If this gets evaluated after, the `merged` and `locked` + # states (which are overridden) can be nil. + # + def self.available_state_names + super + [:merged, :locked] + end + + state_machine :state_id, initial: :opened, initialize: false do + event :close do + transition [:opened] => :closed + end + + event :mark_as_merged do + transition [:opened, :locked] => :merged + end + + event :reopen do + transition closed: :opened + end + + event :lock_mr do + transition [:opened] => :locked + end + + event :unlock_mr do + transition locked: :opened + end + + before_transition any => [:opened, :merged] do |merge_request| + merge_request.merge_jid = nil + end + + before_transition any => :closed do |merge_request| + merge_request.merge_error = nil + end + + before_transition any => :merged do |merge_request| + merge_request.merge_error = nil + merge_request.metrics.first_contribution = true if merge_request.first_contribution? + end + + after_transition any => :opened do |merge_request| + merge_request.run_after_commit do + UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id) + end + end + + state :opened, value: MergeRequest.available_states[:opened] + state :closed, value: MergeRequest.available_states[:closed] + state :merged, value: MergeRequest.available_states[:merged] + state :locked, value: MergeRequest.available_states[:locked] + end + + # NOTE: If you modify this state machine (transitions or callbacks), you must also + # review and update the batch method below (batch_mark_as_unchecked). + # Batch methods bypass callbacks for performance, so any new callbacks may need + # to be handled manually. + # + # See: https://gitlab.com/gitlab-org/gitlab/-/work_items/546431 + state_machine :merge_status, initial: :unchecked do + event :mark_as_preparing do + transition [:unchecked, :can_be_merged] => :preparing + end + + event :mark_as_unchecked do + transition [:preparing, :can_be_merged, :checking] => :unchecked + transition [:cannot_be_merged, :cannot_be_merged_rechecking] => :cannot_be_merged_recheck + end + + event :mark_as_checking do + transition unchecked: :checking + transition cannot_be_merged_recheck: :cannot_be_merged_rechecking + end + + event :mark_as_mergeable do + transition [:unchecked, :cannot_be_merged_recheck, :checking, :cannot_be_merged_rechecking] => :can_be_merged + end + + event :mark_as_unmergeable do + transition [:unchecked, :cannot_be_merged_recheck, :checking, :cannot_be_merged_rechecking] => :cannot_be_merged + end + + state :preparing + state :unchecked + state :cannot_be_merged_recheck + state :checking + state :cannot_be_merged_rechecking + state :can_be_merged + state :cannot_be_merged + + around_transition do |merge_request, transition, block| + Gitlab::Timeless.timeless(merge_request, &block) + end + + after_transition any => [:unchecked, :cannot_be_merged_recheck, :can_be_merged, :cannot_be_merged] do |merge_request, transition| + next if merge_request.skip_merge_status_trigger + + merge_request.run_after_commit do + GraphqlTriggers.merge_request_merge_status_updated(merge_request) + end + end + + after_transition any => :can_be_merged do |merge_request, transition| + merge_request.run_after_commit do + # We find it instead of reloading in order not to clear any other attributes + next unless MergeRequest.where(id: merge_request.id, auto_merge_enabled: true).exists? + + AutoMergeProcessWorker.perform_async({ 'merge_request_id' => merge_request.id }) + end + end + + # rubocop: disable CodeReuse/ServiceClass + after_transition [:unchecked, :checking] => :cannot_be_merged do |merge_request, _| + merge_request.run_after_commit do + next unless merge_request.notify_conflict? + + publish_code_conflict_event + NotificationService.new.merge_request_unmergeable(merge_request) + TodoService.new.merge_request_became_unmergeable(merge_request) + end + end + # rubocop: enable CodeReuse/ServiceClass + + def check_state?(merge_status) + [:unchecked, :cannot_be_merged_recheck, :checking, :cannot_be_merged_rechecking].include?(merge_status.to_sym) + end + + # rubocop: disable Style/SymbolProc + before_transition { |merge_request| merge_request.enable_transitioning } + + after_transition { |merge_request| merge_request.disable_transitioning } + # rubocop: enable Style/SymbolProc + end + + # NOTE: Batch transition merge_status to unchecked/cannot_be_merged_recheck. + # This bypasses state machine callbacks for performance but mimics their behavior. + # + # Transitions mirror the `mark_as_unchecked` event defined above. + # Process batch updates in batches as there can be many target merge requests. + def self.batch_mark_as_unchecked(mr_ids, mrs_to_trigger: [], batch_size: 1000) + return if mr_ids.blank? + + mr_ids.each_slice(batch_size) do |batch_ids| + where(id: batch_ids) + .where(merge_status: [:preparing, :can_be_merged, :checking]) + .update_all(merge_status: :unchecked) + + where(id: batch_ids) + .where(merge_status: [:cannot_be_merged, :cannot_be_merged_rechecking]) + .update_all(merge_status: :cannot_be_merged_recheck) + end + + mrs_to_trigger.each do |mr| + GraphqlTriggers.merge_request_merge_status_updated(mr.reset) + end + end + + # NOTE: Batch transition merge_status to checking/cannot_be_merged_rechecking. + # This bypasses state machine callbacks for performance but mimics their behavior. + # + # Transitions mirror the `mark_as_checking` event defined above. + def self.batch_mark_as_checking(mr_ids, batch_size: 1000) + return if mr_ids.blank? + + mr_ids.each_slice(batch_size) do |batch_ids| + where(id: batch_ids) + .where(merge_status: :unchecked) + .update_all(merge_status: :checking) + + where(id: batch_ids) + .where(merge_status: :cannot_be_merged_recheck) + .update_all(merge_status: :cannot_be_merged_rechecking) + end + end + + def self.batch_clear_merge_error(mr_ids, batch_size: 1000) + return if mr_ids.blank? + + mr_ids.each_slice(batch_size) do |batch_ids| + where(id: batch_ids) + .where.not(merge_error: nil) + .update_all(merge_error: nil) + end + end + + # Returns current merge_status except it returns `cannot_be_merged_rechecking` as `checking` + # to avoid exposing unnecessary internal state + def public_merge_status + cannot_be_merged_rechecking? || preparing? ? 'checking' : merge_status + end + + validates :source_project, presence: true, unless: [:allow_broken, :importing?, :closed_or_merged_without_fork?] + validates :source_branch, presence: true + validates :target_project, presence: true + validates :target_branch, presence: true + validates :merge_user, presence: true, if: :auto_merge_enabled?, unless: :importing? + validate :validate_branches, unless: [ + :allow_broken, + :importing_or_transitioning?, + :closed_or_merged_without_fork? + ] + validate :validate_fork, unless: [:closed_or_merged_without_fork?, :allow_broken] + validate :validate_branch_existence, on: :create, unless: [ + :allow_broken, + :skip_branch_existence_check, + :importing_or_transitioning?, + :closed_or_merged_without_fork? + ] + validate :validate_target_project, on: :create, unless: :importing_or_transitioning? + validate :validate_reviewer_size_length, unless: :importing_or_transitioning? + + scope :by_source_or_target_branch, ->(branch_name) do + where("source_branch = :branch OR target_branch = :branch", branch: branch_name) + end + + scope :by_milestone, ->(milestone) { where(milestone_id: milestone) } + scope :of_projects, ->(ids) { where(target_project_id: ids) } + scope :from_project, ->(project) { where(source_project_id: project) } + scope :from_fork, -> { where('source_project_id <> target_project_id') } + scope :from_and_to_forks, ->(project) do + from_fork.where('source_project_id = ? OR target_project_id = ?', project.id, project.id) + end + scope :merged, -> { with_state(:merged) } + scope :non_closed, -> { where.not(state_id: available_states[:closed]) } + scope :open_and_closed, -> { with_state(:opened, :closed) } + scope :drafts, -> { where(draft: true) } + scope :from_source_branches, ->(branches) { where(source_branch: branches) } + scope :by_sorted_source_branches, ->(branches) do + from_source_branches(branches) + .order(source_branch: :asc, id: :desc) + end + scope :including_target_project, -> do + includes(:target_project) + end + + scope :by_commit_sha, ->(target_project_id, sha) do + if Feature.enabled?(:commit_sha_scope_logger, type: :ops) # rubocop:disable Gitlab/FeatureFlagWithoutActor -- pre-existing ff, ops flag + Gitlab::AppLogger.info( + event: 'merge_request_by_commit_sha_call', + message: "MergeRequest.by_commit_sha called via #{caller_locations.reject { |line| line.path.include?('/gems/') }.first}" + ) + end + + where('EXISTS (?)', MergeRequestDiff.select(1).where('merge_requests.latest_merge_request_diff_id = merge_request_diffs.id').by_commit_sha(target_project_id, sha)).reorder(nil) + end + scope :by_merge_commit_sha, ->(sha) do + where(merge_commit_sha: sha) + end + scope :by_squash_commit_sha, ->(sha) do + where(squash_commit_sha: sha) + end + scope :by_merged_commit_sha, ->(sha) do + where(merged_commit_sha: sha) + end + scope :by_merged_or_merge_or_squash_commit_sha, ->(sha) do + from_union([by_squash_commit_sha(sha), by_merge_commit_sha(sha), by_merged_commit_sha(sha)]) + end + scope :by_generated_ref_commit_sha, ->(sha) do + joins(:generated_ref_commits).where(p_generated_ref_commits: { commit_sha: sha }) + end + scope :by_related_commit_sha, ->(project, sha) do + if Feature.enabled?(:commit_sha_scope_logger, type: :ops) # rubocop:disable Gitlab/FeatureFlagWithoutActor -- pre-existing ff, ops flag + Gitlab::AppLogger.info( + event: 'merge_request_by_related_commit_sha_call', + message: "MergeRequest.by_related_commit_sha called via #{caller_locations.reject { |line| line.path.include?('/gems/') }.first}" + ) + end + + from_union( + [ + by_commit_sha(project.id, sha), + by_squash_commit_sha(sha), + by_merge_commit_sha(sha), + by_merged_commit_sha(sha), + by_generated_ref_commit_sha(sha) + ] + ) + end + scope :by_latest_merge_request_diffs, ->(merge_request_diffs) do + where(latest_merge_request_diff_id: merge_request_diffs) + end + scope :join_project, -> { joins(:target_project) } + scope :join_metrics, ->(target_project_id = nil) do + # Do not join the relation twice + return self if self.arel.join_sources.any? { |join| join.left.try(:name).eql?(MergeRequest::Metrics.table_name) } + + query = joins(:metrics) + + if !target_project_id && self.where_values_hash["target_project_id"] + target_project_id = self.where_values_hash["target_project_id"] + query = query.unscope(where: :target_project_id) + end + + project_condition = if target_project_id + MergeRequest::Metrics.arel_table[:target_project_id].eq(target_project_id) + else + MergeRequest.arel_table[:target_project_id].eq(MergeRequest::Metrics.arel_table[:target_project_id]) + end + + query.where(project_condition) + end + scope :references_project, -> { references(:target_project) } + scope :with_api_entity_associations, -> { + preload_routables.preload( + :assignees, :author, :unresolved_notes, :labels, :milestone, :timelogs, :reviewers, :merge_schedule, :merge_user, + :latest_merge_request_diff, target_project: [:project_feature, :project_setting, + { namespace: :namespace_settings_with_ancestors_inherited_settings }], + metrics: [:latest_closed_by, :merged_by]) + } + + scope :with_csv_entity_associations, -> { preload(:assignees, :approved_by_users, :author, :milestone, metrics: [:merged_by]) } + + scope :by_target_branch_wildcard, ->(wildcard_branch_name) do + where("target_branch LIKE ?", ApplicationRecord.sanitize_sql_like(wildcard_branch_name).tr('*', '%')) + end + scope :by_target_branch, ->(branch_name) { where(target_branch: branch_name) } + scope :by_source_branch, ->(branch_name) { where(source_branch: branch_name) } + scope :order_by_metric, ->(metric, direction) do + order = order_by_metric_column(metric, direction) + order.apply_cursor_conditions(join_metrics).order(order) + end + scope :reorder_by_metric, ->(metric, direction) do + order = order_by_metric_column(metric, direction) + order.apply_cursor_conditions(join_metrics).reorder(order) + end + + scope :order_merged_at_asc, -> { order_by_metric(:merged_at, 'ASC') } + scope :order_merged_at_desc, -> { order_by_metric(:merged_at, 'DESC') } + scope :order_closed_at_asc, -> { order_by_metric(:latest_closed_at, 'ASC') } + scope :order_closed_at_desc, -> { order_by_metric(:latest_closed_at, 'DESC') } + scope :preload_source_project, -> { preload(:source_project) } + scope :preload_target_project, -> { preload(:target_project) } + scope :preload_target_project_with_namespace, -> { preload(target_project: [:namespace]) } + scope :preload_routables, -> do + preload( + target_project: [:route, { namespace: :route }], + source_project: [:route, { namespace: :route }] + ) + end + scope :preload_author, -> { preload(:author) } + scope :preload_approved_by_users, -> { preload(:approved_by_users) } + scope :preload_metrics, ->(relation) { preload(metrics: relation) } + scope :preload_merge_data, ->(project) do + return all unless Feature.enabled?(:merge_requests_merge_data_dual_write, project) + + preload(:merge_data) + end + + scope :preload_project_and_latest_diff, -> { preload(:source_project, :target_project, :latest_merge_request_diff) } + + scope :preload_latest_diff_commit, ->(project) do + preload(latest_merge_request_diff: { + merge_request_diff_commits: [{ + merge_request_commits_metadata: [:commit_author, :committer] + }] + }) + end + + scope :preload_milestoneish_associations, -> { preload_routables.preload(:assignees, :labels) } + + scope :with_web_entity_associations, -> do + preload(:author, :labels, target_project: [:project_feature, :route, { group: [:route, :parent], namespace: :route }]) + end + + scope :with_auto_merge_enabled, -> do + with_state(:opened).where(auto_merge_enabled: true) + end + + scope :including_metrics, -> do + includes(:metrics) + end + + scope :with_jira_issue_keys, -> { where('title ~ :regex OR merge_requests.description ~ :regex', regex: Gitlab::Regex.jira_issue_key_regex(expression_escape: '\m').source) } + + scope :review_requested, -> do + where(reviewers_subquery.exists) + end + + scope :no_review_requested, -> do + where(reviewers_subquery.exists.not) + end + + scope :with_review_states_or_no_reviewer, ->(states) do + where( + reviewers_subquery + .where(Arel::Table.new("#{to_ability_name}_reviewers")[:state].in(states)) + .exists + ).or( + where( + reviewers_subquery.exists.not + ) + ) + end + + scope :no_review_requested_or_only_user, ->(user) do + reviewers = Arel::Table.new("#{to_ability_name}_reviewers") + + where.not( + MergeRequestReviewer + .where(reviewers[:merge_request_id].eq(Arel.sql('merge_requests.id'))) + .where(reviewers[:user_id].not_eq(user.id)) + .arel + .exists + ) + end + + scope :with_valid_or_no_reviewers, ->(states, user) do + reviewers = Arel::Table.new("#{to_ability_name}_reviewers") + + valid_states_expr = reviewers_subquery + .where(reviewers[:user_id].not_eq(user.id)) + .where(reviewers[:state].in(states)) + .exists + + no_reviewers_expr = reviewers_subquery.exists.not + + only_user_expr = reviewers_subquery + .where(reviewers[:user_id].not_eq(user.id)) + .exists + .not + + where( + valid_states_expr + .or(no_reviewers_expr) + .or(only_user_expr.and(valid_states_expr.not)) + ) + end + + scope :review_requested_to, ->(user, states = nil) do + scope = reviewers_subquery.where(Arel::Table.new("#{to_ability_name}_reviewers")[:user_id].eq(user.id)) + scope = scope.where(Arel::Table.new("#{to_ability_name}_reviewers")[:state].in(states)) if states + + where(scope.exists) + end + + scope :no_review_requested_to, ->(user) do + where( + reviewers_subquery + .where(Arel::Table.new("#{to_ability_name}_reviewers")[:user_id].eq(user.id)) + .exists + .not + ) + end + + scope :review_states, ->(states, ignored_reviewer = nil) do + reviewers = Arel::Table.new("#{to_ability_name}_reviewers") + + scope = reviewers_subquery.where(reviewers[:state].in(states)) + scope = scope.where(reviewers[:user_id].not_eq(ignored_reviewer.id)) if ignored_reviewer + + where(scope.exists) + end + + scope :not_only_reviewer, ->(user) do + reviewers = Arel::Table.new("#{to_ability_name}_reviewers") + + subquery = reviewers + .project(Arel.sql('1')) + .where(reviewers[:merge_request_id].eq(Arel.sql('merge_requests.id'))) + .group(reviewers[:merge_request_id]) + .having(reviewers[:id].count.eq(1)) + .having(reviewers[:user_id].maximum.eq(user.id)) + + review_requested.where.not(subquery.exists) + end + + scope :no_review_states, ->(states, ignored_reviewer = nil) do + reviewers = Arel::Table.new("#{to_ability_name}_reviewers") + + scope = reviewers_subquery + scope = scope.where(reviewers[:user_id].not_eq(ignored_reviewer.id)) if ignored_reviewer + + forbidden = scope.clone.where(reviewers[:state].in(states)) + + where(scope.exists).where(forbidden.exists.not) + end + + scope :assignee_or_reviewer, ->(user, assigned_review_states, reviewer_state) do + assigned_to_scope = assigned_to(user) + assigned_to_scope = assigned_to_scope.review_states(assigned_review_states) if assigned_review_states + + from_union( + assigned_to_scope, + review_requested_to(user, reviewer_state) + ) + end + + scope :author_or_assignee, ->(user, review_states = nil) do + authored = where(author_id: user) + authored = authored.review_states(review_states) if review_states + + assigned = joins(:merge_request_assignees).where(merge_request_assignees: { user_id: user }) + assigned = assigned.review_states(review_states) if review_states + + from("(#{from_union([authored, assigned], remove_duplicates: true).to_sql}) merge_requests") + end + + scope :without_hidden, -> { + # We add `+ 0` to the author_id to make the query planner use a nested loop and prevent + # loading of all banned user IDs for certain queries + where_not_exists(Users::BannedUser.where('banned_users.user_id = (merge_requests.author_id + 0)')) + } + + scope :merged_without_state_event_source, -> { + joins(:resource_state_events) + .merge(ResourceStateEvent.merged_with_no_event_source) + } + + scope :by_blob_path, ->(path) do + joins(latest_merge_request_diff: :merge_request_diff_files) + .where(merge_request_diff_files: { old_path: path }) + end + + # Optimization support, see: https://docs.gitlab.com/development/database/efficient_in_operator_queries/ + scope :in_optimization_array_mapping_scope, ->(id_expression) { + where(arel_table[:target_project_id].eq(id_expression)) + } + scope :in_optimization_finder_query, ->(_created_at_expression, id_expression) { + where(arel_table[:id].eq(id_expression)) + } + + scope :with_closed_between, ->(closed_after = nil, closed_before = nil) do + return all unless closed_after || closed_before + + metrics_scope = MergeRequest::Metrics + .where(MergeRequest::Metrics.arel_table[:merge_request_id].eq(arel_table[:id])) + .where(MergeRequest::Metrics.arel_table[:target_project_id].eq(arel_table[:target_project_id])) + + metrics_scope = metrics_scope.closed_after(closed_after) if closed_after + metrics_scope = metrics_scope.closed_before(closed_before) if closed_before + + where(metrics_scope.select(1).arel.exists) + end + + def self.total_time_to_merge + join_metrics + .where( + # Replicating the scope MergeRequest::Metrics.with_valid_time_to_merge + MergeRequest::Metrics.arel_table[:merged_at].gt( + MergeRequest::Metrics.arel_table[:created_at] + ) + ) + .pick(MergeRequest::Metrics.time_to_merge_expression) + end + + alias_method :project, :target_project + alias_method :project=, :target_project= + alias_attribute :project_id, :target_project_id + + # Currently, `merge_when_pipeline_succeeds` column is used as a flag + # to check if _any_ auto merge strategy is activated on the merge request. + # Today, we have multiple strategies and MWPS is one of them. + # we'd eventually rename the column for avoiding confusions, but in the mean time + # please use `auto_merge_enabled` alias instead of `merge_when_pipeline_succeeds`. + alias_attribute :auto_merge_enabled, :merge_when_pipeline_succeeds + alias_attribute :issuing_parent_id, :target_project_id + alias_method :issuing_parent, :target_project + + delegate :builds_with_coverage, to: :head_pipeline, prefix: true, allow_nil: true + + RebaseLockTimeout = Class.new(StandardError) + + def self.reference_prefix + '!' + end + + def self.order_by_metric_column(metric, direction) + column_expression = MergeRequest::Metrics.arel_table[metric] + column_expression_with_direction = direction == 'ASC' ? column_expression.asc : column_expression.desc + + Gitlab::Pagination::Keyset::Order.build( + [ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: "merge_request_metrics_#{metric}", + column_expression: column_expression, + order_expression: column_expression_with_direction.nulls_last, + reversed_order_expression: column_expression_with_direction.reverse.nulls_first, + order_direction: direction, + nullable: :nulls_last, + add_to_projections: true + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'merge_request_metrics_id', + order_expression: MergeRequest::Metrics.arel_table[:id].desc, + add_to_projections: true + ) + ]) + end + + # Returns the top 100 target branches + # + # The returned value is a Array containing branch names + # sort by updated_at of merge request: + # + # ['master', 'develop', 'production'] + # + # limit - The maximum number of target branch to return. + def self.recent_target_branches(limit: 100) + group(:target_branch) + .select(:target_branch) + .reorder(arel_table[:updated_at].maximum.desc) + .limit(limit) + .pluck(:target_branch) + end + + def self.recent_source_branches(limit: 100) + group(:source_branch) + .select(:source_branch) + .reorder(arel_table[:updated_at].maximum.desc) + .limit(limit) + .pluck(:source_branch) + end + + def self.distinct_source_branches + distinct.pluck(:source_branch) + end + + def self.sort_by_attribute(method, excluded_labels: []) + case method.to_s + when 'merged_at', 'merged_at_asc' then order_merged_at_asc + when 'closed_at', 'closed_at_asc' then order_closed_at_asc + when 'merged_at_desc' then order_merged_at_desc + when 'closed_at_desc' then order_closed_at_desc + else + super + end + end + + def self.reviewers_subquery + MergeRequestReviewer.arel_table + .project('true') + .where(Arel::Nodes::SqlLiteral.new("#{to_ability_name}_id = #{to_ability_name}s.id")) + end + + def rebase_in_progress? + rebase_jid.present? && Gitlab::SidekiqStatus.running_or_enqueued?(rebase_jid) + end + + def permits_force_push? + return true unless ProtectedBranch.protected?(source_project, source_branch) + + ProtectedBranch.allow_force_push?(source_project, source_branch) + end + + # Use this method whenever you need to make sure the head_pipeline is synced with the + # branch head commit, for example checking if a merge request can be merged. + # For more information check: https://gitlab.com/gitlab-org/gitlab-foss/issues/40004 + def diff_head_pipeline + head_pipeline&.matches_sha_or_source_sha?(diff_head_sha) ? head_pipeline : nil + end + + def merge_pipeline + if sha = merged_commit_sha + target_project.latest_unscheduled_pipeline(target_branch, sha) + end + end + + def diff_head_pipeline_success? + !!diff_head_pipeline&.success? + end + + # Pattern used to extract `!123` merge request references from text + # + # This pattern supports cross-project references. + def self.reference_pattern + @reference_pattern ||= %r{ + (#{Project.reference_pattern})? + #{Regexp.escape(reference_prefix)}#{Gitlab::Regex.merge_request} + }x + end + + def self.link_reference_pattern + @link_reference_pattern ||= compose_link_reference_pattern('merge_requests', Gitlab::Regex.merge_request) + end + + def self.reference_valid?(reference) + reference.to_i > 0 && reference.to_i <= Gitlab::Database::MAX_INT_VALUE + end + + def self.project_foreign_key + 'target_project_id' + end + + # Returns all the merge requests from an ActiveRecord:Relation. + # + # This method uses a UNION as it usually operates on the result of + # ProjectsFinder#execute. PostgreSQL in particular doesn't always like queries + # using multiple sub-queries especially when combined with an OR statement. + # UNIONs on the other hand perform much better in these cases. + # + # relation - An ActiveRecord::Relation that returns a list of Projects. + # + # Returns an ActiveRecord::Relation. + def self.in_projects(relation) + # unscoping unnecessary conditions that'll be applied + # when executing `where("merge_requests.id IN (#{union.to_sql})")` + source = unscoped.where(source_project_id: relation) + target = unscoped.where(target_project_id: relation) + + from_union([source, target]) + end + + # This is used after project import, to reset the IDs to the correct + # values. It is not intended to be called without having already scoped the + # relation. + # + # Only set `regular` merge request diffs as latest so `merge_head` diff + # won't be considered as `MergeRequest#merge_request_diff`. + def self.set_latest_merge_request_diff_ids! + update = " + latest_merge_request_diff_id = ( + SELECT MAX(id) + FROM merge_request_diffs + WHERE merge_requests.id = merge_request_diffs.merge_request_id + AND merge_request_diffs.diff_type = #{MergeRequestDiff.diff_types[:regular]} + )".squish + + self.each_batch do |batch| + batch.update_all(update) + end + end + + DRAFT_REGEX = /\A*#{Gitlab::Regex.merge_request_draft}+\s*/i + + def self.draft?(title) + !!(title =~ DRAFT_REGEX) + end + + def self.draftless_title(title) + title.sub(DRAFT_REGEX, "") + end + + def self.draft_title(title) + draft?(title) ? title : "Draft: #{title}" + end + + class << self + alias_method :work_in_progress?, :draft? + alias_method :wipless_title, :draftless_title + alias_method :wip_title, :draft_title + end + + def self.participant_includes + [:assignees, :reviewers] + super + end + + def recent_commits(limit: MergeRequestDiff::COMMITS_SAFE_SIZE, load_from_gitaly: false, page: nil, preload_metadata: false) + if preload_metadata && !load_from_gitaly + preload_commits_metadata + end + + commits(limit: limit, load_from_gitaly: load_from_gitaly, page: page) + end + + def preload_commits_metadata + return unless merge_request_diff.persisted? + + ActiveRecord::Associations::Preloader.new( + records: merge_request_diff.merge_request_diff_commits, + associations: { + merge_request_commits_metadata: [:commit_author, :committer] + } + ).call + end + + def committers(with_merge_commits: false, lazy: false, include_author_when_signed: false) + strong_memoize_with(:committers, with_merge_commits, lazy, include_author_when_signed) do + preload_commits_metadata + + commits.committers( + with_merge_commits: with_merge_commits, + lazy: lazy, + include_author_when_signed: include_author_when_signed + ) + end + end + + def committer_ids_to_filter_from_approvers + if Feature.enabled?(:approval_committer_emails_from_diff, target_project) + User.by_any_email(committer_emails_from_diff).select(:id) + else + committers(with_merge_commits: true, include_author_when_signed: true).select(:id) + end + end + strong_memoize_attr :committer_ids_to_filter_from_approvers + + def committers_to_filter_from_approvers + if Feature.enabled?(:approval_committer_emails_from_diff, target_project) + User.by_any_email(committer_emails_from_diff) + else + committers(with_merge_commits: true, lazy: true, include_author_when_signed: true) + end + end + strong_memoize_attr :committers_to_filter_from_approvers + + # Verifies if title has changed not taking into account Draft prefix + # for merge requests. + def draftless_title_changed(old_title) + self.class.draftless_title(old_title) != self.draftless_title + end + alias_method :wipless_title_changed, :draftless_title_changed + + def hook_attrs + Gitlab::HookData::MergeRequestBuilder.new(self).build + end + + # `from` argument can be a Namespace or Project. + def to_reference(from = nil, full: false) + reference = "#{self.class.reference_prefix}#{iid}" + + "#{project.to_reference_base(from, full: full)}#{reference}" + end + + def context_commits(limit: nil) + @context_commits ||= merge_request_context_commits.order_by_committed_date_desc.limit(limit).map(&:to_commit) + end + + def recent_context_commits + context_commits(limit: MergeRequestDiff::COMMITS_SAFE_SIZE) + end + + def context_commits_count + context_commits.count + end + + def commits(limit: nil, load_from_gitaly: false, page: nil) + return merge_request_diff.commits(limit: limit, load_from_gitaly: load_from_gitaly, page: page) if merge_request_diff.persisted? + + commits_arr = if compare_commits + reversed_commits = compare_commits.reverse + limit ? reversed_commits.take(limit) : reversed_commits + else + [] + end + + CommitCollection.new(source_project, commits_arr, source_branch) + end + + def commits_count + if merge_request_diff.persisted? + merge_request_diff.commits_count + elsif compare_commits + compare_commits.size + else + 0 + end + end + + def commit_shas(limit: nil, bypass_preloaded: false) + mode = bypass_preloaded ? :force_metadata : :preload + + return merge_request_diff.commit_shas(limit: limit, mode: mode) if merge_request_diff.persisted? + + shas = + if compare_commits + compare_commits.to_a.reverse.map(&:sha) + else + Array(diff_head_sha) + end + + limit ? shas.take(limit) : shas + end + + def supports_suggestion? + true + end + + def supports_lock_on_merge? + return false unless merged? + + project.supports_lock_on_merge? + end + + # Calls `MergeWorker` to proceed with the merge process and + # updates `merge_jid` with the MergeWorker#jid. + # This helps tracking enqueued and ongoing merge jobs. + def merge_async(user_id, params) + jid = MergeWorker.with_status.perform_async(id, user_id, params.to_h) + update_merge_jid(jid) + + # merge_ongoing? depends on merge_jid + # expire etag cache since the attribute is changed without triggering callbacks + expire_etag_cache + end + + # Set off a rebase asynchronously, atomically updating the `rebase_jid` of + # the MR so that the status of the operation can be tracked. + def rebase_async(user_id, skip_ci: false) + with_rebase_lock do + raise ActiveRecord::StaleObjectError if !open? || rebase_in_progress? + + # Although there is a race between setting rebase_jid here and clearing it + # in the RebaseWorker, it can't do any harm since we check both that the + # attribute is set *and* that the sidekiq job is still running. So a JID + # for a completed RebaseWorker is equivalent to a nil JID. + jid = Sidekiq::Worker.skipping_transaction_check do + RebaseWorker.with_status.perform_async(id, user_id, skip_ci) + end + + update_column(:rebase_jid, jid) + end + + # rebase_in_progress? depends on rebase_jid + # expire etag cache since the attribute is changed without triggering callbacks + expire_etag_cache + end + + def merge_participants + participants = [author] + + if auto_merge_enabled? && participants.exclude?(merge_user) + participants << merge_user + end + + participants.select { |participant| Ability.allowed?(participant, :read_merge_request, self) } + end + + def first_commit + compare_commits.present? ? compare_commits.first : merge_request_diff.first_commit + end + + def raw_diffs(*args) + compare.present? ? compare.raw_diffs(*args) : merge_request_diff.raw_diffs(*args) + end + + def diffs_for_streaming(diff_options = {}) + # Use compare when MR is being created and diffs haven't been persisted yet + return compare.diffs_for_streaming(diff_options) if compare + # Use context commits diff when only_context_commits is requested and context commits exist + return context_commits_diff.diffs(diff_options) if show_context_commits_diff?(diff_options) + + # Default: resolve the diff version (handles version comparisons, specific diff_id, etc.) + diff = resolve_diff_version(diff_options) + diff.diffs_for_streaming(diff_options) + end + + def diffs_for_streaming_by_changed_paths(diff_options = {}, &) + diff = resolve_diff_version(diff_options) + diff.diffs_for_streaming_by_changed_paths(diff_options, &) + end + + def diffs(diff_options = {}) + if compare + # When saving MR diffs, `expanded` is implicitly added (because we need + # to save the entire contents to the DB), so add that here for + # consistency. + compare.diffs(diff_options.merge(expanded: true)) + else + merge_request_diff.diffs(diff_options) + end + end + + def non_latest_diffs + merge_request_diffs.id_not_in(merge_request_diff.id) + end + + def note_positions_for_paths(paths, user = nil) + positions = notes.non_legacy_diff_notes.joins(:note_diff_file) + .where('note_diff_files.old_path IN (?) OR note_diff_files.new_path IN (?)', paths, paths) + .positions + + collection = Gitlab::Diff::PositionCollection.new(positions, diff_head_sha) + + return collection unless user + + positions = draft_notes + .authored_by(user) + .positions + .select { |pos| paths.include?(pos.file_path) } + + collection.concat(positions) + end + + def preloads_discussion_diff_highlighting? + true + end + + def discussions_diffs + strong_memoize(:discussions_diffs) do + note_diff_files = NoteDiffFile + .joins(:diff_note) + .merge(notes.or(commit_notes)) + .includes(diff_note: :project) + + Gitlab::DiscussionsDiff::FileCollection.new(note_diff_files.to_a) + end + end + + def diff_stats + return unless diff_refs + + strong_memoize(:diff_stats) do + project.repository.diff_stats(diff_refs.base_sha, diff_refs.head_sha) + end + end + + def diff_size + # Calling `merge_request_diff.diffs.real_size` will also perform + # highlighting, which we don't need here. + merge_request_diff&.real_size || diff_stats&.real_size || diffs.real_size + end + + def modified_paths(past_merge_request_diff: nil, fallback_on_overflow: false) + if past_merge_request_diff + past_merge_request_diff.modified_paths(fallback_on_overflow: fallback_on_overflow) + elsif compare + diff_stats&.paths || compare.modified_paths + else + merge_request_diff.modified_paths(fallback_on_overflow: fallback_on_overflow) + end + end + + def changed_paths + project.repository.find_changed_paths(commit_shas(bypass_preloaded: true), merge_commit_diff_mode: :all_parents) + end + request_cache(:changed_paths) { [id, diff_head_sha] } + + def new_paths + diffs.diff_files.map(&:new_path) + end + + def diff_head_commit + if merge_request_diff.persisted? + merge_request_diff.head_commit + else + source_branch_head + end + end + + def diff_start_sha + if merge_request_diff.persisted? + merge_request_diff.start_commit_sha + else + target_branch_head.try(:sha) + end + end + + def diff_base_sha + if merge_request_diff.persisted? + merge_request_diff.base_commit_sha + else + branch_merge_base_commit.try(:sha) + end + end + + def diff_head_sha + if merge_request_diff.persisted? + merge_request_diff.head_commit_sha + else + source_branch_head.try(:sha) + end + end + + def merged_in_repository? + merge_commit_sha.present? || read_attribute(:merged_commit_sha).present? || squash_commit_reachable_from_target_branch? + end + + def squash_commit_reachable_from_target_branch? + return false if squash_commit_sha.blank? + return false unless target_project.repository.exists? + + target_head = target_project.repository.commit(target_branch) + return false unless target_head + + target_project.repository.ancestor?(squash_commit_sha, target_head.sha) + rescue Gitlab::Git::Repository::NoRepository, Gitlab::Git::CommandError => e + Gitlab::ErrorTracking.track_exception( + e, + merge_request_id: id, + merge_request_iid: iid, + project_id: target_project_id, + method: :squash_commit_reachable_from_target_branch? + ) + false + end + + # When importing a pull request from GitHub, the old and new branches may no + # longer actually exist by those names, but we need to recreate the merge + # request diff with the right source and target shas. + # We use these attributes to force these to the intended values. + attr_writer :target_branch_sha, :source_branch_sha + + def source_branch_ref(or_sha: true) + return @source_branch_sha if @source_branch_sha && or_sha + return unless source_branch + + Gitlab::Git::BRANCH_REF_PREFIX + source_branch + end + + def target_branch_ref + return @target_branch_sha if @target_branch_sha + return unless target_branch + + Gitlab::Git::BRANCH_REF_PREFIX + target_branch + end + + def source_branch_head + strong_memoize(:source_branch_head) do + if source_project && source_branch_ref + source_project.repository.commit(source_branch_ref) + end + end + end + + def target_branch_head + strong_memoize(:target_branch_head) do + target_project.repository.commit(target_branch_ref) + end + end + + def branch_merge_base_commit + start_sha = target_branch_sha + head_sha = source_branch_sha + + if start_sha && head_sha + target_project.merge_base_commit(start_sha, head_sha) + end + end + + def target_branch_sha + @target_branch_sha || target_branch_head.try(:sha) + end + + def source_branch_sha + @source_branch_sha || source_branch_head.try(:sha) + end + + def diff_refs + if importing? || persisted? + merge_request_diff.diff_refs + else + repository_diff_refs + end + end + + # Instead trying to fetch the + # persisted diff_refs, this method goes + # straight to the repository to get the + # most recent data possible. + def repository_diff_refs + Gitlab::Diff::DiffRefs.new( + base_sha: branch_merge_base_sha, + start_sha: target_branch_sha, + head_sha: source_branch_sha + ) + end + + def branch_merge_base_sha + branch_merge_base_commit.try(:sha) + end + + def existing_mrs_targeting_same_branch + similar_mrs = target_project + .merge_requests + .where(source_branch: source_branch, target_branch: target_branch) + .where(source_project: source_project) + .opened + + similar_mrs = similar_mrs.id_not_in(id) if persisted? + + similar_mrs + end + + def validate_branches + return unless target_project && source_project + + if target_project == source_project && target_branch == source_branch + errors.add :branch_conflict, "You can't use same project/branch for source and target" + return + end + + [:source_branch, :target_branch].each { |attr| validate_branch_name(attr) } + + if opened? + conflicting_mr = existing_mrs_targeting_same_branch.first + + if conflicting_mr + errors.add( + :validate_branches, + conflicting_mr_message(conflicting_mr) + ) + end + end + end + + def conflicting_mr_message(conflicting_mr) + format( + _("Another open merge request already exists for this source branch: %{conflicting_mr_reference}"), + conflicting_mr_reference: conflicting_mr.to_reference + ) + end + + def validate_branch_name(attr) + return unless will_save_change_to_attribute?(attr) + + branch = read_attribute(attr) + + return unless branch + + errors.add(attr) unless Gitlab::GitRefValidator.validate_merge_request_branch(branch) + end + + def validate_branch_existence + return unless source_project && target_project + + if source_branch.present? && !source_branch_exists? + errors.add(:source_branch, _('does not exist')) + end + + if target_branch.present? && !target_branch_exists? + errors.add(:target_branch, _('does not exist')) + end + end + + def validate_target_project + return true if target_project.merge_requests_enabled? + + errors.add :base, 'Target project has disabled merge requests' + end + + def validate_fork + return true unless target_project && source_project + return true if target_project == source_project + return true unless source_project_missing? + + errors.add :validate_fork, 'Source project is not a fork of the target project' + end + + def validate_reviewer_size_length + return true unless reviewers.size > MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS + + errors.add :reviewers, + ->(_object, _data) { self.class.max_number_of_assignees_or_reviewers_message } + end + + def merge_ongoing? + # While the MergeRequest is locked, it should present itself as 'merge ongoing'. + # The unlocking process is handled by StuckMergeJobsWorker scheduled in Cron. + return true if locked? + + !!merge_jid && !merged? && Gitlab::SidekiqStatus.running_or_enqueued?(merge_jid) + end + + def closed_or_merged_without_fork? + (closed? || merged?) && source_project_missing? + end + + def source_project_missing? + return false unless for_fork? + return true unless source_project + + !source_project.in_fork_network_of?(target_project) + end + + def ensure_merge_request_diff + return if merge_request_diff.persisted? + + create_merge_request_diff(preload_gitaly: !importing?) + end + + def create_merge_request_diff(preload_gitaly: false) + fetch_ref! + + # n+1: https://gitlab.com/gitlab-org/gitlab/-/issues/19377 + Gitlab::GitalyClient.allow_n_plus_1_calls do + if preload_gitaly + new_diff = merge_request_diffs.build + new_diff.preload_gitaly_data + new_diff.save! + else + merge_request_diffs.create! + end + + reload_merge_request_diff + end + end + + def merge_request_diff_for(diff_refs_or_sha, order_by: :id_asc) + matcher = + if diff_refs_or_sha.is_a?(Gitlab::Diff::DiffRefs) + { + 'start_commit_sha' => diff_refs_or_sha.start_sha, + 'head_commit_sha' => diff_refs_or_sha.head_sha, + 'base_commit_sha' => diff_refs_or_sha.base_sha + } + else + { 'head_commit_sha' => diff_refs_or_sha } + end + + viewable_diffs(order_by: order_by).find do |diff| + diff.attributes.slice(*matcher.keys) == matcher + end + end + + def previous_diff + merge_request_diffs.order(id: :desc).offset(1).take + end + + def version_params_for(diff_refs) + if diff = merge_request_diff_for(diff_refs) + { diff_id: diff.id } + elsif diff = merge_request_diff_for(diff_refs.head_sha) + { + diff_id: diff.id, + start_sha: diff_refs.start_sha + } + end + end + + def clear_memoized_shas + @target_branch_sha = @source_branch_sha = nil + + clear_memoization(:source_branch_head) + clear_memoization(:target_branch_head) + clear_memoization(:diff_stats) + end + + def reload_diff_if_branch_changed + if (saved_change_to_source_branch? || saved_change_to_target_branch?) && + source_branch_head && target_branch_head + reload_diff + end + end + + # rubocop: disable CodeReuse/ServiceClass + def reload_diff(current_user = nil) + return unless open? + return if state_changed_since_load? + + MergeRequests::ReloadDiffsService.new(self, current_user).execute + end + + def check_mergeability(async: false, sync_retry_lease: false) + return unless recheck_merge_status? + + check_service = MergeRequests::MergeabilityCheckService.new(self) + + if async + check_service.async_execute + else + check_service.execute(retry_lease: sync_retry_lease) + end + end + # rubocop: enable CodeReuse/ServiceClass + + def diffable_merge_ref? + open? && merge_head_diff.present? && can_be_merged? + end + + # Returns boolean indicating the merge_status should be rechecked in order to + # switch to either can_be_merged or cannot_be_merged. + def recheck_merge_status? + self.class.state_machines[:merge_status].check_state?(merge_status) + end + + def merge_event + @merge_event ||= target_project.events.where(target_id: self.id, target_type: "MergeRequest", action: :merged).last + end + + def closed_event + @closed_event ||= target_project.events.where(target_id: self.id, target_type: "MergeRequest", action: :closed).last + end + + def draft? + self.class.draft?(title) + end + alias_method :work_in_progress?, :draft? + + def draftless_title + self.class.draftless_title(self.title) + end + alias_method :wipless_title, :draftless_title + + def draft_title + self.class.draft_title(self.title) + end + alias_method :wip_title, :draft_title + + def skipped_auto_merge_checks(options = {}) + merge_when_checks_pass_strat = options[:auto_merge_strategy].in?([ + ::AutoMergeService::STRATEGY_MERGE_WHEN_CHECKS_PASS, + ::AutoMergeService::STRATEGY_ADD_TO_MERGE_TRAIN_WHEN_CHECKS_PASS + ]) + + skip_conflict_check = merge_when_checks_pass_strat && recheck_merge_status? + + { + skip_ci_check: merge_when_checks_pass_strat, + skip_approved_check: merge_when_checks_pass_strat, + skip_conflict_check: skip_conflict_check, + skip_draft_check: merge_when_checks_pass_strat, + skip_blocked_check: merge_when_checks_pass_strat, + skip_discussions_check: merge_when_checks_pass_strat, + skip_external_status_check: merge_when_checks_pass_strat, + skip_requested_changes_check: merge_when_checks_pass_strat, + skip_locked_paths_check: merge_when_checks_pass_strat, + skip_jira_check: merge_when_checks_pass_strat, + skip_locked_lfs_files_check: merge_when_checks_pass_strat, + skip_security_policy_check: merge_when_checks_pass_strat, + skip_security_policy_pipeline_check: merge_when_checks_pass_strat, + skip_merge_time_check: merge_when_checks_pass_strat, + skip_merge_request_title_check: merge_when_checks_pass_strat + } + end + + # Checks whether the merge request is eligible for auto-merge with the given strategy. + # Unlike `mergeable?`, this skips checks that the auto-merge process will re-evaluate + # later (e.g., CI status, approvals, discussions), focusing only on preconditions that + # must be met to accept the auto-merge request. + def auto_merge_eligible?(strategy:, check_mergeability_retry_lease: false, use_cache: true) + mergeable?( + check_mergeability_retry_lease: check_mergeability_retry_lease, + use_cache: use_cache, + **skipped_auto_merge_checks(auto_merge_strategy: strategy) + ) + end + + # mergeable_state_check_params allows a hash of merge checks to skip or not + # skip_ci_check + # skip_conflict_check + # skip_discussions_check + # skip_draft_check + # skip_approved_check + # skip_blocked_check + # skip_external_status_check + # skip_requested_changes_check + # skip_locked_paths_check + # skip_jira_check + # skip_locked_lfs_files_check + def mergeable?( + check_mergeability_retry_lease: false, skip_rebase_check: false, + skip_conflict_check: false, use_cache: true, **mergeable_state_check_params) + return false unless mergeable_state?(use_cache: use_cache, **mergeable_state_check_params) + + check_mergeability(sync_retry_lease: check_mergeability_retry_lease) + mergeable_git_state?(skip_rebase_check: skip_rebase_check, skip_conflict_check: skip_conflict_check, use_cache: use_cache) + end + + def self.mergeable_state_checks + mergeable_state_checks_by_tier.values.flatten + end + + # Checks grouped by cost tier for fail-fast optimization. + # EE prepends additional checks into each tier via override. + # Tiers are executed in order: cheapest first, so we can + # fail fast before running the more expensive ones. + # Checks within each tier are ordered by P99 latency ascending. + # + # Thresholds based on production P99 latency: + # - trivial: P99 < 0.005s + # - light: P99 0.005s - 0.019s + # - heavy: P99 >= 0.020s + def self.mergeable_state_checks_by_tier + { + trivial: [ + ::MergeRequests::Mergeability::CheckOpenStatusService, + ::MergeRequests::Mergeability::CheckDraftStatusService + ], + light: [ + ::MergeRequests::Mergeability::CheckMergeTimeService + ], + heavy: [ + ::MergeRequests::Mergeability::CheckLfsFileLocksService, + ::MergeRequests::Mergeability::CheckDiscussionsStatusService, + ::MergeRequests::Mergeability::CheckCiStatusService, + ::MergeRequests::Mergeability::CheckCommitsStatusService + ] + } + end + + def self.mergeable_git_state_checks + [ + ::MergeRequests::Mergeability::CheckRebaseStatusService, + ::MergeRequests::Mergeability::CheckConflictStatusService + ] + end + + def self.all_mergeability_checks + mergeable_state_checks + mergeable_git_state_checks + end + + # mergeable_state_check_params allows a hash of merge checks to skip or not + # skip_ci_check + # skip_discussions_check + # skip_draft_check + # skip_approved_check + # skip_blocked_check + # skip_external_status_check + # skip_requested_changes_check + # skip_jira_check + def mergeable_state?(use_cache: true, **params) + execute_merge_checks( + self.class.mergeable_state_checks, + params: params, + execute_all: false, + use_cache: use_cache + ).success? + end + + # This runs only git related checks + def mergeable_git_state?(use_cache: true, **params) + execute_merge_checks( + self.class.mergeable_git_state_checks, + params: params, + execute_all: false, + use_cache: use_cache + ).success? + end + + # This runs all the checks + def mergeability_checks_pass?(**params) + execute_merge_checks( + self.class.all_mergeability_checks, + params: params, + execute_all: false + ).success? + end + + def all_mergeability_checks_results + execute_merge_checks( + self.class.all_mergeability_checks, + execute_all: true + ).payload[:results] + end + + def ff_merge_possible? + project.repository.ancestor?(target_branch_sha, diff_head_sha) + end + + def should_be_rebased? + project.ff_merge_must_be_possible? && !ff_merge_possible? + end + + def can_cancel_auto_merge?(current_user) + can_be_merged_by?(current_user) || self.author == current_user + end + + def can_remove_source_branch?(current_user) + source_project && + !ProtectedBranch.protected?(source_project, source_branch) && + !source_project.root_ref?(source_branch) && + Ability.allowed?(current_user, :push_code, source_project) && + diff_head_sha == source_branch_head.try(:sha) + end + + def should_remove_source_branch? + Gitlab::Utils.to_boolean(merge_params['should_remove_source_branch']) + end + + def force_remove_source_branch? + Gitlab::Utils.to_boolean(merge_params['force_remove_source_branch']) + end + + def auto_merge_strategy + return unless auto_merge_enabled? + + merge_params['auto_merge_strategy'] || default_auto_merge_strategy + end + + def default_auto_merge_strategy + AutoMergeService::STRATEGY_MERGE_WHEN_CHECKS_PASS + end + + def auto_merge_strategy=(strategy) + append_merge_params({ 'auto_merge_strategy' => strategy }) + end + + def remove_source_branch? + should_remove_source_branch? || force_remove_source_branch? + end + + def notify_conflict? + (opened? || locked?) && + has_commits? && + !branch_missing? && + !project.repository.can_be_merged?(diff_head_sha, target_branch) + rescue Gitlab::Git::CommandError + # Checking mergeability can trigger exception, e.g. non-utf8 + # We ignore this type of errors. + false + end + + def related_notes + # We're using a UNION ALL here since this results in better performance + # compared to using OR statements. We're using UNION ALL since the queries + # used won't produce any duplicates (e.g. a note for a commit can't also be + # a note for an MR). + Note + .from_union([notes, commit_notes], remove_duplicates: false) + .includes(:noteable) + end + + alias_method :discussion_notes, :related_notes + + def commit_notes + # Fetch comments only from last 100 commits + commit_ids = commit_shas(limit: 100) + + Note + .user + .where(project_id: [source_project_id, target_project_id]) + .for_commit_id(commit_ids) + end + + def duo_code_review_progress_note + # Overridden in EE + end + + def mergeable_discussions_state? + return true unless only_allow_merge_if_all_discussions_are_resolved? + + unresolved_notes.none?(&:to_be_resolved?) + end + + def for_fork? + target_project != source_project + end + + def for_same_project? + target_project == source_project + end + + def cache_closing_issues_after_merge!(current_user) + return unless merge_commit || squash_commit + + messages = [squash_commit&.safe_message, merge_commit&.safe_message].compact + + squash_and_merge_commit_issue_ids = Gitlab::ClosingIssueExtractor.new(project, current_user) + .closed_by_message(messages.join("\n")) + .reject { |issue| issue.is_a?(ExternalIssue) } + .map(&:id) + + transaction do + update_cached_closing_issues_from_description!(squash_and_merge_commit_issue_ids) + existing_issue_ids = merge_request_closing_issues.pluck(:issue_id) + issue_ids_to_create = squash_and_merge_commit_issue_ids - existing_issue_ids + + bulk_insert_cached_closing_issues(issue_ids_to_create) + end + 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 + # running `ReferenceExtractor` on each of them separately. + # This optimization does not apply to issues from external sources. + def cache_merge_request_closes_issues!(current_user = self.author) + return if closed? || merged? + + issues_to_close_ids = closes_issues(current_user).reject { |issue| issue.is_a?(ExternalIssue) }.map(&:id) + + transaction do + merge_request_closing_issues.from_mr_description.delete_all + + updated_issue_ids = update_cached_closing_issues_from_description!(issues_to_close_ids) + issue_ids_to_create = issues_to_close_ids - updated_issue_ids + next unless issue_ids_to_create.any? + + bulk_insert_cached_closing_issues(issue_ids_to_create) + end + end + + def visible_closing_issues_for(current_user = self.author) + strong_memoize_with(:visible_closing_issues_for, current_user&.id) do + visible_issues = if self.target_project.has_external_issue_tracker? + closes_issues(current_user) + else + Ability.issues_readable_by_user(cached_closes_issues, current_user) + end + + ActiveRecord::Associations::Preloader.new( + records: visible_issues.select { |issue| issue.is_a?(Issue) }, + associations: :project + ).call + # Exclude issues that have been cached but their project setting has been disabled, or they belong to a group + visible_issues.select do |issue| + !issue.is_a?(Issue) || issue.autoclose_by_merged_closing_merge_request? + end + end + end + + # Return the set of issues that will be closed if this merge request is accepted. + def closes_issues(current_user = self.author) + if target_branch == project.default_branch + messages = [title, description] + messages.concat(commits(load_from_gitaly: true).map(&:safe_message)) if merge_request_diff.persisted? + messages << squash_commit.safe_message if squash_commit.present? + messages << merge_commit.safe_message if merge_commit.present? + + Gitlab::ClosingIssueExtractor.new(project, current_user) + .closed_by_message(messages.join("\n")) + else + [] + end + end + + def issues_mentioned_but_not_closing(current_user) + return [] unless target_branch == project.default_branch + + ext = Gitlab::ReferenceExtractor.new(project, current_user) + ext.analyze("#{title}\n#{description}") + + ext.issues - visible_closing_issues_for(current_user) + end + + def related_issues(user) + visible_notes = user.can?(:read_internal_note, project) ? notes : notes.not_internal + + messages = [title, description, *visible_notes.pluck(:note)] + messages += commits(load_from_gitaly: true).map(&:safe_message) if merge_request_diff.persisted? + + ext = Gitlab::ReferenceExtractor.new(project, user) + ext.analyze(messages.join("\n")) + + ext.issues + end + + def target_project_path + if target_project + target_project.full_path + else + "(removed)" + end + end + + def source_project_path + if source_project + source_project.full_path + else + "(removed)" + end + end + + def source_project_namespace + if source_project && source_project.namespace + source_project.namespace.full_path + else + "(removed)" + end + end + + def target_project_namespace + if target_project && target_project.namespace + target_project.namespace.full_path + else + "(removed)" + end + end + + def preload_branches + # There are cases when MergeRequest object is only partially loaded + # Skip preloading if required fields are missing + return unless has_attribute?(:source_project_id) && has_attribute?(:target_project_id) + + Gitlab::Git::RefPreloader.collect_ref(source_project_id, Gitlab::Git::BRANCH_REF_PREFIX + self.source_branch) + Gitlab::Git::RefPreloader.collect_ref(target_project_id, Gitlab::Git::BRANCH_REF_PREFIX + self.target_branch) + + nil + end + + def source_branch_exists? + return false unless self.source_project + + self.source_project.repository.branch_exists?(self.source_branch) + end + + def target_branch_exists? + return false unless self.target_project + + self.target_project.repository.branch_exists?(self.target_branch) + end + + def default_merge_commit_message(include_description: false, user: nil) + if self.target_project.merge_commit_template.present? && !include_description + return ::Gitlab::MergeRequests::MessageGenerator.new(merge_request: self, current_user: user).merge_commit_message + end + + closes_issues_references = visible_closing_issues_for.map do |issue| + issue.to_reference(target_project) + end + + message = [ + "Merge branch '#{source_branch}' into '#{target_branch}'", + title + ] + + if !include_description && closes_issues_references.present? + message << "Closes #{closes_issues_references.to_sentence}" + end + + message << description if include_description && description.present? + message << "See merge request #{to_reference(full: true)}" + + message.join("\n\n") + end + + def default_squash_commit_message(user: nil) + squash_commit_message = ::Gitlab::MergeRequests::MessageGenerator.new(merge_request: self, current_user: user).squash_commit_message.presence + + squash_commit_message || title + end + + # Returns the oldest multi-line commit + def first_multiline_commit + strong_memoize(:first_multiline_commit) do + recent_commits(load_from_gitaly: true).without_merge_commits.reverse_each.find(&:description?) + end + end + + # Returns the description (without the first line/title) of the first multiline commit + def first_multiline_commit_description + strong_memoize(:first_multiline_commit_description) do + commit = first_multiline_commit + commit&.description + end + end + + def squash_on_merge? + return true if squash_always? + return false if squash_never? + + squash? + end + + def has_ci? + return false if has_no_commits? + + !!(head_pipeline_id || all_pipelines.any? || source_project&.ci_integration) + end + + def branch_missing? + !source_branch_exists? || !target_branch_exists? + end + + def broken? + has_no_commits? || branch_missing? || cannot_be_merged? + end + + def can_be_merged_by?(user, skip_collaboration_check: false) + access = ::Gitlab::UserAccess.new(user, container: project, skip_collaboration_check: skip_collaboration_check) + access.can_update_branch?(target_branch) + end + + def only_allow_merge_if_pipeline_succeeds? + project.only_allow_merge_if_pipeline_succeeds?(inherit_group_setting: true) + end + + def allow_merge_without_pipeline? + project.allow_merge_without_pipeline?(inherit_group_setting: true) + end + + def only_allow_merge_if_all_discussions_are_resolved? + project.only_allow_merge_if_all_discussions_are_resolved?(inherit_group_setting: true) + end + + # We use a heuristic of if there are pipeline created, being created, or a ci integration is setup + def has_ci_enabled? + has_ci? || pipeline_creating? + end + + def pipeline_creating? + Ci::PipelineCreation::Requests.pipeline_creating_for_merge_request?(self) + end + + def environments_in_head_pipeline(deployment_status: nil) + diff_head_pipeline&.environments_in_self_and_project_descendants(deployment_status: deployment_status) || Environment.none + end + + def fetch_ref! + target_project.repository.fetch_source_branch!(source_project.repository, source_branch_ref(or_sha: false), ref_path) + expire_ancestor_cache + end + + # Returns the current merge-ref HEAD commit. + # + def merge_ref_head + return project.repository.commit(merge_ref_sha) if merge_ref_sha + + project.repository.commit(merge_ref_path) + end + + def ref_path + "refs/#{Repository::REF_MERGE_REQUEST}/#{iid}/head" + end + + def merge_ref_path + "refs/#{Repository::REF_MERGE_REQUEST}/#{iid}/merge" + end + + def train_ref_path + "refs/#{Repository::REF_MERGE_REQUEST}/#{iid}/train" + end + + def rebase_on_merge_path + "refs/#{Repository::REF_MERGE_REQUEST}/#{iid}/rebase_on_merge" + end + + def schedule_cleanup_refs(only: :all) + if Feature.enabled?(:merge_request_delete_gitaly_refs_in_batches, target_project) + async_cleanup_refs(only: only) + elsif Feature.enabled?(:merge_request_cleanup_ref_worker_async, target_project) + MergeRequests::CleanupRefWorker.perform_async(id, only.to_s) + else + cleanup_refs(only: only) + end + end + + def refs_to_cleanup(only: :all) + target_refs = [] + target_refs << ref_path if %i[all head].include?(only) + target_refs << merge_ref_path if %i[all merge].include?(only) + target_refs << train_ref_path if %i[all train].include?(only) + target_refs << rebase_on_merge_path if %i[all rebase_on_merge_path].include?(only) + target_refs + end + + def cleanup_refs(only: :all) + project.repository.delete_refs(*refs_to_cleanup(only: only)) + end + + def async_cleanup_refs(only: :all) + project.repository.async_delete_refs(*refs_to_cleanup(only: only)) + end + + def self.merge_request_ref?(ref) + ref.start_with?("refs/#{Repository::REF_MERGE_REQUEST}/") + end + + def self.merge_train_ref?(ref) + %r{\Arefs/#{Repository::REF_MERGE_REQUEST}/\d+/train\z}o.match?(ref) + end + + def in_locked_state + # This method raises an error when adding to Redis set fails. This is so + # we can retry merging if it wasn't added to ensure that the MR gets added + # to locked set for unsticking in case it gets into a stuck state during + # the merge process. + add_to_locked_set + lock_mr + yield + ensure + unlock_mr if locked? + + # We only remove it from the locked set if it's no longer locked as it means + # the MR is either unlocked or merged. + remove_from_locked_set unless locked? + end + + # Recovers a merge request that is stuck `locked` and cannot be unlocked + # normally because reopening it now fails a structural validation (most + # commonly a newer opened MR occupying the same source branch, a missing + # source project, or a broken fork). Those validations are bypassed via + # `allow_broken` and the MR is moved locked -> opened -> closed so the + # state-machine callbacks (e.g. the head pipeline update on `=> :opened`) + # still fire. + # + # Returns false without persisting a close when the MR is not locked or a + # transition fails. The non-bang transitions return false (instead of raising) + # on a non-bypassable validation error or a concurrent state change, so a + # failure here never aborts the surrounding (cron) batch. Note: if unlock_mr + # succeeds but close then fails, the MR is left opened (already a recovered + # state); the unstick cron self-heals that on its next run. See #600038. + def force_unlock_and_close + return false unless locked? + + with_allow_broken { unlock_mr && close } + end + + def update_and_mark_in_progress_merge_commit_sha(commit_id) + self.update(in_progress_merge_commit_sha: commit_id) + # Since another process checks for matching merge request, we need + # to make it possible to detect whether the query should go to the + # primary. + target_project.sticking.stick(:project, target_project.id) + end + + def diverged_commits_count + cache = Rails.cache.read(:"merge_request_#{id}_diverged_commits") + + if cache.blank? || cache[:source_sha] != source_branch_sha || cache[:target_sha] != target_branch_sha + cache = { + source_sha: source_branch_sha, + target_sha: target_branch_sha, + diverged_commits_count: compute_diverged_commits_count + } + Rails.cache.write(:"merge_request_#{id}_diverged_commits", cache) + end + + cache[:diverged_commits_count] + end + + def compute_diverged_commits_count + return 0 unless source_branch_sha && target_branch_sha + + target_project.repository + .count_commits_between(source_branch_sha, target_branch_sha) + end + private :compute_diverged_commits_count + + def diverged_from_target_branch? + diverged_commits_count > 0 + end + + def all_pipelines + strong_memoize(:all_pipelines) do + Ci::PipelinesForMergeRequestFinder.new(self, nil).all + end + end + + def update_head_pipeline + find_diff_head_pipeline.try do |pipeline| + self.head_pipeline = pipeline + + next unless head_pipeline_id_changed? + + update_columns( + head_pipeline_id: head_pipeline.id, + retargeted: false + ) + end + end + + def has_test_reports? + !!diff_head_pipeline&.has_test_reports? + end + + # rubocop: disable Metrics/AbcSize -- Despite being long, this method is quite straightforward. Splitting it in smaller chunks would likely reduce readability. + def predefined_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables.append(key: 'CI_MERGE_REQUEST_ID', value: id.to_s) + variables.append(key: 'CI_MERGE_REQUEST_IID', value: iid.to_s) + variables.append(key: 'CI_MERGE_REQUEST_REF_PATH', value: ref_path.to_s) + variables.append(key: 'CI_MERGE_REQUEST_PROJECT_ID', value: project.id.to_s) + variables.append(key: 'CI_MERGE_REQUEST_PROJECT_PATH', value: project.full_path) + variables.append(key: 'CI_MERGE_REQUEST_PROJECT_URL', value: project.web_url) + variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_NAME', value: target_branch.to_s) + variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_PROTECTED', value: ProtectedBranch.protected?(target_project, target_branch).to_s) + variables.append(key: 'CI_MERGE_REQUEST_TITLE', value: title) + variables.append(key: 'CI_MERGE_REQUEST_DRAFT', value: work_in_progress?.to_s) + + mr_description, mr_description_truncated = truncate_mr_description + variables.append(key: 'CI_MERGE_REQUEST_DESCRIPTION', value: mr_description) + variables.append(key: 'CI_MERGE_REQUEST_DESCRIPTION_IS_TRUNCATED', value: mr_description_truncated) + variables.append(key: 'CI_MERGE_REQUEST_ASSIGNEES', value: assignee_username_list) if assignees.present? + variables.append(key: 'CI_MERGE_REQUEST_MILESTONE', value: milestone.title) if milestone + variables.append(key: 'CI_MERGE_REQUEST_LABELS', value: label_names.join(',')) if labels.present? + variables.append(key: 'CI_MERGE_REQUEST_SQUASH_ON_MERGE', value: squash_on_merge?.to_s) + variables.concat(source_project_variables) + end + end + # rubocop: enable Metrics/AbcSize + + def compare_test_reports + unless has_test_reports? + return { status: :error, status_reason: 'This merge request does not have test reports' } + end + + compare_reports(Ci::CompareTestReportsService) + end + + def has_accessibility_reports? + diff_head_pipeline.present? && diff_head_pipeline.complete_and_has_reports?(Ci::JobArtifact.of_report_type(:accessibility)) + end + + def has_coverage_reports? + has_coverage_reports_for?(diff_head_pipeline) + end + + def has_coverage_reports_for?(pipeline) + !!pipeline&.has_coverage_reports? + end + + def has_terraform_reports? + !!diff_head_pipeline&.complete_and_has_self_or_descendant_reports?(Ci::JobArtifact.of_report_type(:terraform)) + end + + def compare_accessibility_reports + unless has_accessibility_reports? + return { status: :error, status_reason: _('This merge request does not have accessibility reports') } + end + + compare_reports(Ci::CompareAccessibilityReportsService) + end + + # TODO: this method and compare_test_reports use the same + # result type, which is handled by the controller's #reports_response. + # we should minimize mistakes by isolating the common parts. + # issue: https://gitlab.com/gitlab-org/gitlab/issues/34224 + def find_coverage_reports + unless has_coverage_reports? + return { status: :error, status_reason: 'This merge request does not have coverage reports' } + end + + compare_reports(Ci::GenerateCoverageReportsService) + end + + def has_codequality_mr_diff_report? + diff_head_pipeline&.has_codequality_mr_diff_report? + end + + # TODO: this method and compare_test_reports use the same + # result type, which is handled by the controller's #reports_response. + # we should minimize mistakes by isolating the common parts. + # issue: https://gitlab.com/gitlab-org/gitlab/issues/34224 + def find_codequality_mr_diff_reports + unless has_codequality_mr_diff_report? + return { status: :error, status_reason: 'This merge request does not have codequality mr diff reports' } + end + + compare_reports(Ci::GenerateCodequalityMrDiffReportService) + end + + def has_codequality_reports? + has_codequality_reports_for?(diff_head_pipeline) + end + + def has_codequality_reports_for?(pipeline) + !!pipeline&.complete_and_has_self_or_descendant_reports?(Ci::JobArtifact.of_report_type(:codequality)) + end + + def compare_codequality_reports + unless has_codequality_reports? + return { status: :error, status_reason: _('This merge request does not have codequality reports') } + end + + compare_reports(Ci::CompareCodequalityReportsService) + rescue ReactiveCaching::InvalidateReactiveCache + { status: :parsing } + end + + def find_terraform_reports + unless has_terraform_reports? + return { status: :error, status_reason: 'This merge request does not have terraform reports' } + end + + compare_reports(Ci::GenerateTerraformReportsService) + end + + def has_exposed_artifacts? + diff_head_pipeline&.has_exposed_artifacts? + end + + # TODO: this method and compare_test_reports use the same + # result type, which is handled by the controller's #reports_response. + # we should minimize mistakes by isolating the common parts. + # issue: https://gitlab.com/gitlab-org/gitlab/issues/34224 + def find_exposed_artifacts + unless has_exposed_artifacts? + return { status: :error, status_reason: 'This merge request does not have exposed artifacts' } + end + + compare_reports(Ci::GenerateExposedArtifactsReportService) + end + + # TODO: consider renaming this as with exposed artifacts we generate reports, + # not always compare + # issue: https://gitlab.com/gitlab-org/gitlab/issues/34224 + def compare_reports(service_class, current_user = nil, report_type = nil, additional_params = {}) + with_reactive_cache(service_class.name, current_user&.id, report_type) do |data| + unless service_class.new(project, current_user, id: id, report_type: report_type, additional_params: additional_params) + .latest?(comparison_base_pipeline(service_class), diff_head_pipeline, data) + raise ReactiveCaching::InvalidateReactiveCache + end + + data + end || { status: :parsing } + end + + def has_sast_reports? + !!diff_head_pipeline&.complete_or_manual? && + pipeline_has_report_in_self_or_descendants?(:sast) + end + + def calculate_reactive_cache(identifier, current_user_id = nil, report_type = nil, *args) + service_class = identifier.constantize + + # TODO: the type check should change to something that includes exposed artifacts service + # issue: https://gitlab.com/gitlab-org/gitlab/issues/34224 + raise NameError, service_class unless service_class < Ci::CompareReportsBaseService + + current_user = User.find_by(id: current_user_id) + service_class.new(project, current_user, id: id, report_type: report_type).execute(comparison_base_pipeline(service_class), diff_head_pipeline) + end + + MAX_RECENT_DIFF_HEAD_SHAS = 100 + + def recent_diff_head_shas(limit = MAX_RECENT_DIFF_HEAD_SHAS) + # see MergeRequestDiff.recent + return merge_request_diffs.to_a.sort_by(&:id).reverse.first(limit).pluck(:head_commit_sha) if merge_request_diffs.loaded? + + merge_request_diffs.recent(limit).pluck(:head_commit_sha) + end + + def all_commits + relation = MergeRequestDiffCommit + .where(merge_request_diff: merge_request_diffs.recent) + .limit(10_000) + + relation = relation.where(project_id: target_project_id) if project_id_pruning_enabled? + + relation + end + + # Note that this could also return SHA from now dangling commits + # + def all_commit_shas + return commit_shas unless persisted? + + all_commit_shas_from_metadata + end + strong_memoize_attr :all_commit_shas + + def merge_commit + @merge_commit ||= project.commit(merge_commit_sha) if merge_commit_sha + end + + def squash_commit + @squash_commit ||= project.commit(squash_commit_sha) if squash_commit_sha + end + + def merged_commit_sha + return unless merged? + + sha = super || merge_commit_sha || squash_commit_sha || diff_head_sha + sha.presence + end + + def short_merged_commit_sha + if sha = merged_commit_sha + Commit.truncate_sha(sha) + end + end + + # Exists only for merged merge requests + def commit_to_revert + return unless merged? + + # By default, it's equal to a merge commit + return merge_commit if merge_commit + + # But in case of fast-forward merge merge commits are not created + # To solve that we can use `squash_commit` if the merge request was squashed + return squash_commit if squash_commit + + # Edge case: one commit in the merge request without merge or squash commit + return project.commit(diff_head_sha) if commits_count == 1 + + nil + end + + def commit_to_cherry_pick + commit_to_revert + end + + def can_be_reverted?(current_user) + return false unless merged_at + + commit = commit_to_revert + return false unless commit + + # It is not guaranteed that Note#created_at will be strictly later than + # MergeRequestMetric#merged_at. Nanoseconds on MySQL may break this + # comparison, as will a HA environment if clocks are not *precisely* + # synchronized. Add a minute's leeway to compensate for both possibilities + cutoff = merged_at - 1.minute + + notes_association = notes_with_associations.where('created_at >= ?', cutoff) + + !commit.has_been_reverted?(current_user, notes_association) + end + + def merged_at + strong_memoize(:merged_at) do + next unless merged? + + metrics&.merged_at || + merge_event&.created_at || + resource_state_events.find_by(state: :merged)&.created_at || + notes.system.reorder(nil).find_by(note: 'merged')&.created_at + end + end + + def can_be_cherry_picked? + commit_to_cherry_pick.present? + end + + def has_complete_diff_refs? + diff_refs && diff_refs.complete? + end + + # rubocop: disable CodeReuse/ServiceClass + def update_diff_discussion_positions(old_diff_refs:, new_diff_refs:, current_user: nil) + return unless has_complete_diff_refs? + return if new_diff_refs == old_diff_refs + + active_diff_discussions = self.notes.non_legacy_diff_notes.discussions.select do |discussion| + discussion.active?(old_diff_refs) + end + return if active_diff_discussions.empty? + + paths = active_diff_discussions.flat_map { |n| n.diff_file.paths }.uniq + + active_discussions_resolved = active_diff_discussions.all?(&:resolved?) + + service = Discussions::UpdateDiffPositionService.new( + self.project, + current_user, + old_diff_refs: old_diff_refs, + new_diff_refs: new_diff_refs, + paths: paths + ) + + active_diff_discussions.each do |discussion| + service.execute(discussion) + discussion.clear_memoized_values + end + + # If they were all already resolved, this method will have already been called. + # If they all don't get resolved, we don't need to call the method + # If they go from unresolved -> resolved, then we call the method + if !active_discussions_resolved && + active_diff_discussions.all?(&:resolved?) && + project.resolve_outdated_diff_discussions? + MergeRequests::ResolvedDiscussionNotificationService + .new(project: project, current_user: current_user) + .execute(self) + end + end + # rubocop: enable CodeReuse/ServiceClass + + def keep_around_commit + return if async_keep_around_refs? + + project.repository.keep_around(self.merge_commit_sha, source: self.class.name) + end + + def enqueue_keep_around_commit + return unless merge_commit_sha.present? + return unless async_keep_around_refs? + + MergeRequests::KeepAroundRefsWorker.perform_async( + [project.id], + [merge_commit_sha], + self.class.name + ) + end + + def async_keep_around_refs? + strong_memoize(:async_keep_around_refs) do + Feature.enabled?(:async_keep_around_refs_for_merge_request_diffs, project, type: :gitlab_com_derisk) + end + end + + def has_commits? + merge_request_diff.persisted? && commits_count.to_i > 0 + end + + def has_no_commits? + !has_commits? + end + + def pipeline_coverage_delta + if base_pipeline&.coverage && head_pipeline&.coverage + head_pipeline.coverage - base_pipeline.coverage + end + end + + def comparison_base_pipeline(service_class) + target_shas = [ + diff_head_pipeline&.target_sha, + diff_base_sha, + diff_start_sha + ] + + target_shas + .compact + .lazy + .filter_map { |sha| target_branch_pipelines_for(sha: sha).last } + .first + end + + def merge_base_pipeline + target_sha = diff_head_pipeline&.target_sha + + return unless target_sha + + target_branch_pipelines_for(sha: target_sha).last + end + strong_memoize_attr :merge_base_pipeline + + def base_pipeline + target_branch_pipelines_for(sha: diff_base_sha).last + end + strong_memoize_attr :base_pipeline + + def discussions_rendered_on_frontend? + true + end + + # rubocop: disable CodeReuse/ServiceClass + def invalidate_project_counter_caches + Projects::OpenMergeRequestsCountService.new(target_project).delete_cache + end + # rubocop: enable CodeReuse/ServiceClass + + def first_contribution? + return metrics.first_contribution if merged? && metrics.present? + + !project.merge_requests.merged.exists?(author_id: author_id) + end + + # TODO: remove once production database rename completes + # https://gitlab.com/gitlab-org/gitlab-foss/issues/47592 + alias_attribute :allow_collaboration, :allow_maintainer_to_push + + def allow_collaboration + collaborative_push_possible? && allow_maintainer_to_push + end + + alias_method :allow_collaboration?, :allow_collaboration + + def collaborative_push_possible? + source_project.present? && for_fork? && + target_project.visibility_level > Gitlab::VisibilityLevel::PRIVATE && + source_project.visibility_level > Gitlab::VisibilityLevel::PRIVATE && + !ProtectedBranch.protected?(source_project, source_branch) + end + + def can_allow_collaboration?(user) + collaborative_push_possible? && + Ability.allowed?(user, :push_code, source_project) + end + + def find_diff_head_pipeline + all_pipelines.for_sha_or_source_sha(diff_head_sha).first + end + + def real_time_notes_enabled? + true + end + + def recent_visible_deployments + deployments.visible.includes(:environment).order(id: :desc).limit(10) + end + + def banzai_render_context(field) + super.merge(label_url_method: :project_merge_requests_url) + end + + def ensure_metrics! + MergeRequest::Metrics.record!(self) + end + + def allows_reviewers? + true + end + + def allows_multiple_assignees? + project.allows_multiple_merge_request_assignees? + end + + def allows_multiple_reviewers? + project.allows_multiple_merge_request_reviewers? + end + + def reviewer_auto_assignment_enabled? + project.project_setting.reviewer_auto_assignment_enabled? + end + + def supports_assignee? + true + end + + def find_reviewer(user) + merge_request_reviewers.find_by(user_id: user.id) + end + + def merge_request_reviewers_with(user_ids) + merge_request_reviewers.where(user_id: user_ids) + end + + def create_requested_changes(user) + # Overridden in EE + end + + def destroy_requested_changes(user) + # Overridden in EE + end + + def batch_update_reviewer_state(user_ids, state) + merge_request_reviewers.where(user_id: user_ids).update_all_state(state) + end + + def enabled_reports + { + sast: report_type_enabled?(:sast), + secret_detection: report_type_enabled?(:secret_detection) + } + end + + def includes_ci_config? + return false unless diff_stats + + diff_stats.map(&:path).include?(project.ci_config_path_or_default) + end + + def context_commits_diff + strong_memoize(:context_commits_diff) do + ContextCommitsDiff.new(self) + end + end + + def target_default_branch? + target_branch == project.default_branch + end + + def merge_blocked_by_other_mrs? + false # Overridden in EE + end + + def execute_merge_checks(checks, params: {}, execute_all: false, use_cache: true) + # rubocop: disable CodeReuse/ServiceClass + MergeRequests::Mergeability::RunChecksService + .new(merge_request: self, params: params) + .execute(checks, execute_all: execute_all, use_cache: use_cache) + # rubocop: enable CodeReuse/ServiceClass + end + + def can_suggest_reviewers? + false # overridden in EE + end + + def hidden? + author&.banned? + end + + def diffs_batch_cache_with_max_age? + Feature.enabled?(:diffs_batch_cache_with_max_age, project) + end + + def prepared? + prepared_at.present? + end + + def initial_preparation? + preparing? && !prepared? + end + + def check_for_spam?(*) + spammable_attribute_changed? && project.public? + end + + def missing_required_squash? + !squash && squash_always? + end + + def current_patch_id_sha + merge_request_diff.get_patch_id_sha + end + + def diff_head_pipeline_considered_in_progress? + return true if pipeline_creating? + + pipeline = diff_head_pipeline + return false unless pipeline + + # We allow auto-merge on blocked pipelines when "Pipelines must succeed" is + # enabled, because in that case the pipeline blocks the merge. When + # "Pipelines must succeed" is disabled, immediate merges are neither blocked + # nor discouraged on blocked pipelines, so auto merge should not wait for + # the pipeline to finish. + if only_allow_merge_if_pipeline_succeeds? + !pipeline.complete? + else + pipeline.active? || pipeline.created? + end + end + + def temporarily_unapproved? + false + end + + def has_jira_issue_keys? + return false unless project&.jira_integration&.reference_pattern + + Atlassian::JiraIssueKeyExtractor.has_keys?( + title, + description, + custom_regex: project.jira_integration.reference_pattern + ) + end + + def merge_exclusive_lease + lease_key = ['merge_requests_merge_service', id].join(':') + + Gitlab::ExclusiveLease.new(lease_key, timeout: MERGE_LEASE_TIMEOUT) + end + + def source_and_target_branches_exist? + source_branch_sha.present? && target_branch_sha.present? + end + + def has_diffs? + Gitlab::Git::Compare.new( + project.repository.raw_repository, + target_branch_sha, + source_branch_sha + ).diffs.any? + end + + def add_to_locked_set + Gitlab::MergeRequests::LockedSet.add(self.id, rescue_connection_error: false) + end + + def remove_from_locked_set + Gitlab::MergeRequests::LockedSet.remove(self.id) + end + + def first_diffs_slice(limit, diff_options = {}) + # use compare when MR is being created and diffs haven't been persisted yet + return compare.first_diffs_slice(limit, diff_options) if compare + # use context commits diff when only_context_commits is requested and context commits exist + return context_commits_diff.first_diffs_slice(limit, diff_options) if show_context_commits_diff?(diff_options) + + # default: resolve the diff version (handles version comparisons, specific diff_id, etc.) + diff = resolve_diff_version(diff_options) + diff.first_diffs_slice(limit, diff_options) + end + + def squash_option + target_project.project_setting + end + + delegate :squash_always?, :squash_never?, :squash_enabled_by_default?, :squash_readonly?, to: :squash_option + + def log_approval_deletion_on_merged_or_locked_mr(source:, current_user:, cause: nil) + return unless Feature.enabled?(:log_merged_mr_approval_deletion, target_project) + return unless merged? || locked? + + Gitlab::AppLogger.warn( + message: 'Approvals deleted on merged or locked MR', + source: source, + cause: cause, + merge_request_id: id, + merge_request_iid: iid, + merge_request_state: state, + project_id: target_project_id, + current_user_id: current_user&.id + ) + end + + def reached_versions_limit? + merge_request_diffs.count >= Gitlab::CurrentSettings.diff_max_versions + end + + def reached_diff_commits_limit? + total_commits_count = MergeRequestDiff + .from(merge_request_diffs.limit(1000), :limited_diffs) + .pick('SUM(commits_count)') + .to_i + + total_commits_count >= Gitlab::CurrentSettings.diff_max_commits + end + + def diffs_batch_cache_key + return unless diffs_batch_cache_with_max_age? + return latest_merge_request_diff&.patch_id_sha if cannot_be_merged? + + merge_head_diff&.patch_id_sha + end + + def commit_exists?(sha) + diff_commits_subquery = MergeRequestDiffCommit + .where('merge_request_diff_commits.merge_request_commits_metadata_id = merge_request_commits_metadata.id') + .where_exists( + merge_request_diffs.where('merge_request_diffs.id = merge_request_diff_commits.merge_request_diff_id') + ) + + diff_commits_subquery = diff_commits_subquery.where(project_id: target_project_id) if project_id_pruning_enabled? + + # Data can be found in either table until backfill completes. First look for SHAs in table + # `merge_request_commits_metadata`, if not found look in `merge_request_diff_commits`. + return true if MergeRequest::CommitsMetadata + .where(project: project, sha: sha) + .where_exists(diff_commits_subquery) + .exists? + + # We skip querying `merge_request_diff_commits` table when FF `mr_diff_commits_read_new_table` is enabled. + # This flag will only be enabled when new table is fully populated + return false if read_new_commits_table? + + all_commits.exists?(sha: sha) + end + + %w[ + merge_status + merge_params + merge_error + merge_user_id + merge_user + merge_jid + merge_commit_sha + merged_commit_sha + merge_ref_sha + squash_commit_sha + in_progress_merge_commit_sha + auto_merge_enabled + squash + ].each do |column_name| + define_method(:"#{column_name}=") do |value| + # rubocop:disable GitlabSecurity/PublicSend -- temporary manual delegation + with_merge_data_sync( + -> { super(value) }, + -> { merge_data.public_send(__method__, value) } + ) + # rubocop:enable GitlabSecurity/PublicSend + end + end + + def append_merge_params(params) + with_merge_data_sync( + -> { merge_params.merge!(params) }, + -> { merge_data.merge_params.merge!(params) } + ) + end + + def clear_merge_params(param_keys) + with_merge_data_sync( + -> { merge_params.except!(*param_keys) }, + -> { merge_data.merge_params.except!(*param_keys) } + ) + end + + def update_merge_ref_sha(sha) + with_single_transaction_merge_data_sync( + -> { update_column(:merge_ref_sha, sha) }, + -> do + # update_column only works if the record is already saved. + if merge_data.persisted? + merge_data.update_column(:merge_ref_sha, sha) + else + merge_data.update_attribute(:merge_ref_sha, sha) + end + end + ) + end + + def update_merge_jid(jid) + with_single_transaction_merge_data_sync( + -> { update_column(:merge_jid, jid) }, + -> do + # update_column only works if the record is already saved. + if merge_data.persisted? + merge_data.update_column(:merge_jid, jid) + else + merge_data.update_attribute(:merge_jid, jid) + end + end + ) + end + + def ensure_merge_data + # Eventually we probably need to set up a callback for this, but we need to + # conditionally initialize this for now. + record = merge_data || build_merge_data(merge_data_attributes) + + # id may not have been set if the merge_request got saved after the initial + # ensure_merge_data so we need to set merge_request_id here if it is not already set. + record.merge_request_id ||= id + record.project_id ||= target_project_id + record + end + + def viewable_recent_merge_request_diffs + merge_request_diffs + .viewable + .order_id_desc + .limit(Gitlab::CurrentSettings.diff_max_versions) + end + + def show_context_commits_diff?(diff_options) + diff_options[:only_context_commits] && context_commits_diff && !context_commits_diff.empty? + end + + private + + # Runs the block with `allow_broken` enabled, restoring the previous value + # afterwards. Used to bypass the structural validations that would otherwise + # block recovering a stuck `locked` merge request. Keep this private: a public + # generic validation escape hatch would be too easy to misuse. + def with_allow_broken + previous_allow_broken = allow_broken + self.allow_broken = true + yield + ensure + self.allow_broken = previous_allow_broken + end + + # Returns true when the in-memory state_id no longer matches what the primary + # database holds. This can happen because the instance was loaded from a + # replica that lagged behind primary, or because another process transitioned + # the MR (e.g. lock_mr, mark_as_merged) after this instance was loaded. The + # caller uses this to skip reload_diff and avoid writing an empty diff on top + # of a valid merged diff. + def state_changed_since_load? + primary_state_id = ::Gitlab::Database::LoadBalancing::SessionMap + .current(self.class.load_balancer) + .use_primary { self.class.where(id: id).pick(:state_id) } + + primary_state_id != state_id + end + + def publish_code_conflict_event + cloud_event = MergeRequests::CodeConflictEvent.build(merge_request: self) + Gitlab::EventStore.publish(cloud_event) if cloud_event + end + + def committer_emails_from_diff + return [] unless merge_request_diff&.persisted? + + committer_emails_query = + if read_new_commits_table? + committer_emails_via_metadata_query + else + Arel::Nodes::Union.new(committer_emails_via_metadata_query, committer_emails_via_direct_query) + end + + # This is a pure read used only for approval eligibility filtering, which + # can tolerate a few seconds of replication lag, and it is gated behind the + # approval_committer_emails_from_diff feature flag. We send it to a replica + # via select_all, which ConnectionProxy routes through the load balancer's + # read path; the previous select_values fell through to #method_missing and + # was treated as ambiguous, so it ran on the primary. The SQL is unchanged. + ::Gitlab::Database::LoadBalancing::SessionMap.use_replica_if_available do + ApplicationRecord.connection.select_all(committer_emails_query.to_sql).rows.flatten + end + end + strong_memoize_attr :committer_emails_from_diff + + def committer_emails_via_metadata_query + dc = MergeRequestDiffCommit.arel_table + m = MergeRequest::CommitsMetadata.arel_table + u = MergeRequest::DiffCommitUser.arel_table + + query = dc.project(u[:email]) + .join(m).on( + m[:id].eq(dc[:merge_request_commits_metadata_id]) + .and(m[:project_id].eq(target_project_id)) + ) + .join(u).on(u[:id].eq(m[:committer_id])) + .where(dc[:merge_request_diff_id].eq(merge_request_diff.id)) + .where(u[:email].not_eq(nil)) + + query = query.where(dc[:project_id].eq(target_project_id)) if project_id_pruning_enabled? + + query + end + + def committer_emails_via_direct_query + dc = MergeRequestDiffCommit.arel_table + u = MergeRequest::DiffCommitUser.arel_table + + dc.project(u[:email]) + .join(u).on(u[:id].eq(dc[:committer_id])) + .where(dc[:merge_request_diff_id].eq(merge_request_diff.id)) + .where(dc[:merge_request_commits_metadata_id].eq(nil)) + .where(u[:email].not_eq(nil)) + end + + def update_cached_closing_issues_from_description!(issues_to_close_ids) + # These might have been created manually from the work item interface + issue_ids_to_update = merge_request_closing_issues + .where(from_mr_description: false, issue_id: issues_to_close_ids) + .pluck(:issue_id) + + if issue_ids_to_update.any? + merge_request_closing_issues + .where(issue_id: issue_ids_to_update) + .update_all(from_mr_description: true) + end + + issue_ids_to_update + end + + def bulk_insert_cached_closing_issues(issue_ids_to_create) + now = Time.zone.now + new_associations = issue_ids_to_create.map do |issue_id| + MergeRequestsClosingIssues.new( + issue_id: issue_id, + merge_request_id: id, + from_mr_description: true, + link_type: :closes, + created_at: now, + updated_at: now + ) + end + + # We can't skip validations here in bulk insert as we don't have a unique constraint on the DB. + # We can skip validations once we have validated the unique constraint + # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/456965 + MergeRequestsClosingIssues.bulk_insert!(new_associations, batch_size: 100) + end + + def merge_data_attributes + { + project: project, + merge_status: read_attribute(:merge_status), + merge_params: read_attribute(:merge_params), + merge_error: read_attribute(:merge_error), + merge_user_id: read_attribute(:merge_user_id), + merge_jid: read_attribute(:merge_jid), + merge_commit_sha: read_attribute(:merge_commit_sha), + merged_commit_sha: read_attribute(:merged_commit_sha), + merge_ref_sha: read_attribute(:merge_ref_sha), + squash_commit_sha: read_attribute(:squash_commit_sha), + in_progress_merge_commit_sha: read_attribute(:in_progress_merge_commit_sha), + auto_merge_enabled: read_attribute(:auto_merge_enabled), + squash: read_attribute(:squash) + } + end + + def with_merge_data_sync(merge_request_block, merge_data_block) + if dual_write_to_merge_data_ff_enabled? + # We want to ensure the new merge_data is initialized with the current + # attributes before we update it. + ensure_merge_data + result = merge_request_block.call + + begin + merge_data_block.call + + rescue StandardError => e + Gitlab::ErrorTracking.track_exception(e, + message: "Failed to sync MergeData", + merge_request_id: id + ) + end + + result + else + merge_request_block.call + end + end + + def with_single_transaction_merge_data_sync(merge_request_block, merge_data_block) + if dual_write_to_merge_data_ff_enabled? + result = nil + ensure_merge_data + + ApplicationRecord.transaction do + result = merge_request_block.call + merge_data_block.call + end + + result + else + merge_request_block.call + end + end + + def save_merge_data_changes + return unless dual_write_to_merge_data_ff_enabled? + + ensure_merge_data + return unless merge_data&.changed? + + # id is set when merge_request gets saved so we need to manually + # set merge_request_id here if it is not already set. + merge_data.merge_request_id ||= id + merge_data.project_id ||= target_project_id + merge_data.save! + end + + def dual_write_to_merge_data_ff_enabled? + Feature.enabled?(:merge_requests_merge_data_dual_write, project) + end + strong_memoize_attr :dual_write_to_merge_data_ff_enabled? + + def viewable_diffs(order_by: :id_asc) + strong_memoize_with(:viewable_diffs, order_by) do + merge_request_diffs.viewable.order_by(order_by).to_a + end + end + + def target_branch_pipelines_for(sha:) + project.ci_pipelines + .where(sha: sha, ref: target_branch) + end + + def set_draft_status + self.draft = draft? + end + + def expire_ancestor_cache + project.repository.expire_ancestor_cache(target_branch_sha, diff_head_sha) + end + + def missing_report_error(report_type) + { status: :error, status_reason: "This merge request does not have #{report_type} reports." } + end + + def with_rebase_lock + with_retried_nowait_lock { yield } + end + + # If the merge request is idle in transaction or has a SELECT FOR + # UPDATE, we don't want to block indefinitely or this could cause a + # queue of SELECT FOR UPDATE calls. Instead, try to get the lock for + # 5 s before raising an error to the user. + def with_retried_nowait_lock + # Try at most 0.25 + (1.5 * .25) + (1.5^2 * .25) ... (1.5^5 * .25) = 5.2 s to get the lock + Retriable.retriable(on: ActiveRecord::LockWaitTimeout, tries: 6, base_interval: 0.25) do + with_lock('FOR UPDATE NOWAIT') do + yield + end + end + rescue ActiveRecord::LockWaitTimeout => e + Gitlab::ErrorTracking.track_exception(e) + raise RebaseLockTimeout, _('Failed to enqueue the rebase operation, possibly due to a long-lived transaction. Try again later.') + end + + def source_project_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + break variables unless source_project + + variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_ID', value: source_project.id.to_s) + variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_PATH', value: source_project.full_path) + variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_URL', value: source_project.web_url) + variables.append(key: 'CI_MERGE_REQUEST_SOURCE_BRANCH_NAME', value: source_branch.to_s) + variables.append(key: 'CI_MERGE_REQUEST_SOURCE_BRANCH_PROTECTED', value: ProtectedBranch.protected?(source_project, source_branch).to_s) + end + end + + def expire_etag_cache + return unless project.namespace + + key = Gitlab::Routing.url_helpers.cached_widget_project_json_merge_request_path(project, self, format: :json) + Gitlab::EtagCaching::Store.new.touch(key) + end + + def report_type_enabled?(report_type) + supported_report_types_for_child_pipelines = [:sast, :secret_detection, :container_scanning] + + if report_type == :license_scanning + ::Gitlab::LicenseScanning.scanner_for_pipeline(project, diff_head_pipeline).has_data? + elsif supported_report_types_for_child_pipelines.include?(report_type) + pipeline_has_report_in_self_or_descendants?(report_type) + else + !!diff_head_pipeline&.batch_lookup_report_artifact_for_file_type(report_type) + end + end + + def truncate_mr_description + if description && description.length > CI_MERGE_REQUEST_DESCRIPTION_MAX_LENGTH + [description.truncate(CI_MERGE_REQUEST_DESCRIPTION_MAX_LENGTH), 'true'] + else + [description, 'false'] + end + end + + def pipeline_has_report_in_self_or_descendants?(report_type) + !!diff_head_pipeline + &.latest_report_builds_in_self_and_project_descendants(::Ci::JobArtifact.of_report_type(report_type)) + &.exists? + end + + def all_commit_shas_from_metadata + commits_subquery = all_commits.select(:merge_request_commits_metadata_id) + migrated_shas = MergeRequest::CommitsMetadata + .joins( + "INNER JOIN (#{commits_subquery.to_sql}) AS diff_commits " \ + "ON diff_commits.merge_request_commits_metadata_id = merge_request_commits_metadata.id" + ) + .where(project_id: project_id) + .pluck(:sha) + + return migrated_shas.uniq if read_new_commits_table? + + # We need to query SHAs from `merge_request_diff_commits` table to account + # for records that don't have `merge_request_commits_metadata_id` populated yet + unmigrated_shas = all_commits.where(merge_request_commits_metadata_id: nil).pluck(:sha) + + (migrated_shas + unmigrated_shas).uniq + end + + def resolve_diff_version(diff_options = {}) + params = { + diff_id: diff_options.delete(:diff_id), + start_sha: diff_options.delete(:start_sha), + commit_id: diff_options.delete(:commit_id) + }.compact + + ::Gitlab::MergeRequests::DiffResolver.new(self, params).resolve + end + + def read_new_commits_table? + Feature.enabled?(:mr_diff_commits_read_new_table, project) + end + strong_memoize_attr :read_new_commits_table? + + def project_id_pruning_enabled? + MergeRequestDiffCommit.project_id_pruning_enabled?(target_project_id) + end + strong_memoize_attr :project_id_pruning_enabled? +end + +MergeRequest.prepend_mod_with('MergeRequest') diff --git a/spec/fixtures/corpus/gitlab/app/models/project.rb b/spec/fixtures/corpus/gitlab/app/models/project.rb new file mode 100644 index 0000000..50e5a12 --- /dev/null +++ b/spec/fixtures/corpus/gitlab/app/models/project.rb @@ -0,0 +1,4147 @@ +# frozen_string_literal: true + +require 'carrierwave/orm/activerecord' + +class Project < ApplicationRecord + include Gitlab::ConfigHelper + include Gitlab::VisibilityLevel + include AccessRequestable + include Authz::HasRoles + include Avatarable + include CacheMarkdownField + include Sortable + include AfterCommitQueue + include CaseSensitivity + include TokenAuthenticatable + include ValidAttribute + include ProjectAPICompatibility + include ProjectFeaturesCompatibility + include SelectForProjectAuthorization + include Presentable + include HasRepository + include HasWiki + include WebHooks::HasWebHooks + include CanMoveRepositoryStorage + include Routable + include GroupDescendant + include Gitlab::SQL::Pattern + include DeploymentPlatform + include ::Gitlab::Utils::StrongMemoize + include ChronicDurationAttribute + include FastDestroyAll::Helpers + include WithUploads + include BatchDestroyDependentAssociations + include FeatureGate + include OptionallySearch + include FromUnion + include ::Repositories::CanHousekeepRepository + include EachBatch + include GitlabRoutingHelper + include BulkMemberAccessLoad + include BulkUsersByEmailLoad + include RunnerTokenExpirationInterval + include BlocksUnsafeSerialization + include Subquery + include WorkItems::Parent + include UpdatedAtFilterable + include UseSqlFunctionForPrimaryKeyLookups + include Importable + include SafelyChangeColumnDefault + include Todoable + include Namespaces::AdjournedDeletable + include Cells::Claimable + + cells_claims_attribute :id, type: CLAIMS_BUCKET_TYPE::PROJECT_IDS, feature_flag: :cells_claims_projects + + cells_claims_metadata subject_type: CLAIMS_SUBJECT_TYPE::ORGANIZATION, subject_key: :organization_id + + columns_changing_default :organization_id + + ignore_column :emails_disabled, remove_with: '16.3', remove_after: '2023-08-22' + + extend Gitlab::Cache::RequestCache + extend Gitlab::Utils::Override + + extend Gitlab::ConfigHelper + + BoardLimitExceeded = Class.new(StandardError) + ExportLimitExceeded = Class.new(StandardError) + ExportAlreadyInProgress = Class.new(StandardError) + + EPOCH_CACHE_EXPIRATION = 30.days + STATISTICS_ATTRIBUTE = 'repositories_count' + UNKNOWN_IMPORT_URL = 'http://unknown.git' + # Hashed Storage versions handle rolling out new storage to project and dependents models: + # nil: legacy + # 1: repository + # 2: attachments + LATEST_STORAGE_VERSION = 2 + HASHED_STORAGE_FEATURES = { + repository: 1, + attachments: 2 + }.freeze + + VALID_IMPORT_PORTS = [80, 443].freeze + VALID_IMPORT_PROTOCOLS = %w[http https git].freeze + + VALID_MIRROR_PORTS = [22, 80, 443].freeze + VALID_MIRROR_PROTOCOLS = %w[http https ssh git].freeze + + SORTING_PREFERENCE_FIELD = :projects_sort + MAX_BUILD_TIMEOUT = 1.month + + GL_REPOSITORY_TYPES = [Gitlab::GlRepository::PROJECT, Gitlab::GlRepository::WIKI, Gitlab::GlRepository::DESIGN].freeze + + MAX_SUGGESTIONS_TEMPLATE_LENGTH = 255 + MAX_COMMIT_TEMPLATE_LENGTH = 500 + MAX_MR_TITLE_TEMPLATE_LENGTH = 100 + MAX_MERGE_REQUEST_TITLE_REGEX = 255 + MAX_MERGE_REQUEST_TITLE_REGEX_DESCRIPTION = 255 + + INSTANCE_RUNNER_RUNNING_JOBS_MAX_BUCKET = 5 + + DEFAULT_MERGE_COMMIT_TEMPLATE = <<~MSG.rstrip.freeze + Merge branch '%{source_branch}' into '%{target_branch}' + + %{title} + + %{issues} + + See merge request %{reference} + MSG + + DEFAULT_SQUASH_COMMIT_TEMPLATE = '%{title}' + + PROJECT_FEATURES_DEFAULTS = { + issues: gitlab_config_features.issues, + merge_requests: gitlab_config_features.merge_requests, + builds: gitlab_config_features.builds, + wiki: gitlab_config_features.wiki, + snippets: gitlab_config_features.snippets + }.freeze + + # List of attributes that, when updated, should be considered as Project Activity + PROJECT_ACTIVITY_ATTRIBUTES = %w[description name].freeze + + cache_markdown_field :description, pipeline: :description + + attribute :packages_enabled, default: true + attribute :archived, default: false + attribute :resolve_outdated_diff_discussions, default: false + attribute :repository_storage, default: -> { Repository.pick_storage_shard } + attribute :shared_runners_enabled, default: -> { Gitlab::CurrentSettings.shared_runners_enabled } + attribute :only_allow_merge_if_all_discussions_are_resolved, default: false + attribute :remove_source_branch_after_merge, default: true + attribute :autoclose_referenced_issues, default: true + attribute :ci_config_path, default: -> { Gitlab::CurrentSettings.default_ci_config_path } + + add_authentication_token_field :runners_token, + encrypted: :required, + format_with_prefix: :runners_token_prefix, + require_prefix_for_validation: true + + # Storage specific hooks + after_initialize :use_hashed_storage + after_initialize :set_project_feature_defaults, if: :new_record? + + before_validation :mark_remote_mirrors_for_removal, if: -> { RemoteMirror.table_exists? } + before_validation :ensure_project_namespace_in_sync + before_validation :set_package_registry_access_level, if: :packages_enabled_changed? + before_validation :remove_leading_spaces_on_name + before_validation :set_last_activity_at + + after_validation :check_pending_delete + + before_save :ensure_runners_token + + after_create -> { create_or_load_association(:project_feature) } + after_create -> { create_or_load_association(:ci_cd_settings) } + after_create -> { create_or_load_association(:container_expiration_policy) } + after_create -> { create_or_load_association(:pages_metadatum) } + after_create :set_timestamps_for_create + after_create :check_repository_absence! + + after_update :enqueue_catalog_resource_sync_event_worker, + if: -> { catalog_resource && (saved_change_to_name? || saved_change_to_description? || saved_change_to_visibility_level?) } + + before_destroy :remove_private_deploy_keys + after_destroy :remove_exports + + after_save :update_project_statistics, if: :saved_change_to_namespace_id? + + after_save :schedule_sync_event_worker, if: -> { saved_change_to_id? || saved_change_to_namespace_id? } + + after_save :create_import_state, if: ->(project) do + project.import? && project.import_state.nil? && (!project.transfer_import? || project.mirror?) + end + + after_save :save_topics + + after_save :reload_project_namespace_details + + has_many :project_topics, -> { order(:id) }, class_name: 'Projects::ProjectTopic' + has_many :topics, through: :project_topics, class_name: 'Projects::Topic' + + attr_accessor :old_path_with_namespace + attr_accessor :template_name + attr_writer :pipeline_status + attr_accessor :skip_disk_validation + attr_writer :topic_list + + alias_attribute :title, :name + + ## marked_for_deletion_at is deprecated in our v5 REST API in favor of marked_for_deletion_on + ## https://docs.gitlab.com/ee/api/projects.html#removals-in-api-v5 + alias_attribute :marked_for_deletion_on, :marked_for_deletion_at + + # Relations + belongs_to :deleting_user, foreign_key: 'marked_for_deletion_by_user_id', class_name: 'User' + belongs_to :pool_repository + belongs_to :creator, class_name: 'User' + belongs_to :organization, class_name: 'Organizations::Organization' + belongs_to :group, -> { where(type: Group.sti_name) }, foreign_key: 'namespace_id' + alias_method :notification_group, :group + belongs_to :namespace + # Sync deletion via DB Trigger to ensure we do not have + # a project without a project_namespace (or vice-versa) + belongs_to :project_namespace, autosave: true, class_name: 'Namespaces::ProjectNamespace', foreign_key: 'project_namespace_id', inverse_of: :project, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent -- needed to unclaim + + alias_method :parent, :namespace + alias_attribute :parent_id, :namespace_id + + has_one :catalog_resource, class_name: 'Ci::Catalog::Resource', inverse_of: :project + has_many :ci_components, class_name: 'Ci::Catalog::Resources::Component', inverse_of: :project + has_many :ci_component_last_usages, class_name: 'Ci::Catalog::Resources::Components::LastUsage', inverse_of: :component_project + has_many :catalog_resource_versions, class_name: 'Ci::Catalog::Resources::Version', inverse_of: :project + has_many :catalog_resource_sync_events, class_name: 'Ci::Catalog::Resources::SyncEvent', inverse_of: :project + + has_one :last_event, -> { order 'events.created_at DESC' }, class_name: 'Event' + has_many :boards + + def self.integration_association_name(name) + "#{name}_integration" + end + + # Project integrations + has_one :apple_app_store_integration, class_name: 'Integrations::AppleAppStore' + has_one :asana_integration, class_name: 'Integrations::Asana' + has_one :assembla_integration, class_name: 'Integrations::Assembla' + has_one :bamboo_integration, class_name: 'Integrations::Bamboo' + has_one :beyond_identity_integration, class_name: 'Integrations::BeyondIdentity' + has_one :bugzilla_integration, class_name: 'Integrations::Bugzilla' + has_one :buildkite_integration, class_name: 'Integrations::Buildkite' + has_one :campfire_integration, class_name: 'Integrations::Campfire' + has_one :clickup_integration, class_name: 'Integrations::Clickup' + has_one :confluence_integration, class_name: 'Integrations::Confluence' + has_one :custom_issue_tracker_integration, class_name: 'Integrations::CustomIssueTracker' + has_one :datadog_integration, class_name: 'Integrations::Datadog' + has_one :diffblue_cover_integration, class_name: 'Integrations::DiffblueCover' + has_one :discord_integration, class_name: 'Integrations::Discord' + has_one :drone_ci_integration, class_name: 'Integrations::DroneCi' + has_one :emails_on_push_integration, class_name: 'Integrations::EmailsOnPush' + has_one :ewm_integration, class_name: 'Integrations::Ewm' + has_one :external_wiki_integration, class_name: 'Integrations::ExternalWiki' + has_one :gitlab_slack_application_integration, class_name: 'Integrations::GitlabSlackApplication' + has_one :google_play_integration, class_name: 'Integrations::GooglePlay' + has_one :hangouts_chat_integration, class_name: 'Integrations::HangoutsChat' + has_one :harbor_integration, class_name: 'Integrations::Harbor' + has_one :irker_integration, class_name: 'Integrations::Irker' + has_one :jenkins_integration, class_name: 'Integrations::Jenkins' + has_one :jira_integration, class_name: 'Integrations::Jira' + has_one :jira_cloud_app_integration, class_name: 'Integrations::JiraCloudApp' + has_one :linear_integration, class_name: 'Integrations::Linear' + has_one :mattermost_integration, class_name: 'Integrations::Mattermost' + has_one :mattermost_slash_commands_integration, class_name: 'Integrations::MattermostSlashCommands' + has_one :matrix_integration, class_name: 'Integrations::Matrix' + has_one :microsoft_teams_integration, class_name: 'Integrations::MicrosoftTeams' + has_one :mock_ci_integration, class_name: 'Integrations::MockCi' + has_one :mock_monitoring_integration, class_name: 'Integrations::MockMonitoring' + has_one :packagist_integration, class_name: 'Integrations::Packagist' + has_one :phorge_integration, class_name: 'Integrations::Phorge' + has_one :pipelines_email_integration, class_name: 'Integrations::PipelinesEmail' + has_one :pivotaltracker_integration, class_name: 'Integrations::Pivotaltracker' + has_one :prometheus_integration, class_name: 'Integrations::Prometheus', inverse_of: :project + has_one :pumble_integration, class_name: 'Integrations::Pumble' + has_one :pushover_integration, class_name: 'Integrations::Pushover' + has_one :redmine_integration, class_name: 'Integrations::Redmine' + has_one :slack_integration, class_name: 'Integrations::Slack' + has_one :squash_tm_integration, class_name: 'Integrations::SquashTm' + has_one :teamcity_integration, class_name: 'Integrations::Teamcity' + has_one :telegram_integration, class_name: 'Integrations::Telegram' + has_one :unify_circuit_integration, class_name: 'Integrations::UnifyCircuit' + has_one :webex_teams_integration, class_name: 'Integrations::WebexTeams' + has_one :youtrack_integration, class_name: 'Integrations::Youtrack' + has_one :zentao_integration, class_name: 'Integrations::Zentao' + + has_one :wiki_repository, class_name: 'Projects::WikiRepository', inverse_of: :project + has_one :design_management_repository, class_name: 'DesignManagement::Repository', inverse_of: :project + has_one :root_of_fork_network, foreign_key: 'root_project_id', inverse_of: :root_project, class_name: 'ForkNetwork' + has_one :fork_network_member + has_one :fork_network, through: :fork_network_member + has_one :forked_from_project, through: :fork_network_member + + # Projects with a very large number of notes may time out destroying them + # through the foreign key. + # + # https://gitlab.com/gitlab-org/gitlab/-/issues/207222 + # Order of this association is important for project deletion. + # has_many :notes` should be the first association among all `has_many` associations. + has_many :notes, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + + has_many :forked_to_members, class_name: 'ForkNetworkMember', foreign_key: 'forked_from_project_id' + has_many :forks, through: :forked_to_members, source: :project, inverse_of: :forked_from_project + has_many :fork_network_projects, through: :fork_network, source: :projects + + # Packages + has_many :packages, class_name: 'Packages::Package' + # repository_files must be destroyed by ruby code in order to properly remove carrierwave uploads + has_many :rpm_repository_files, + inverse_of: :project, + class_name: 'Packages::Rpm::RepositoryFile', + dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + # debian_distributions and associated component_files must be destroyed by ruby code in order to properly remove carrierwave uploads + has_many :debian_distributions, + class_name: 'Packages::Debian::ProjectDistribution', + dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :npm_metadata_caches, class_name: 'Packages::Npm::MetadataCache' + has_many :helm_metadata_caches, class_name: 'Packages::Helm::MetadataCache' + has_many :rubygems_spec_files, class_name: 'Packages::Rubygems::SpecFile', inverse_of: :project + has_one :packages_cleanup_policy, class_name: 'Packages::Cleanup::Policy', inverse_of: :project + has_many :package_protection_rules, + class_name: 'Packages::Protection::Rule', + inverse_of: :project + + has_one :import_state, autosave: true, class_name: 'ProjectImportState', inverse_of: :project + has_many :import_export_uploads, dependent: :destroy, inverse_of: :project # rubocop:disable Cop/ActiveRecordDependent -- Previously was has_one association, dependent: :destroy to be removed in a separate issue and cascade FK will be added + has_many :relation_import_trackers, class_name: 'Projects::ImportExport::RelationImportTracker', inverse_of: :project + has_many :export_jobs, class_name: 'ProjectExportJob' + has_many :bulk_import_exports, class_name: 'BulkImports::Export', inverse_of: :project + has_many :relation_export_uploads, class_name: 'Projects::ImportExport::RelationExportUpload', inverse_of: :project + has_one :project_repository, inverse_of: :project + has_one :incident_management_setting, inverse_of: :project, class_name: 'IncidentManagement::ProjectIncidentManagementSetting' + has_one :error_tracking_setting, inverse_of: :project, class_name: 'ErrorTracking::ProjectErrorTrackingSetting' + has_one :project_setting, inverse_of: :project, autosave: true + has_one :service_desk_setting, class_name: 'ServiceDeskSetting' + has_one :service_desk_custom_email_verification, class_name: 'ServiceDesk::CustomEmailVerification' + has_one :service_desk_custom_email_credential, class_name: 'ServiceDesk::CustomEmailCredential' + + # Merge requests for target project should be removed with it + has_many :merge_requests, foreign_key: 'target_project_id', inverse_of: :target_project, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :merge_request_metrics, foreign_key: 'target_project', class_name: 'MergeRequest::Metrics', inverse_of: :target_project + has_many :source_of_merge_requests, foreign_key: 'source_project_id', class_name: 'MergeRequest' + has_many :issues, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :work_items # the issues relation will handle any destroys + has_many :incident_management_issuable_escalation_statuses, through: :issues, inverse_of: :project, class_name: 'IncidentManagement::IssuableEscalationStatus' + has_many :incident_management_timeline_event_tags, inverse_of: :project, class_name: 'IncidentManagement::TimelineEventTag' + has_many :labels, class_name: 'ProjectLabel', dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :events + has_many :milestones + + has_many :integrations + has_many :alert_hooks_integrations, -> { alert_hooks }, class_name: 'Integration' + has_many :incident_hooks_integrations, -> { incident_hooks }, class_name: 'Integration' + has_many :archive_trace_hooks_integrations, -> { archive_trace_hooks }, class_name: 'Integration' + has_many :confidential_issue_hooks_integrations, -> { confidential_issue_hooks }, class_name: 'Integration' + has_many :confidential_note_hooks_integrations, -> { confidential_note_hooks }, class_name: 'Integration' + has_many :deployment_hooks_integrations, -> { deployment_hooks }, class_name: 'Integration' + has_many :issue_hooks_integrations, -> { issue_hooks }, class_name: 'Integration' + has_many :job_hooks_integrations, -> { job_hooks }, class_name: 'Integration' + has_many :merge_request_hooks_integrations, -> { merge_request_hooks }, class_name: 'Integration' + has_many :note_hooks_integrations, -> { note_hooks }, class_name: 'Integration' + has_many :pipeline_hooks_integrations, -> { pipeline_hooks }, class_name: 'Integration' + has_many :push_hooks_integrations, -> { push_hooks }, class_name: 'Integration' + has_many :tag_push_hooks_integrations, -> { tag_push_hooks }, class_name: 'Integration' + has_many :wiki_page_hooks_integrations, -> { wiki_page_hooks }, class_name: 'Integration' + has_many :snippets, class_name: 'ProjectSnippet' + has_many :snippet_repositories, inverse_of: :project + has_many :hooks, class_name: 'ProjectHook' + has_many :protected_branches + has_many :exported_protected_branches + has_many :all_protected_branches, ->(project) { ProtectedBranch.unscope(:where).from_union(project.protected_branches, project.group_protected_branches) }, class_name: 'ProtectedBranch' + + has_many :protected_tags + has_many :repository_languages, -> { order "share DESC" } + has_many :designs, inverse_of: :project, class_name: 'DesignManagement::Design' + + has_many :project_authorizations + has_many :authorized_users, -> { allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/422045') }, + through: :project_authorizations, source: :user, class_name: 'User' + + has_many :project_members, -> { non_request }, + as: :source, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent + alias_method :members, :project_members + has_many :namespace_members, ->(project) { where(requested_at: nil).unscope(where: %i[source_id source_type]) }, + primary_key: :project_namespace_id, foreign_key: :member_namespace_id, inverse_of: :project, class_name: 'ProjectMember' + + has_many :requesters, -> { where.not(requested_at: nil) }, + as: :source, class_name: 'ProjectMember', dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent + has_many :namespace_requesters, ->(project) { where.not(requested_at: nil).unscope(where: %i[source_id source_type]) }, + primary_key: :project_namespace_id, foreign_key: :member_namespace_id, inverse_of: :project, class_name: 'ProjectMember' + + has_many :members_and_requesters, as: :source, class_name: 'ProjectMember' + + has_many :namespace_members_and_requesters, -> { unscope(where: %i[source_id source_type]) }, + primary_key: :project_namespace_id, foreign_key: :member_namespace_id, inverse_of: :project, + class_name: 'ProjectMember' + + has_many :users, -> { allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/422405") }, + through: :project_members + has_many :provisioned_user_details, class_name: 'UserDetail', foreign_key: 'provisioned_by_project_id', inverse_of: :provisioned_by_project + has_many :provisioned_users, through: :provisioned_user_details, source: :user + + has_many :maintainers, + -> do + allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/422405") + .where(members: { access_level: Gitlab::Access::MAINTAINER }) + end, + through: :project_members, + source: :user + + has_many :owners_and_maintainers, + -> do + where(members: { access_level: [Gitlab::Access::OWNER, Gitlab::Access::MAINTAINER] }) + end, + through: :project_members, + source: :user + + has_many :project_callouts, class_name: 'Users::ProjectCallout', foreign_key: :project_id + + has_many :deploy_keys_projects, inverse_of: :project + has_many :deploy_keys, through: :deploy_keys_projects + has_many :users_star_projects + has_many :starrers, through: :users_star_projects, source: :user + has_many :releases + has_many :lfs_objects_projects + has_many :lfs_objects, -> { distinct }, through: :lfs_objects_projects + has_many :lfs_file_locks + has_many :project_group_links + has_many :invited_groups, through: :project_group_links, source: :group do + def with_developer_access + where(project_group_links: { group_access: [Gitlab::Access::DEVELOPER..Gitlab::Access::OWNER] }) + end + end + + has_many :todos + has_many :notification_settings, as: :source, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent + + has_many :internal_ids + + has_one :import_data, class_name: 'ProjectImportData', inverse_of: :project, autosave: true + has_one :project_feature, inverse_of: :project + has_one :statistics, class_name: 'ProjectStatistics' + has_one :feature_usage, class_name: 'ProjectFeatureUsage' + + has_one :cluster_project, class_name: 'Clusters::Project' + has_many :clusters, through: :cluster_project, class_name: 'Clusters::Cluster' + has_many :kubernetes_namespaces, class_name: 'Clusters::KubernetesNamespace' + has_many :management_clusters, class_name: 'Clusters::Cluster', foreign_key: :management_project_id, inverse_of: :management_project + has_many :cluster_agents, class_name: 'Clusters::Agent' + has_many :ci_access_project_authorizations, class_name: 'Clusters::Agents::Authorizations::CiAccess::ProjectAuthorization' + + has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :project + has_many :alert_management_http_integrations, class_name: 'AlertManagement::HttpIntegration', inverse_of: :project + + has_many :container_registry_protection_rules, class_name: 'ContainerRegistry::Protection::Rule', inverse_of: :project + has_many :container_registry_protection_tag_rules, class_name: 'ContainerRegistry::Protection::TagRule', inverse_of: :project + + # Container repositories need to remove data from the container registry, + # which is not managed by the DB. Hence we're still using dependent: :destroy + # here. + has_many :container_repositories, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_one :container_expiration_policy, inverse_of: :project + + has_many :commit_statuses + # The relation :all_pipelines is intended to be used when we want to get the + # whole list of pipelines associated to the project + has_many :all_pipelines, class_name: 'Ci::Pipeline', inverse_of: :project + # The relation :ci_pipelines includes all those that directly contribute to the + # latest status of a ref. This does not include dangling pipelines such as those + # from webide, child pipelines, etc. + has_many :ci_pipelines, -> { ci_sources }, class_name: 'Ci::Pipeline', inverse_of: :project + has_many :stages, class_name: 'Ci::Stage', inverse_of: :project + has_many :ci_refs, class_name: 'Ci::Ref', inverse_of: :project + has_many :pipeline_metadata, class_name: 'Ci::PipelineMetadata', inverse_of: :project + has_many :pending_builds, class_name: 'Ci::PendingBuild' + has_many :builds, class_name: 'Ci::Build', inverse_of: :project + has_many :bridges, class_name: 'Ci::Bridge', inverse_of: :project + has_many :processables, class_name: 'Ci::Processable', inverse_of: :project + has_many :build_report_results, class_name: 'Ci::BuildReportResult', inverse_of: :project + has_many :job_artifacts, class_name: 'Ci::JobArtifact', dependent: :restrict_with_error + has_many :pipeline_artifacts, class_name: 'Ci::PipelineArtifact', inverse_of: :project, dependent: :restrict_with_error + has_many :runner_projects, class_name: 'Ci::RunnerProject', inverse_of: :project + has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' + has_many :variables, class_name: 'Ci::Variable' + has_many :triggers, -> { not_expired }, class_name: 'Ci::Trigger' + has_many :secure_files, class_name: 'Ci::SecureFile', dependent: :restrict_with_error + has_many :environments + has_many :environments_for_dashboard, -> { from(with_rank.unfoldered.available, :environments).where('rank <= 3') }, class_name: 'Environment' + has_many :deployments + has_many :pipeline_schedules, class_name: 'Ci::PipelineSchedule' + has_many :project_deploy_tokens + has_many :deploy_tokens, through: :project_deploy_tokens + has_many :resource_groups, class_name: 'Ci::ResourceGroup', inverse_of: :project + has_many :freeze_periods, class_name: 'Ci::FreezePeriod', inverse_of: :project + + has_one :auto_devops, class_name: 'ProjectAutoDevops', inverse_of: :project, autosave: true + has_many :custom_attributes, class_name: 'ProjectCustomAttribute' + + has_many :project_badges, class_name: 'ProjectBadge', inverse_of: :project + has_one :ci_cd_settings, class_name: 'ProjectCiCdSetting', inverse_of: :project, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + + has_many :remote_mirrors, inverse_of: :project + has_many :external_pull_requests, inverse_of: :project, class_name: 'Ci::ExternalPullRequest' + + has_many :sourced_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :source_project_id + has_many :source_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :project_id + + has_many :import_failures, inverse_of: :project + has_many :jira_imports, -> { order(JiraImportState.arel_table[:created_at].asc) }, class_name: 'JiraImportState', inverse_of: :project + + has_many :daily_build_group_report_results, class_name: 'Ci::DailyBuildGroupReportResult' + has_many :ci_feature_usages, class_name: 'Projects::CiFeatureUsage' + + has_many :repository_storage_moves, class_name: 'Projects::RepositoryStorageMove', inverse_of: :container + + has_many :webide_pipelines, -> { webide_source }, class_name: 'Ci::Pipeline', inverse_of: :project + has_many :reviews, inverse_of: :project + + has_many :terraform_states, class_name: 'Terraform::State', inverse_of: :project + has_many :terraform_state_protection_rules, class_name: 'Terraform::StateProtectionRule', inverse_of: :project + + # GitLab Pages + has_many :pages_domains + has_one :pages_metadatum, class_name: 'ProjectPagesMetadatum', inverse_of: :project + # rubocop:disable Cop/ActiveRecordDependent -- we need to clean up files, not only remove records + has_many :pages_deployments, dependent: :destroy, inverse_of: :project + # rubocop:enable Cop/ActiveRecordDependent + has_many :active_pages_deployments, -> { active.order_by(:created_desc) }, class_name: 'PagesDeployment', inverse_of: :project + + has_many :operations_feature_flags, class_name: 'Operations::FeatureFlag' + has_one :operations_feature_flags_client, class_name: 'Operations::FeatureFlagsClient' + has_many :operations_feature_flags_user_lists, class_name: 'Operations::FeatureFlags::UserList' + + has_many :error_tracking_client_keys, inverse_of: :project, class_name: 'ErrorTracking::ClientKey' + + has_many :timelogs + + has_one :ci_project_mirror, class_name: 'Ci::ProjectMirror' + has_one :ci_project_metric, class_name: 'Ci::ProjectMetric' + has_many :sync_events, class_name: 'Projects::SyncEvent' + + has_one :build_artifacts_size_refresh, class_name: 'Projects::BuildArtifactsSizeRefresh' + + accepts_nested_attributes_for :variables, allow_destroy: true + accepts_nested_attributes_for :project_feature, update_only: true + accepts_nested_attributes_for :project_setting, update_only: true + accepts_nested_attributes_for :import_data + accepts_nested_attributes_for :auto_devops, update_only: true + accepts_nested_attributes_for :ci_cd_settings, update_only: true + accepts_nested_attributes_for :container_expiration_policy, update_only: true + + accepts_nested_attributes_for :remote_mirrors, + allow_destroy: true, + reject_if: ->(attrs) { attrs[:id].blank? && attrs[:url].blank? } + + accepts_nested_attributes_for :incident_management_setting, update_only: true + accepts_nested_attributes_for :error_tracking_setting, update_only: true + + with_options to: :project_namespace, allow_nil: true do + delegate :allowed_work_item_types, + :allowed_work_item_type?, + :supports_work_items?, + :state, + :archive, + :unarchive, + :schedule_deletion, + :start_deletion!, + :reschedule_deletion!, + :cancel_deletion, + :cancel_deletion!, + :deletion_error, + :deletion_error=, + :deletion_in_progress?, + :deletion_scheduled?, + :namespace_details, + :deletion_scheduled_at, + :deletion_scheduled_at= + end + + delegate :merge_requests_access_level, :forking_access_level, :issues_access_level, :wiki_access_level, :snippets_access_level, :builds_access_level, :repository_access_level, :package_registry_access_level, :pages_access_level, :metrics_dashboard_access_level, :analytics_access_level, :operations_access_level, :security_and_compliance_access_level, :container_registry_access_level, :environments_access_level, :feature_flags_access_level, :monitor_access_level, :releases_access_level, :infrastructure_access_level, :model_experiments_access_level, :model_registry_access_level, to: :project_feature, allow_nil: true + delegate :name, to: :owner, allow_nil: true, prefix: true + delegate :jira_dvcs_server_last_sync_at, to: :feature_usage + delegate :last_pipeline, to: :commit, allow_nil: true + delegate :import_user, to: :root_ancestor + + with_options to: :team do + delegate :members, prefix: true + delegate :add_member, :add_members, :member?, :max_member_access_for_user + delegate :add_guest, :add_planner, :add_reporter, :add_security_manager, :add_developer, :add_maintainer, :add_owner, :add_role + delegate :has_user? + end + + with_options to: :namespace do + delegate :actual_limits, :actual_plan_name, :actual_plan, :root_ancestor, allow_nil: true + delegate :maven_package_requests_forwarding, :pypi_package_requests_forwarding, :npm_package_requests_forwarding + end + + with_options to: :ci_cd_settings, allow_nil: true do + delegate :allow_composite_identities_to_run_pipelines, :allow_composite_identities_to_run_pipelines= + delegate :group_runners_enabled, :group_runners_enabled= + delegate :keep_latest_artifact, :keep_latest_artifact= + delegate :pipeline_override_role_privileged? + delegate :restrict_user_defined_variables, :restrict_user_defined_variables= + delegate :runner_token_expiration_interval, :runner_token_expiration_interval=, :runner_token_expiration_interval_human_readable, :runner_token_expiration_interval_human_readable= + delegate :job_token_scope_enabled, :job_token_scope_enabled=, prefix: :ci_outbound + delegate :resource_group_default_process_mode, :resource_group_default_process_mode= + + with_options prefix: :ci do + delegate :pipeline_variables_minimum_override_role, :pipeline_variables_minimum_override_role= + delegate :push_repository_for_job_token_allowed, :push_repository_for_job_token_allowed= + delegate :cross_project_push_for_job_token_allowed, :cross_project_push_for_job_token_allowed= + delegate :default_git_depth, :default_git_depth= + delegate :forward_deployment_enabled, :forward_deployment_enabled= + delegate :forward_deployment_rollback_allowed, :forward_deployment_rollback_allowed= + delegate :inbound_job_token_scope_enabled, :inbound_job_token_scope_enabled= + delegate :allow_fork_pipelines_to_run_in_parent_project, :allow_fork_pipelines_to_run_in_parent_project= + delegate :separated_caches, :separated_caches= + delegate :id_token_sub_claim_components, :id_token_sub_claim_components= + delegate :delete_pipelines_in_seconds, :delete_pipelines_in_seconds= + delegate :display_pipeline_variables, :display_pipeline_variables= + delegate :skip_branch_pipelines_for_mrs, :skip_branch_pipelines_for_mrs= + end + end + + with_options to: :project_setting do + delegate :allow_merge_on_skipped_pipeline, :allow_merge_on_skipped_pipeline?, :allow_merge_on_skipped_pipeline= + delegate :allow_merge_without_pipeline, :allow_merge_without_pipeline?, :allow_merge_without_pipeline= + delegate :has_confluence? + delegate :show_diff_preview_in_email, :show_diff_preview_in_email=, :show_diff_preview_in_email? + delegate :runner_registration_enabled, :runner_registration_enabled=, :runner_registration_enabled? + delegate :emails_enabled, :emails_enabled=, :emails_enabled? + delegate :squash_always?, :squash_never?, :squash_enabled_by_default?, :squash_readonly? + delegate :mr_default_target_self, :mr_default_target_self= + delegate :previous_default_branch, :previous_default_branch= + delegate :squash_option, :squash_option= + delegate :extended_prat_expiry_webhooks_execute, :extended_prat_expiry_webhooks_execute= + delegate :protect_merge_request_pipelines, :protect_merge_request_pipelines=, :protect_merge_request_pipelines? + + with_options allow_nil: true do + delegate :merge_commit_template, :merge_commit_template= + delegate :squash_commit_template, :squash_commit_template= + delegate :mr_default_title_template, :mr_default_title_template= + delegate :issue_branch_template, :issue_branch_template= + delegate :show_default_award_emojis, :show_default_award_emojis= + delegate :enforce_auth_checks_on_uploads, :enforce_auth_checks_on_uploads= + delegate :warn_about_potentially_unwanted_characters, :warn_about_potentially_unwanted_characters= + delegate :duo_features_enabled, :duo_features_enabled= + delegate :model_prompt_cache_enabled, :model_prompt_cache_enabled= + delegate :merge_request_title_regex, :merge_request_title_regex= + delegate :merge_request_title_regex_description, :merge_request_title_regex_description= + delegate :web_based_commit_signing_enabled, :web_based_commit_signing_enabled= + end + end + + # Validations + validates :creator, presence: true, on: :create + validates :description, length: { maximum: 2000 }, allow_blank: true + validates :ci_config_path, + format: { without: %r{(\.{2}|\A/)}, + message: N_('cannot include leading slash or directory traversal.') }, + length: { maximum: 255 }, + allow_blank: true + validates :name, + presence: true, + length: { maximum: 255 } + validates :path, + presence: true, + project_path: true, + length: { maximum: 255 } + + validates :name, + format: { with: Gitlab::Regex.project_name_regex, + message: Gitlab::Regex.project_name_regex_message }, + if: :name_changed? + validates :path, + format: { with: Gitlab::Regex.oci_repository_path_regex, + message: Gitlab::Regex.oci_repository_path_regex_message }, + if: :path_changed? + + validates :project_feature, presence: true + validates :namespace, presence: true + validates :organization, presence: true + validates :project_namespace, presence: true, on: :create, if: -> { self.namespace } + validates :project_namespace, presence: true, on: :update, if: -> { self.project_namespace_id_changed?(to: nil) } + validates :name, uniqueness: { scope: :namespace_id } + validates :unsafe_import_url, public_url: { schemes: ->(project) { project.persisted? ? VALID_MIRROR_PROTOCOLS : VALID_IMPORT_PROTOCOLS }, + ports: ->(project) { project.persisted? ? VALID_MIRROR_PORTS : VALID_IMPORT_PORTS }, + enforce_user: true }, if: :validate_unsafe_import_url? + validates :star_count, numericality: { greater_than_or_equal_to: 0 } + validate :check_personal_projects_limit, on: :create + validate :check_repository_path_availability, on: :update, if: ->(project) { project.renamed? } + validate :visibility_level_allowed_by_namespace, if: :should_validate_visibility_level? + validate :visibility_level_allowed_as_fork, if: :should_validate_visibility_level? + validate :validate_pages_https_only, if: -> { changes.has_key?(:pages_https_only) } + validate :changing_shared_runners_enabled_is_allowed + validate :parent_organization_match + validates :repository_storage, presence: true, inclusion: { in: ->(_) { Gitlab.config.repositories.storages.keys } } + validates :variables, nested_attributes_duplicates: { scope: :environment_scope } + validates :bfg_object_map, file_size: { maximum: :max_attachment_size } + validates :max_artifacts_size, numericality: { only_integer: true, greater_than: 0, allow_nil: true } + validates :suggestion_commit_message, length: { maximum: MAX_SUGGESTIONS_TEMPLATE_LENGTH } + + validate :path_availability, if: :path_changed? + + # Scopes + scope :deletion_in_progress, -> { + joins(:project_namespace).where(namespaces: { state: :deletion_in_progress }) + } + + scope :not_deletion_in_progress, -> { + where( + Namespace.select(1) + .where(Namespace.arel_table[:id].eq(arel_table[:project_namespace_id])) + .where(state: :deletion_in_progress) + .arel.exists.not + ) + } + + scope :pending_delete, -> { where(pending_delete: true) } + scope :without_deleted, -> { where(pending_delete: false) } + scope :not_hidden, -> { where(hidden: false) } + scope :not_in_groups, ->(groups) { where.not(group: groups) } + scope :by_not_in_root_id, ->(root_id) { joins(:project_namespace).where('namespaces.traversal_ids[1] NOT IN (?)', root_id) } + scope :with_visibility_level_greater_than, ->(level) { where("visibility_level > ?", level) } + + scope :aimed_for_deletion, -> { where.not(marked_for_deletion_at: nil).without_deleted } + scope :self_or_ancestors_aimed_for_deletion, -> do + left_joins(:group) + .where.not(marked_for_deletion_at: nil) + .or(where(Group.self_or_ancestors_deletion_schedule_subquery.exists)) + .without_deleted + end + + scope :not_aimed_for_deletion, -> { where(marked_for_deletion_at: nil).without_deleted } + scope :self_and_ancestors_not_aimed_for_deletion, -> do + left_joins(:group) + .where(marked_for_deletion_at: nil) + .where.not(Group.self_or_ancestors_deletion_schedule_subquery.exists) + .without_deleted + end + + scope :marked_for_deletion_before, ->(date) { where('marked_for_deletion_at <= ?', date) } + scope :marked_for_deletion_on, ->(marked_for_deletion_on) do + where(marked_for_deletion_at: marked_for_deletion_on) + end + + scope :with_deleting_user, -> { includes(:deleting_user) } + + scope :with_storage_feature, ->(feature) do + where(arel_table[:storage_version].gteq(HASHED_STORAGE_FEATURES[feature])) + end + scope :without_storage_feature, ->(feature) do + where(arel_table[:storage_version].lt(HASHED_STORAGE_FEATURES[feature]) + .or(arel_table[:storage_version].eq(nil))) + end + scope :with_unmigrated_storage, -> do + where(arel_table[:storage_version].lt(LATEST_STORAGE_VERSION) + .or(arel_table[:storage_version].eq(nil))) + end + + scope :recently_contributed_by, ->(user, since: 1.month.ago) do + joins(:events) + .where(Event.arel_table[:author_id].eq(user.id)) + .where(Event.arel_table[:created_at].gt(since)) + end + + # The `projects_visits` table is partitioned on `visited_at` by month + # Please avoid setting `since` to more than 3 months ago + scope :recently_visited_by, ->(user, since: 3.months.ago) do + joins("INNER JOIN #{Users::ProjectVisit.table_name} ON projects_visits.entity_id = projects.id") + .merge(Users::ProjectVisit.for_user(user).recently_visited(since: since)) + end + + scope :sorted_by_activity, -> { reorder(self.arel_table['last_activity_at'].desc) } + scope :sorted_by_updated_asc, -> { reorder(self.arel_table['last_activity_at'].asc) } + scope :sorted_by_updated_desc, -> { reorder(self.arel_table['last_activity_at'].desc) } + scope :sorted_by_stars_desc, -> { reorder(self.arel_table['star_count'].desc) } + scope :sorted_by_stars_asc, -> { reorder(self.arel_table['star_count'].asc) } + scope :sorted_by_path_asc, -> { reorder(self.arel_table['path'].asc) } + scope :sorted_by_path_desc, -> { reorder(self.arel_table['path'].desc) } + scope :sorted_by_full_path_asc, -> { order_by_full_path(:asc) } + scope :sorted_by_full_path_desc, -> { order_by_full_path(:desc) } + scope :order_by_full_path, ->(direction) do + build_keyset_order_on_joined_column( + scope: joins(:route), + attribute_name: 'project_full_path', + column: Route.arel_table[:path], + direction: direction, + nullable: :nulls_first + ) + end + + # Sometimes queries (e.g. using CTEs) require explicit disambiguation with table name + scope :projects_order_id_asc, -> { reorder(self.arel_table['id'].asc) } + scope :projects_order_id_desc, -> { reorder(self.arel_table['id'].desc) } + scope :projects_order_namespace_id_asc, -> { reorder(self.arel_table['namespace_id'].asc) } + scope :sorted_by_storage_size_asc, -> { order_by_storage_size(:asc) } + scope :sorted_by_storage_size_desc, -> { order_by_storage_size(:desc) } + scope :order_by_storage_size, ->(direction) do + order_by_project_statistics('project_statistics_storage_size', :storage_size, direction) + end + + scope :sorted_by_repository_size_asc, -> { order_by_repository_size(:asc) } + scope :sorted_by_repository_size_desc, -> { order_by_repository_size(:desc) } + scope :order_by_repository_size, ->(direction) do + order_by_project_statistics('project_statistics_repository_size', :repository_size, direction) + end + + scope :sorted_by_snippets_size_asc, -> { order_by_snippet_size(:asc) } + scope :sorted_by_snippets_size_desc, -> { order_by_snippet_size(:desc) } + scope :order_by_snippet_size, ->(direction) do + order_by_project_statistics('project_statistics_snippets_size', :snippets_size, direction) + end + + scope :sorted_by_build_artifacts_size_asc, -> { order_by_build_artifacts_size(:asc) } + scope :sorted_by_build_artifacts_size_desc, -> { order_by_build_artifacts_size(:desc) } + scope :order_by_build_artifacts_size, ->(direction) do + order_by_project_statistics('project_statistics_build_artifacts_size', :build_artifacts_size, direction) + end + + scope :sorted_by_lfs_objects_size_asc, -> { order_by_lfs_objects_size(:asc) } + scope :sorted_by_lfs_objects_size_desc, -> { order_by_lfs_objects_size(:desc) } + scope :order_by_lfs_objects_size, ->(direction) do + order_by_project_statistics('project_statistics_lfs_objects_size', :lfs_objects_size, direction) + end + + scope :sorted_by_packages_size_asc, -> { order_by_packages_size(:asc) } + scope :sorted_by_packages_size_desc, -> { order_by_packages_size(:desc) } + scope :order_by_packages_size, ->(direction) do + order_by_project_statistics('project_statistics_packages_size', :packages_size, direction) + end + + scope :sorted_by_wiki_size_asc, -> { order_by_wiki_size(:asc) } + scope :sorted_by_wiki_size_desc, -> { order_by_wiki_size(:desc) } + scope :order_by_wiki_size, ->(direction) do + order_by_project_statistics('project_statistics_wiki_size', :wiki_size, direction) + end + + scope :sorted_by_container_registry_size_asc, -> { order_by_container_registry_size(:asc) } + scope :sorted_by_container_registry_size_desc, -> { order_by_container_registry_size(:desc) } + scope :order_by_container_registry_size, ->(direction) do + order_by_project_statistics('project_statistics_container_registry_size', :container_registry_size, direction) + end + + scope :order_by_project_statistics, ->(attribute_name, attribute_column, direction) do + build_keyset_order_on_joined_column( + scope: joins(:statistics), + attribute_name: attribute_name, + column: ::ProjectStatistics.arel_table[attribute_column], + direction: direction, + nullable: :nulls_first + ) + end + + scope :sorted_by_similarity_desc, ->(search, full_path_only: false, exclude_description: false) do + path_sort_rule = { column: arel_table["path"], multiplier: 1 } + name_sort_rule = { column: arel_table["name"], multiplier: 0.7 } + rules = if full_path_only + [path_sort_rule] + elsif exclude_description + [path_sort_rule, name_sort_rule] + else + [ + path_sort_rule, + name_sort_rule, + { column: arel_table["description"], multiplier: 0.2 } + ] + end + + order_expression = Gitlab::Database::SimilarityScore.build_expression( + search: search, + rules: rules + ) + + order = Gitlab::Pagination::Keyset::Order.build( + [ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'similarity', + column_expression: order_expression, + order_expression: order_expression.desc, + order_direction: :desc, + add_to_projections: true + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id', + order_expression: Project.arel_table[:id].desc + ) + ]) + + order.apply_cursor_conditions(reorder(order)) + end + + scope :with_packages, -> { joins(:packages) } + scope :in_namespace, ->(namespace_ids) { where(namespace_id: namespace_ids) } + scope :personal, ->(user) { where(namespace_id: user.namespace_id) } + scope :joined, ->(user) { where.not(namespace_id: user.namespace_id) } + scope :starred_by, ->(user) { joins(:users_star_projects).where('users_star_projects.user_id': user.id) } + scope :visible_to_user, ->(user) { where(id: user.authorized_projects.select(:id).reorder(nil)) } + scope :visible_to_user_and_access_level, ->(user, access_level) { where(id: user.authorized_projects.where('project_authorizations.access_level >= ?', access_level).select(:id).reorder(nil)) } + + scope :archived, -> { self_or_ancestors_archived } + scope :self_or_ancestors_archived, -> do + left_joins(:group) + .where(archived: true) + .or(where(Group.self_or_ancestors_archived_setting_subquery.exists)) + end + + scope :non_archived, -> { self_and_ancestors_non_archived } + scope :self_and_ancestors_non_archived, -> do + left_joins(:group) + .where(archived: false) + .where.not(Group.self_or_ancestors_archived_setting_subquery.exists) + end + + # NOTE: This scope must always be chained with a namespace filter (e.g. .in_namespace()). + # It has no namespace constraint and will scan all projects if used standalone. + # See: Integrations::PropagateService#propagate_integration_to_descendant_projects + scope :without_integration_excluding_ancestor_archived_check, ->(integration) { + integrations = Integration + .select('1') + .where("#{Integration.table_name}.project_id = projects.id") + .where(type: integration.type) + + where('NOT EXISTS (?)', integrations) + .where(pending_delete: false) + .where(archived: false) + } + + scope :self_and_ancestors_active, -> { self_and_ancestors_non_archived.self_and_ancestors_not_aimed_for_deletion } + scope :self_or_ancestors_inactive, -> { self_or_ancestors_archived.or(self_or_ancestors_aimed_for_deletion) } + + scope :with_push, -> { joins(:events).merge(Event.pushed_action) } + scope :with_project_feature, -> { joins('LEFT JOIN project_features ON projects.id = project_features.project_id') } + scope :with_jira_dvcs_server, -> { joins(:feature_usage).merge(ProjectFeatureUsage.with_jira_dvcs_integration_enabled(cloud: false)) } + scope :by_name, ->(name) { where('projects.name LIKE ?', "#{sanitize_sql_like(name)}%") } + scope :inc_routes, -> { includes(:route, namespace: :route) } + scope :include_fork_networks, -> { includes(:fork_network) } + scope :with_statistics, -> { includes(:statistics) } + scope :with_namespace, -> { includes(:namespace) } + scope :with_project_namespace_details, -> { preload(project_namespace: :namespace_details) } + scope :joins_namespace, -> { joins(:namespace) } + scope :with_group, -> { includes(:group) } + scope :with_import_state, -> { includes(:import_state) } + scope :include_project_feature, -> { includes(:project_feature) } + scope :include_integration, ->(integration_association_name) { includes(integration_association_name) } + scope :with_integration, ->(integration_class) { joins(:integrations).merge(integration_class.all) } + scope :with_active_integration, ->(integration_class) { with_integration(integration_class).merge(integration_class.active) } + scope :with_shared_runners_enabled, -> { where(shared_runners_enabled: true) } + # .with_slack_integration can generate poorly performing queries. It is intended only for UsagePing. + scope :with_slack_integration, -> { joins(:slack_integration) } + + scope :include_topics, -> { includes(:topics, :project_topics) } + + scope :with_jira_installation, ->(installation_id) do + joins(namespace: :jira_connect_subscriptions) + .where(jira_connect_subscriptions: { jira_connect_installation_id: installation_id }) + end + + scope :with_feature_enabled, ->(feature) { + with_project_feature.merge(ProjectFeature.with_feature_enabled(feature)) + } + + scope :with_feature_access_level, ->(feature, level) { + with_project_feature.merge(ProjectFeature.with_feature_access_level(feature, level)) + } + + # Picks projects which use the given programming language + scope :with_programming_language, ->(language_name) do + lang_id_query = ProgrammingLanguage + .with_name_case_insensitive(language_name) + .select(:id) + + joins(:repository_languages) + .where(repository_languages: { programming_language_id: lang_id_query }) + end + + scope :with_programming_language_id, ->(language_id) do + joins(:repository_languages) + .where(repository_languages: { programming_language_id: language_id }) + end + + scope :service_desk_enabled, -> { where(service_desk_enabled: true) } + scope :with_builds_enabled, -> { with_feature_enabled(:builds) } + scope :with_issues_enabled, -> { with_feature_enabled(:issues) } + scope :with_package_registry_enabled, -> { with_feature_enabled(:package_registry) } + scope :with_public_package_registry, -> do + where_exists( + ::ProjectFeature + .where(::ProjectFeature.arel_table[:project_id].eq(arel_table[:id])) + .with_feature_access_level(:package_registry, ::ProjectFeature::PUBLIC) + ) + end + scope :with_issues_available_for_user, ->(current_user) { with_feature_available_for_user(:issues, current_user) } + scope :with_merge_requests_available_for_user, ->(current_user) { with_feature_available_for_user(:merge_requests, current_user) } + scope :with_issues_or_mrs_available_for_user, ->(user) do + with_issues_available_for_user(user).or(with_merge_requests_available_for_user(user)) + end + scope :with_merge_requests_enabled, -> { with_feature_enabled(:merge_requests) } + scope :with_remote_mirrors, -> { joins(:remote_mirrors).where(remote_mirrors: { enabled: true }) } + scope :with_limit, ->(maximum) { limit(maximum) } + + scope :with_group_runners_enabled, -> do + joins(:ci_cd_settings) + .where(project_ci_cd_settings: { group_runners_enabled: true }) + end + + scope :with_pages_deployed, -> do + where_exists(PagesDeployment.active.where('pages_deployments.project_id = projects.id')) + end + + scope :pages_metadata_not_migrated, -> do + left_outer_joins(:pages_metadatum) + .where(project_pages_metadata: { project_id: nil }) + end + + scope :with_namespace_domain_pages, -> do + joins(:project_setting) + .where(project_setting: { pages_unique_domain_enabled: false }) + end + + scope :with_group_child_entity_associations, -> { + with_route.preload(namespace: [:namespace_settings_with_ancestors_inherited_settings]) + } + + scope :with_name, ->(name) { where(name: name) } + scope :created_by, ->(user) { where(creator: user) } + scope :imported_from, ->(type) { where(import_type: type) } + scope :imported, -> { where.not(import_type: nil) } + scope :with_enabled_error_tracking, -> { joins(:error_tracking_setting).where(project_error_tracking_settings: { enabled: true }) } + scope :last_activity_before, ->(time) { where('projects.last_activity_at < ?', time) } + + scope :with_service_desk_key, ->(key) do + # project_key is not indexed for now + # see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/24063#note_282435524 for details + joins(:service_desk_setting).where('service_desk_settings.project_key' => key) + end + + scope :with_topic, ->(topic) { where(id: topic.project_topics.select(:project_id)) } + + scope :contains_all_topic_names, ->(topic_names) do + topic_names = Array.wrap(topic_names) + + project_topics = Projects::ProjectTopic.joins(:topic) + .where(topic: { name: topic_names }) + .group(:project_id) + .having(%(COUNT(DISTINCT "topic"."name") = ?), topic_names.count) + + where(id: project_topics.select(:project_id)) + end + + scope :in_organization, ->(organization) { where(organization: organization) } + scope :by_project_namespace, ->(project_namespace) { where(project_namespace_id: project_namespace) } + scope :by_any_overlap_with_traversal_ids, ->(traversal_ids) { + joins_namespace.where('namespaces.traversal_ids::bigint[] && ARRAY[?]::bigint[]', traversal_ids) + } + + scope :not_a_fork, -> { + left_outer_joins(:fork_network_member).where(fork_network_member: { forked_from_project_id: nil }) + } + + scope :in_fork_network, -> { + joins(:fork_network_member) + } + + # This prevents N+1 queries since the UnlinkForkService service accesses these associations for each project. + scope :with_fork_network_associations, -> { + includes(:fork_network, :forked_from_project, :fork_network_member) + } + + scope :last_repository_check_failed, -> { where(last_repository_check_failed: true) } + scope :last_repository_check_not_failed, -> { where(last_repository_check_failed: [false, nil]) } + + enum :auto_cancel_pending_pipelines, { disabled: 0, enabled: 1 } + + chronic_duration_attr :build_timeout_human_readable, :build_timeout, + default: 3600, error_message: N_('Maximum job timeout has a value which could not be accepted') + + validates :build_timeout, allow_nil: true, numericality: { + greater_than_or_equal_to: 10.minutes, + less_than: MAX_BUILD_TIMEOUT, + only_integer: true, + message: N_('needs to be between 10 minutes and 1 month') + } + + # Used by Projects::CleanupService to hold a map of rewritten object IDs + mount_uploader :bfg_object_map, AttachmentUploader + + def self.with_api_entity_associations + with_fork_network_associations + .preload(:project_feature, :route, :topics, :group, :timelogs, namespace: [:route, :owner]) + end + + def self.with_web_entity_associations + with_project_namespace_details + .preload(:project_feature, :route, :creator, group: :parent, namespace: [:route, :owner, :namespace_settings, :namespace_settings_with_ancestors_inherited_settings]) + end + + def self.with_api_commit_entity_associations + preload(:project_feature, :route, namespace: [:route, :owner]) + end + + def self.with_api_blob_entity_associations + preload(:project_feature, :route, namespace: [:route, :owner]) + end + + def self.with_slack_application_disabled + # Using Arel to avoid exposing what the column backing the type: attribute is + # rubocop: disable GitlabSecurity/PublicSend + with_active_slack = Integration.active.by_name(:gitlab_slack_application) + join_contraint = arel_table[:id].eq(Integration.arel_table[:project_id]) + constraint = with_active_slack.where_clause.send(:predicates).reduce(join_contraint) { |a, b| a.and(b) } + join = arel_table.join(Integration.arel_table, Arel::Nodes::OuterJoin).on(constraint).join_sources + # rubocop: enable GitlabSecurity/PublicSend + + joins(join).where(integrations: { id: nil }) + rescue Integration::UnknownType + all + end + + def self.eager_load_namespace_and_owner + includes(namespace: :owner) + end + + # Returns a collection of projects that is either public or visible to the + # logged in user. + def self.public_or_visible_to_user(user = nil, min_access_level = nil) + min_access_level = nil if user&.can_read_all_resources? + + return public_to_user unless user + + if user.is_a?(DeployToken) + where(id: user.accessible_projects) + else + where( + 'EXISTS (?) OR projects.visibility_level IN (?)', + user.authorizations_for_projects(min_access_level: min_access_level), + self.visibility_levels_for_user(user) + ) + end + end + + # Returns a collection of projects that is either: + # - public and not forked + # - or visible to the logged in user + def self.public_non_forked_or_visible_to_user(user, min_access_level = nil) + min_access_level = nil if user.can_read_all_resources? + + left_outer_joins(:fork_network_member) + .where( + 'EXISTS (?) OR (projects.visibility_level IN (?) AND ?)', + user.authorizations_for_projects(min_access_level: min_access_level), + visibility_levels_for_user(user), + ForkNetworkMember.arel_table[:forked_from_project_id].eq(nil) + ) + end + + # Auditors can :read_all_resources while admins can :read_all_resources and + # read_admin_projects. In EE, a regular user can read_admin_projects through + # custom admin roles. + def self.visibility_levels_for_user(user) + can_read_all_projects = user&.can_read_all_resources? || user&.can?(:read_admin_projects) + + return ::Gitlab::VisibilityLevel::LEVELS_FOR_ADMINS if can_read_all_projects + + Gitlab::VisibilityLevel.levels_for_user(user) + end + + # Define two instance methods: + # + # - [attribute]?(inherit_group_setting) Returns the final value after inheriting the parent group + # - [attribute]_locked? Returns true if the value is inherited from the parent group + # + # These functions will be overridden in EE to make sense afterwards + def self.cascading_with_parent_namespace(attribute) + define_method("#{attribute}?") do |inherit_group_setting: false| + self.public_send(attribute) # rubocop:disable GitlabSecurity/PublicSend + end + + define_method("#{attribute}_locked?") do + false + end + end + + cascading_with_parent_namespace :only_allow_merge_if_pipeline_succeeds + cascading_with_parent_namespace :allow_merge_on_skipped_pipeline + cascading_with_parent_namespace :only_allow_merge_if_all_discussions_are_resolved + cascading_with_parent_namespace :allow_merge_without_pipeline + + def self.with_feature_available_for_user(feature, user) + with_project_feature.merge(ProjectFeature.with_feature_available_for_user(feature, user)) + end + + def self.projects_user_can(projects, user, action) + DeclarativePolicy.user_scope do + projects.select { |project| Ability.allowed?(user, action, project) } + end + end + + def self.filter_out_public_projects_with_unauthorized_private_repos(projects, user) + public_projects_with_private_repos = projects.with_project_feature.where( + visibility_level: Gitlab::VisibilityLevel::PUBLIC, + project_features: { repository_access_level: ProjectFeature::PRIVATE } + ).pluck(:id) + + return projects unless public_projects_with_private_repos.present? + + authorized_public_projects_with_private_repos = projects.filter_by_feature_visibility( + :repository, user + ) + + rejected_projects_with_private_repos = ( + public_projects_with_private_repos - authorized_public_projects_with_private_repos.pluck(:id) + ) + + projects.where.not(id: rejected_projects_with_private_repos) + end + + # This scope returns projects where user has access to both the project and the feature. + def self.filter_by_feature_visibility(feature, user) + with_feature_available_for_user(feature, user) + .public_or_visible_to_user( + user, + ProjectFeature.required_minimum_access_level_for_private_project(feature) + ) + end + + def self.wrap_with_cte(collection) + cte = Gitlab::SQL::CTE.new(:projects_cte, collection) + Project.with(cte.to_arel).from(cte.alias_to(Project.arel_table)) + end + + def self.dormant + project_statistics = ::ProjectStatistics.arel_table + minimum_size_mb = ::Gitlab::CurrentSettings.inactive_projects_min_size_mb.megabytes + last_activity_cutoff = ::Gitlab::CurrentSettings.inactive_projects_send_warning_email_after_months.months.ago + + joins(:statistics) + .where(project_statistics[:storage_size].gt(minimum_size_mb)) + .where('last_activity_at < ?', last_activity_cutoff) + end + + scope :active, -> { joins(:issues, :notes, :merge_requests).order('issues.created_at, notes.created_at, merge_requests.created_at DESC') } + scope :abandoned, -> { where('projects.last_activity_at < ?', 6.months.ago) } + + scope :excluding_project, ->(project) { where.not(id: project) } + + # We require an alias to the project_mirror_data_table in order to use import_state in our queries + scope :joins_import_state, -> { joins("INNER JOIN project_mirror_data import_state ON import_state.project_id = projects.id") } + scope :for_group, ->(group) { where(group: group) } + scope :for_group_and_its_subgroups, ->(group) { where(namespace_id: group.self_and_descendants.select(:id)) } + scope :for_group_and_its_ancestor_groups, ->(group) { where(namespace_id: group.self_and_ancestors.select(:id)) } + scope :is_importing, -> { with_import_state.where(import_state: { status: %w[started scheduled] }) } + + class << self + # Searches for a list of projects based on the query given in `query`. + # + # On PostgreSQL this method uses "ILIKE" to perform a case-insensitive + # search. + # + # query - The search query as a String. + def search(query, include_namespace: false, exclude_description: false, use_minimum_char_limit: true) + if include_namespace + joins(:route).fuzzy_search(query, [Route.arel_table[:path], Route.arel_table[:name], :description], + use_minimum_char_limit: use_minimum_char_limit) + .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/421843') + else + search_columns = [:path, :name] + search_columns << :description unless exclude_description + fuzzy_search(query, search_columns, use_minimum_char_limit: use_minimum_char_limit) + end + end + + def search_by_title(query) + non_archived.fuzzy_search(query, [:name]) + end + + def visibility_levels + Gitlab::VisibilityLevel.options + end + + # rubocop:disable Metrics/CyclomaticComplexity -- stick to existing implementation for sort params: + def sort_by_attribute(method) + case method.to_s + when 'storage_size_desc' then sorted_by_storage_size_desc + when 'storage_size_asc' then sorted_by_storage_size_asc + when 'repository_size_desc' then sorted_by_repository_size_desc + when 'repository_size_asc' then sorted_by_repository_size_asc + when 'snippets_size_desc'then sorted_by_snippets_size_desc + when 'snippets_size_asc'then sorted_by_snippets_size_asc + when 'build_artifacts_size_desc' then sorted_by_build_artifacts_size_desc + when 'build_artifacts_size_asc'then sorted_by_build_artifacts_size_asc + when 'lfs_objects_size_desc'then sorted_by_lfs_objects_size_desc + when 'lfs_objects_size_asc' then sorted_by_lfs_objects_size_asc + when 'packages_size_desc' then sorted_by_packages_size_desc + when 'packages_size_asc' then sorted_by_packages_size_asc + when 'wiki_size_desc' then sorted_by_wiki_size_desc + when 'wiki_size_asc'then sorted_by_wiki_size_asc + when 'container_registry_size_desc' then sorted_by_container_registry_size_desc + when 'container_registry_size_asc' then sorted_by_container_registry_size_asc + when 'latest_activity_desc' then sorted_by_updated_desc + when 'latest_activity_asc' then sorted_by_updated_asc + when 'path_desc'then sorted_by_path_desc + when 'path_asc' then sorted_by_path_asc + when 'full_path_desc'then sorted_by_full_path_desc + when 'full_path_asc' then sorted_by_full_path_asc + when 'stars_desc' then sorted_by_stars_desc + when 'stars_asc' then sorted_by_stars_asc + else + order_by(method) + end + end + # rubocop:enable Metrics/CyclomaticComplexity + + def reference_pattern + %r{ + (?/)? + ((?#{Gitlab::PathRegex::FULL_NAMESPACE_FORMAT_REGEX})/)? + (?#{Gitlab::PathRegex::PROJECT_PATH_FORMAT_REGEX}) + }xo + end + + def reference_postfix + '>' + end + + def reference_postfix_escaped + '>' + end + + # Pattern used to extract `namespace/project>` project references from text. + # '>' or its escaped form ('>') are checked for because '>' is sometimes escaped + # when the reference comes from an external source. + def markdown_reference_pattern + @markdown_reference_pattern ||= + %r{ + #{reference_pattern} + (#{reference_postfix}|#{reference_postfix_escaped}) + }x + end + + def cached_count + Rails.cache.fetch('total_project_count', expires_in: 5.minutes) do + Project.count + end + end + + def group_ids + joins(:namespace).where(namespaces: { type: Group.sti_name }).select(:namespace_id) + end + + # Returns ids of projects with issuables available for given user + # + # Used on queries to find milestones or labels which user can see + # For example: Milestone.where(project_id: ids_with_issuables_available_for(user)) + def ids_with_issuables_available_for(user) + with_issues_enabled = with_issues_available_for_user(user).select(:id) + with_merge_requests_enabled = with_merge_requests_available_for_user(user).select(:id) + + from_union([with_issues_enabled, with_merge_requests_enabled]).select(:id) + end + + def find_by_url(url) + uri = URI(url) + + return unless uri.host == Gitlab.config.gitlab.host + + match = Rails.application.routes.recognize_path(url) + + return if match[:unmatched_route].present? + return if match[:namespace_id].blank? || match[:id].blank? + + find_by_full_path(match.values_at(:namespace_id, :id).join("/")) + rescue ActionController::RoutingError, URI::InvalidURIError + nil + end + + def without_integration(integration) + integrations = Integration + .select('1') + .where("#{Integration.table_name}.project_id = projects.id") + .where(type: integration.type) + + Project + .where('NOT EXISTS (?)', integrations) + .where(pending_delete: false) + .non_archived + end + + def project_features_defaults + PROJECT_FEATURES_DEFAULTS + end + + def by_pages_enabled_unique_domain(domain) + without_deleted + .joins(:project_setting) + .find_by(project_setting: { + pages_unique_domain_enabled: true, + pages_unique_domain: domain + }) + end + + def project_namespace_for(id:) + find_by(id: id)&.project_namespace + end + + def group_by_namespace_traversal_ids(project_batch) + by_ids(project_batch) + .with_namespace + .pluck(:id, 'namespaces.traversal_ids') + .group_by(&:last) + .transform_values { |projects| projects.map(&:first) } + end + + def root_ids_for(project_ids) + namespace_ids = id_in(project_ids) + .limit(Project::MAX_PLUCK) + .distinct + .pluck(:namespace_id) + + return [] if namespace_ids.empty? + + Namespace.root_ids_for(namespace_ids) + end + end + + def initialize(attributes = nil) + # We assign the actual snippet default if no explicit visibility has been initialized. + attributes ||= {} + + unless visibility_attribute_present?(attributes) + attributes[:visibility_level] = Gitlab::CurrentSettings.default_project_visibility + end + + @init_attributes = attributes + + super + end + + def owner_entity + self + end + + def owner_entity_name + :project + end + + # Remove along with ProjectFeaturesCompatibility module + def set_project_feature_defaults + self.class.project_features_defaults.each do |attr, value| + # If the deprecated _enabled or the accepted _access_level attribute is specified, we don't need to set the default + next unless @init_attributes[:"#{attr}_enabled"].nil? && @init_attributes[:"#{attr}_access_level"].nil? + + public_send("#{attr}_enabled=", value) # rubocop:disable GitlabSecurity/PublicSend + end + end + + def resource_parent + self + end + + def certificate_based_clusters_enabled? + !!namespace&.certificate_based_clusters_enabled? + end + + def jenkins_integration_active? + !!jenkins_integration&.active? + end + + def service_accounts + provisioned_users.service_account + end + + def personal_namespace_holder?(user) + return false unless personal? + return false unless user + + # We do not want to use a check like `project.team.owner?(user)` + # here because that would depend upon the state of the `project_authorizations` cache, + # and also perform the check across multiple `owners` of the project, but our intention + # is to check if the user is the "holder" of the personal namespace, so need to make this + # check against only a single user (ie, namespace.owner). + namespace.owner == user + end + + def invalidate_personal_projects_count_of_owner + return unless personal? + return unless namespace.owner + + namespace.owner.invalidate_personal_projects_count + end + + def project_setting + super.presence || build_project_setting + end + + def show_default_award_emojis? + !!project_setting&.show_default_award_emojis? + end + + def enforce_auth_checks_on_uploads? + !!project_setting&.enforce_auth_checks_on_uploads? + end + + def warn_about_potentially_unwanted_characters? + !!project_setting&.warn_about_potentially_unwanted_characters? + end + + def extended_prat_expiry_webhooks_execute? + !!project_setting&.extended_prat_expiry_webhooks_execute? + end + + def no_import? + !!import_state&.no_import? + end + + def import_scheduled? + !!import_state&.scheduled? + end + + def import_started? + !!import_state&.started? + end + + def import_in_progress? + !!import_state&.in_progress? + end + + def import_failed? + !!import_state&.failed? + end + + def import_finished? + !!import_state&.finished? + end + + def all_pipelines + if builds_enabled? + super + else + super.external + end + end + + def ci_pipelines + if builds_enabled? + super + else + super.external + end + end + + def active_webide_pipelines(user:) + webide_pipelines.running_or_pending.for_user(user) + end + + def default_pipeline_lock + if keep_latest_artifacts_available? + return :artifacts_locked + end + + :unlocked + end + + def autoclose_referenced_issues + return true if super.nil? + + super + end + + def preload_protected_branches + ActiveRecord::Associations::Preloader.new( + records: [all_protected_branches, protected_branches].flatten, + associations: [:push_access_levels, :merge_access_levels] + ).call + end + + # returns all ancestor-groups upto but excluding the given namespace + # when no namespace is given, all ancestors upto the top are returned + def ancestors_upto(top = nil, hierarchy_order: nil) + Gitlab::ObjectHierarchy.new(Group.where(id: namespace_id)) + .base_and_ancestors(upto: top, hierarchy_order: hierarchy_order) + end + + def ancestors(hierarchy_order: nil) + group&.self_and_ancestors(hierarchy_order: hierarchy_order) || Group.none + end + alias_method :group_and_ancestors, :ancestors + + def self_and_ancestors_ids + strong_memoize(:self_and_ancestors_ids) do + ancestors.pluck(:id) + [id] + end + end + + def ancestors_upto_ids(...) + ancestors_upto(...).pluck(:id) + end + + def emails_disabled? + # disabling in the namespace overrides the project setting + !emails_enabled? + end + + override :lfs_enabled? + def lfs_enabled? + return namespace.lfs_enabled? if self[:lfs_enabled].nil? + + self[:lfs_enabled] && Gitlab.config.lfs.enabled + end + + alias_method :lfs_enabled, :lfs_enabled? + + def auto_devops_enabled? + if auto_devops&.enabled.nil? + has_auto_devops_implicitly_enabled? + else + auto_devops.enabled? + end + end + + def has_auto_devops_implicitly_enabled? + auto_devops_config = first_auto_devops_config + + auto_devops_config[:scope] != :project && auto_devops_config[:status] + end + + def packages_cleanup_policy + super || build_packages_cleanup_policy + end + + def first_auto_devops_config + return namespace.first_auto_devops_config if auto_devops&.enabled.nil? + + { scope: :project, status: auto_devops&.enabled || Feature.enabled?(:force_autodevops_on_by_default, self) } + end + + # LFS and hashed repository storage are required for using Design Management. + def design_management_enabled? + lfs_enabled? && hashed_storage?(:repository) + end + + def team + @team ||= ProjectTeam.new(self) + end + + # @return [Repository] + def repository + @repository ||= Gitlab::GlRepository::PROJECT.repository_for(self) + end + + def find_or_create_design_management_repository + design_management_repository || create_design_management_repository + end + + def design_repository + strong_memoize(:design_repository) do + find_or_create_design_management_repository.repository + end + end + + def cleanup + @repository = nil + end + + alias_method :reload_repository!, :cleanup + + def container_registry_url + if Gitlab.config.registry.enabled + "#{Gitlab.config.registry.host_port}/#{full_path.downcase}" + end + end + + def container_repositories_size + strong_memoize(:container_repositories_size) do + next 0 if container_repositories.empty? + next unless ContainerRegistry::GitlabApiClient.supports_gitlab_api? + + ContainerRegistry::GitlabApiClient.deduplicated_size(full_path) + end + end + + def has_container_registry_tags? + return @images if defined?(@images) + + @images = container_repositories.to_a.any?(&:has_tags?) || + has_root_container_repository_tags? + end + + # ref can't be HEAD, can only be branch/tag name + def latest_successful_build_for_ref(job_name, ref = default_branch) + return unless ref + + latest_pipeline = ci_pipelines.latest_successful_for_ref(ref) + return unless latest_pipeline + + latest_pipeline.build_with_artifacts_in_self_and_project_descendants(job_name) + end + + def latest_successful_build_for_sha(job_name, sha) + return unless sha + + latest_pipeline = ci_pipelines.latest_successful_for_sha(sha) + return unless latest_pipeline + + latest_pipeline.build_with_artifacts_in_self_and_project_descendants(job_name) + end + + def latest_successful_build_for_ref!(job_name, ref = default_branch) + latest_successful_build_for_ref(job_name, ref) || raise(ActiveRecord::RecordNotFound, "Couldn't find job #{job_name}") + end + + def latest_pipelines(ref: default_branch, sha: nil, limit: nil, source: nil) + ref = ref.presence || default_branch + sha ||= commit(ref)&.sha + return ci_pipelines.none unless sha + + ci_pipelines.newest_first(ref: ref, sha: sha, limit: limit, source: source) + end + + def latest_unscheduled_pipelines(ref: default_branch, sha: nil) + ref = ref.presence || default_branch + sha ||= commit(ref)&.sha + return ci_pipelines.none unless sha + + ci_pipelines.newest_without_schedules(ref: ref, sha: sha) + end + + def latest_unscheduled_pipeline(ref = default_branch, sha = nil) + latest_unscheduled_pipelines(ref: ref, sha: sha).take + end + + def latest_pipeline(ref = default_branch, sha = nil, source = nil) + latest_pipelines(ref: ref, sha: sha, source: source).take + end + + def latest_pipeline_for_ci_and_security_orchestration(ref = default_branch) + all_pipelines.ci_and_security_orchestration_sources.newest_first(ref: ref).take + end + + def merge_base_commit(first_commit_id, second_commit_id) + strong_memoize(:"merge_base_commit_#{first_commit_id}_#{second_commit_id}") do + sha = repository.merge_base(first_commit_id, second_commit_id) + commit_by(oid: sha) if sha + end + end + + def saved? + id && persisted? + end + + def import_status + import_state&.status || 'none' + end + + def import_checksums + import_state&.checksums || {} + end + + def jira_import_status + latest_jira_import&.status || 'initial' + end + + def human_import_status_name + import_state&.human_status_name || 'none' + end + + def beautified_import_status_name + if import_finished? + return 'completed' unless import_checksums.present? + + fetched = import_checksums['fetched'] + imported = import_checksums['imported'] + fetched.keys.any? { |key| fetched[key] != imported[key] } ? 'partially completed' : 'completed' + else + import_status + end + end + + def add_import_job + job_id = + if forked? + RepositoryForkWorker.perform_async(id) + else + RepositoryImportWorker.perform_async(self.id) + end + + log_import_activity(job_id) + + job_id + end + + def log_import_activity(job_id, type: :import) + job_type = type.to_s.capitalize + + if job_id + use_primary = ::Gitlab::Database::LoadBalancing::SessionMap.current(load_balancer).use_primary? + Gitlab::AppLogger.info("#{job_type} job scheduled for #{full_path} with job ID #{job_id} (primary: #{use_primary}).") + else + Gitlab::AppLogger.error("#{job_type} job failed to create for #{full_path}.") + end + end + + def reset_cache_and_import_attrs + run_after_commit do + ProjectCacheWorker.perform_async(self.id) + end + + import_state.update(last_error: nil) + remove_import_data + end + + # This method is overridden in EE::Project model + def remove_import_data + import_data&.destroy + end + + def ci_config_path=(value) + # Strip all leading slashes so that //foo -> foo + super(value&.delete("\0")) + end + + # Used by Import/Export to export commit notes + def commit_notes + notes.where(noteable_type: "Commit") + end + + # Returns sanitized import URL. + # + # @param `masked:` [Boolean] Toggles how URL will be sanitized. Defaults to `true`. + # when `true` the userinfo credentials will be masked, + # when `false` the userinfo credentials will be stripped. + # + # @example project.safe_import_url #=> "https://*****:*****@example.com" + # @example project.safe_import_url(masked: false) # => "https://example.com" + # + # @return [String] Sanitized import URL. + def safe_import_url(masked: true) + url = Gitlab::UrlSanitizer.new(unsafe_import_url) + masked ? url.masked_url : url.sanitized_url + end + + def import_url=(value) + if Gitlab::UrlSanitizer.valid?(value) + # Assign sanitized URL, stripped of userinfo credentials, to `Project#import_url` attribute. + import_url = Gitlab::UrlSanitizer.new(value) + super(import_url.sanitized_url) + + # Assign any userinfo credentials to the `ProjectImportData#credentials` attribute. + credentials = import_url.credentials.to_h.transform_values { |value| CGI.unescape(value.to_s) } + build_or_assign_import_data(credentials: credentials) + else + super(value) + end + end + + # WARNING - This method returns sensitive userinfo credentials of the import URL. + # Use `#safe_import_url` instead unless it is necessary to include sensitive credentials. + # + # Builds an import URL including userinfo credentials from the `import_url` attribute + # and the encrypted `ProjectImportData#credentials`. + # + # @see #safe_import_url + # + # @example project.unsafe_import_url #=> "https://user:secretpassword@example.com" + # + # @return [String] Unsanitized import URL. + def unsafe_import_url + if import_data && import_url.present? + Gitlab::UrlSanitizer.new(import_url, credentials: import_data.credentials).full_url + else + import_url + end + rescue StandardError + import_url + end + + def build_or_assign_import_data(data: nil, credentials: nil) + project_import_data = import_data || build_import_data + + project_import_data.merge_data(data.to_h) if data + project_import_data.merge_credentials(credentials.to_h) if credentials + + project_import_data + end + + def import? + external_import? || forked? || gitlab_project_import? || jira_import? || gitlab_project_migration? || offline_transfer? || Gitlab::ImportSources.template?(import_type) + end + + def external_import? + import_url.present? + end + + def notify_project_import_complete? + return false if import_type.nil? || mirror? || forked? + + gitea_import? || github_import? || bitbucket_import? || bitbucket_server_import? + end + + def jira_import? + import_type == 'jira' && latest_jira_import.present? + end + + def gitlab_project_import? + import_type == 'gitlab_project' + end + + def gitlab_project_migration? + import_type == 'gitlab_project_migration' + end + + def offline_transfer? + import_type == Import::SOURCE_OFFLINE_TRANSFER.to_s + end + + # Direct Transfer and Offline Transfer imports are driven by the BulkImports + # framework, which manages its own progress and does not use ProjectImportState. + def transfer_import? + gitlab_project_migration? || offline_transfer? + end + + def gitea_import? + import_type == 'gitea' + end + + def github_import? + import_type == 'github' + end + + def bitbucket_import? + import_type == 'bitbucket' + end + + def bitbucket_server_import? + import_type == 'bitbucket_server' + end + + def github_enterprise_import? + github_import? && + URI.parse(safe_import_url).host != URI.parse(Octokit::Default::API_ENDPOINT).host + end + + # Determine whether any kind of import is in progress. + # - Full file import + # - Relation import + # - Direct Transfer + def any_import_in_progress? + last_relation_import_tracker = relation_import_trackers.last + + (last_relation_import_tracker&.started? && !last_relation_import_tracker.stale?) || + import_started? || + BulkImports::Entity.with_status(:started).where(project_id: id).any? + end + + def has_remote_mirror? + remote_mirror_available? && remote_mirrors.enabled.exists? + end + + def updating_remote_mirror? + remote_mirrors.enabled.started.exists? + end + + def update_remote_mirrors + return unless remote_mirror_available? + + remote_mirrors.enabled.each(&:sync) + end + + def mark_stuck_remote_mirrors_as_failed! + remote_mirrors.stuck.update_all( + update_status: :failed, + last_error: _('The remote mirror took to long to complete.'), + last_update_at: Time.current + ) + end + + def mark_remote_mirrors_for_removal + remote_mirrors.each(&:mark_for_delete_if_blank_url) + end + + def remote_mirror_available? + remote_mirror_available_overridden || + ::Gitlab::CurrentSettings.mirror_available + end + + def check_personal_projects_limit + # Since this method is called as validation hook, `creator` might not be + # present. Since the validation for that will fail, we can just return + # early. + return if !creator || creator.can_create_project? || + namespace.kind == 'group' + + limit = creator.projects_limit + error = + if limit == 0 + _('You cannot create projects in your personal namespace. Contact your GitLab administrator.') + else + _("You've reached your limit of %{limit} projects created. Contact your GitLab administrator.") + end + + self.errors.add(:limit_reached, error % { limit: limit }) + end + + def should_validate_visibility_level? + new_record? || changes.has_key?(:visibility_level) + end + + def visibility_level_allowed_by_namespace + return if visibility_level_allowed_by_namespace? + + level_name = Gitlab::VisibilityLevel.level_name(self.visibility_level).downcase + # Use group visibility level directly when available to avoid stale association issues + namespace_visibility = group ? group.visibility_level : namespace.visibility_level + namespace_level_name = Gitlab::VisibilityLevel.level_name(namespace_visibility).downcase + self.errors.add(:visibility_level, _("%{level_name} is not allowed in a %{namespace_level_name} namespace.") % { level_name: level_name, namespace_level_name: namespace_level_name }) + end + + def visibility_level_allowed_as_fork + return if visibility_level_allowed_as_fork? + + level_name = Gitlab::VisibilityLevel.level_name(self.visibility_level).downcase + self.errors.add(:visibility_level, _("%{level_name} is not allowed since the fork source project has lower visibility.") % { level_name: level_name }) + end + + def pages_https_only + return false unless Gitlab.config.pages.external_https || Gitlab.config.pages.custom_domain_mode == 'https' + + super + end + + def pages_https_only? + return false unless Gitlab.config.pages.external_https || Gitlab.config.pages.custom_domain_mode == 'https' + + super + end + + def validate_pages_https_only + return unless pages_https_only? + + unless pages_domains.all?(&:https?) + errors.add(:pages_https_only, _("cannot be enabled unless all domains have TLS certificates")) + end + end + + def changing_shared_runners_enabled_is_allowed + return unless new_record? || changes.has_key?(:shared_runners_enabled) + + if shared_runners_setting_conflicting_with_group? + errors.add(:shared_runners_enabled, _('cannot be enabled because parent group does not allow it')) + end + end + + def parent_organization_match + return unless parent + return if parent.organization_id == organization_id + + errors.add(:organization_id, _("must match the parent organization's ID")) + end + + def shared_runners_setting_conflicting_with_group? + shared_runners_enabled && group&.shared_runners_setting == Namespace::SR_DISABLED_AND_UNOVERRIDABLE + end + + def reconcile_shared_runners_setting! + if shared_runners_setting_conflicting_with_group? + self.shared_runners_enabled = false + end + end + + def to_param + if persisted? && errors.include?(:path) + path_was + else + path + end + end + + # Produce a valid reference (see Referable#to_reference) + # + # NB: For projects, all references are 'full' - i.e. they all include the + # full_path, rather than just the project name. For this reason, we ignore + # the value of `full:` passed to this method, which is part of the Referable + # interface. + def to_reference(from = nil, full: false) + base = to_reference_base(from, full: true) + "#{base}#{self.class.reference_postfix}" + end + + # `from` argument can be a Namespace or Project. + def to_reference_base(from = nil, full: false, absolute_path: false) + if full || cross_namespace_reference?(from) + absolute_path ? "/#{full_path}" : full_path + elsif cross_project_reference?(from) + path + end + end + + def to_human_reference(from = nil) + if cross_namespace_reference?(from) + name_with_namespace + elsif cross_project_reference?(from) + name + end + end + + def readme_url + readme_path = repository.readme_path + if readme_path + Gitlab::Routing.url_helpers.project_blob_url(self, File.join(default_branch, readme_path)) + end + end + + def new_issuable_address(author, address_type) + return unless Gitlab::Email::IncomingEmail.supports_issue_creation? && author + + # check since this can come from a request parameter + return unless %w[issue merge_request].include?(address_type) + + author.ensure_incoming_email_token! + + suffix = address_type.dasherize + + # example: incoming+h5bp-html5-boilerplate-8-1234567890abcdef123456789-issue@localhost.com + # example: incoming+h5bp-html5-boilerplate-8-1234567890abcdef123456789-merge-request@localhost.com + Gitlab::Email::IncomingEmail.reply_address("#{full_path_slug}-#{project_id}-#{author.incoming_email_token}-#{suffix}") + end + + def build_commit_note(commit) + notes.new(commit_id: commit.id, noteable_type: 'Commit') + end + + def last_activity + last_event + end + + def project_id + self.id + end + + def get_issue(issue_id, current_user) + issue = IssuesFinder.new(current_user, project_id: id).find_by(iid: issue_id) if issues_enabled? + + if issue + issue + elsif external_issue_tracker + ExternalIssue.new(issue_id, self) + end + end + + def issue_exists?(issue_id) + get_issue(issue_id) + end + + def external_issue_reference_pattern + external_issue_tracker.reference_pattern(only_long: issues_enabled?) + end + + def default_issues_tracker? + !external_issue_tracker + end + + def external_issue_tracker + cache_has_external_issue_tracker if has_external_issue_tracker.nil? + + return unless has_external_issue_tracker? + + @external_issue_tracker ||= integrations.external_issue_trackers.first + end + + def external_references_supported? + external_issue_tracker&.support_cross_reference? + end + + def has_wiki? + wiki_enabled? || has_external_wiki? + end + + def external_wiki + cache_has_external_wiki if has_external_wiki.nil? + + return unless has_external_wiki? + + @external_wiki ||= integrations.external_wikis.first + end + + def find_or_initialize_integrations + Integration + .available_integration_names(include_instance_specific: false) + .difference(disabled_integrations) + .map { find_or_initialize_integration(_1) } + .sort_by { |int| int.title.downcase } + end + + # Returns a list of integration names that should be disabled at the project-level. + # Globally disabled integrations should go in Integration.disabled_integration_names. + def disabled_integrations + %w[zentao] + end + + def find_or_initialize_integration(name) + return if disabled_integrations.include?(name) + return if Integration.available_integration_names(include_instance_specific: false).exclude?(name) + + find_integration(integrations, name) || build_from_instance(name) || build_integration(name) + end + + # rubocop: disable CodeReuse/ServiceClass + def create_labels + Label.templates.in_organization(organization).each do |label| + # slice on column_names to ensure an added DB column will not break a mixed deployment + params = label.attributes + .slice(*Label.column_names) + .except('id', 'template', 'created_at', 'updated_at', 'type', 'organization_id') + Labels::FindOrCreateService.new(nil, self, params).execute(skip_authorization: true) + end + end + # rubocop: enable CodeReuse/ServiceClass + + def ci_integrations + integrations.where(category: :ci) + end + + def ci_integration + @ci_integration ||= ci_integrations.reorder(nil).find_by(active: true) + end + + def avatar_in_git + repository.avatar + end + + def avatar_url(**args) + Gitlab::Routing.url_helpers.project_avatar_url(self) if avatar_in_git + end + + # For compatibility with old code + def code + path + end + + def all_clusters + group_clusters = Clusters::Cluster.joins(:groups).where(cluster_groups: { group_id: ancestors_upto }) + instance_clusters = Clusters::Cluster.instance_type + + Clusters::Cluster.from_union([clusters, group_clusters, instance_clusters]) + end + + def items_for(entity) + case entity + when 'issue' then + issues + when 'merge_request' then + merge_requests + end + end + + # rubocop: disable CodeReuse/ServiceClass + def send_move_instructions(old_path_with_namespace) + # New project path needs to be committed to the DB or notification will + # retrieve stale information + run_after_commit do + NotificationService.new.project_was_moved(self, old_path_with_namespace) + end + end + # rubocop: enable CodeReuse/ServiceClass + + def owner + # This will be phased out and replaced with `owners` relationship + # backed by memberships with direct/inherited Owner access roles + # See https://gitlab.com/groups/gitlab-org/-/epics/7405 + group || namespace.try(:owner) + end + + def deprecated_owner + # Kept in order to maintain webhook structures until we remove owner_name and owner_email + # See https://gitlab.com/gitlab-org/gitlab/-/issues/350603 + group || namespace.try(:owner) + end + + def owners + # This will be phased out and replaced with `owners` relationship + # backed by memberships with direct/inherited Owner access roles + # See https://gitlab.com/groups/gitlab-org/-/epics/7405 + team.owners + end + + def first_owner + obj = owner + + if obj.respond_to?(:first_owner) + obj.first_owner + else + obj + end + end + + # rubocop: disable CodeReuse/ServiceClass + def execute_hooks(data, hooks_scope = :push_hooks) + run_after_commit_or_now do + triggered_hooks(hooks_scope, data).execute + SystemHooksService.new.execute_hooks(data, hooks_scope) + end + end + # rubocop: enable CodeReuse/ServiceClass + + def triggered_hooks(hooks_scope, data) + triggered = ::Projects::TriggeredHooks.new(hooks_scope, data) + + # By default the webhook resource_access_token_hooks will execute for + # seven_days interval but we have a setting to allow webhook execution + # for thirty_days and sixty_days interval too. + if hooks_scope == :resource_access_token_hooks && + data[:interval] != :seven_days && + !self.extended_prat_expiry_webhooks_execute? + + triggered + else + triggered.add_hooks(hooks) + end + end + + def execute_integrations(data, hooks_scope = :push_hooks, skip_ci: false) + # Call only service hooks that are active for this scope + run_after_commit_or_now do + association("#{hooks_scope}_integrations").reader.each do |integration| + next if skip_ci && integration.ci? + + integration.async_execute(data) + end + end + end + + def execute_flow_triggers(_, _) + # noop in CE + end + + def has_active_hooks?(hooks_scope = :push_hooks) + @has_active_hooks ||= {} # rubocop: disable Gitlab/PredicateMemoization + + return @has_active_hooks[hooks_scope] if @has_active_hooks.key?(hooks_scope) + + @has_active_hooks[hooks_scope] = hooks.hooks_for(hooks_scope).any? || + SystemHook.hooks_for(hooks_scope).any? || + Gitlab::FileHook.any? + end + + def has_active_integrations?(hooks_scope = :push_hooks) + @has_active_integrations ||= {} # rubocop: disable Gitlab/PredicateMemoization + + return @has_active_integrations[hooks_scope] if @has_active_integrations.key?(hooks_scope) + + @has_active_integrations[hooks_scope] = integrations.public_send(hooks_scope).any? # rubocop:disable GitlabSecurity/PublicSend + end + + def feature_usage + super.presence || build_feature_usage + end + + def forked? + fork_network && fork_network.root_project != self + end + + def fork_source + return unless forked? + + forked_from_project || fork_network&.root_project + end + + def valid_lfs_oids(oids_to_check) + lfs_objects.where(oid: oids_to_check).pluck(:oid) + end + + def lfs_objects_for_repository_types(*types) + LfsObject + .joins(:lfs_objects_projects) + .where(lfs_objects_projects: { project: self, repository_type: types }) + end + + def lfs_objects_oids(oids: []) + oids(lfs_objects, oids: oids) + end + + def lfs_objects_oids_from_fork_source(oids: []) + return [] unless forked? + + oids(fork_source.lfs_objects, oids: oids) + end + + def personal? + !group + end + + # Expires various caches before a project is renamed. + def expire_caches_before_rename(old_path) + project_repo = Repository.new(old_path, self, shard: repository_storage) + wiki_repo = Repository.new("#{old_path}#{Gitlab::GlRepository::WIKI.path_suffix}", self, shard: repository_storage, repo_type: Gitlab::GlRepository::WIKI) + design_repo = Repository.new("#{old_path}#{Gitlab::GlRepository::DESIGN.path_suffix}", self, shard: repository_storage, repo_type: Gitlab::GlRepository::DESIGN) + + [project_repo, wiki_repo, design_repo].each do |repo| + repo.before_delete if repo.exists? + end + end + + # Check if repository already exists on disk + def check_repository_path_availability + return true if skip_disk_validation + return false unless repository_storage + + # Check if repository with same path already exists on disk we can + # skip this for the hashed storage because the path does not change + if legacy_storage? && repository_with_same_path_already_exists? + errors.add(:base, _('There is already a repository with that name on disk')) + return false + end + + true + rescue GRPC::Internal # if the path is too long + false + end + + # When the project has an existing project_repository record, we want to update it. + # Otherwise, if a corresponding Git repository exists, we create a new record. + # This method might be called when a project_repository record already exists, + # during project transfer to another namespace, in Projects::TransferService + # or when repository storage is being updated in Projects::UpdateRepositoryStorageService + def track_project_repository + return unless project_repository.presence || repository.raw_repository.exists? + + (project_repository || build_project_repository).tap do |proj_repo| + attributes = { shard_name: repository_storage, disk_path: disk_path } + + object_format = repository.object_format + attributes[:object_format] = object_format if object_format.present? + + proj_repo.update!(**attributes) + end + + cleanup + end + + def create_repository(force: false, default_branch: nil, object_format: nil) + # Forked import is handled asynchronously + return if forked? && !force + + repository.create_repository(default_branch, object_format: object_format) + repository.after_create + + true + rescue StandardError => e + Gitlab::ErrorTracking.track_exception(e, project: { id: id, full_path: full_path, disk_path: disk_path }) + errors.add(:base, _('Failed to create repository')) + false + end + + def hook_attrs(backward: true) + attrs = { + id: id, + name: name, + description: description, + web_url: web_url, + avatar_url: avatar_url(only_path: false), + git_ssh_url: ssh_url_to_repo, + git_http_url: http_url_to_repo, + namespace: namespace.name, + visibility_level: visibility_level, + path_with_namespace: full_path, + default_branch: default_branch, + ci_config_path: ci_config_path + } + + # Backward compatibility + if backward + attrs.merge!({ + homepage: web_url, + url: url_to_repo, + ssh_url: ssh_url_to_repo, + http_url: http_url_to_repo + }) + end + + attrs + end + + def member(user) + if project_members.loaded? + project_members.find { |member| member.user_id == user.id } + else + project_members.find_by(user_id: user) + end + end + + # Overridden in EE + def membership_locked? + false + end + + def bots + users.project_bot + end + + # Filters `users` to return only authorized users of the project + def members_among(users) + if users.is_a?(ActiveRecord::Relation) && !users.loaded? + authorized_users.merge(users) + else + return [] if users.empty? + + user_ids = authorized_users.where(users: { id: users.map(&:id) }).pluck(:id) + users.select { |user| user_ids.include?(user.id) } + end + end + + def visibility_level_field + :visibility_level + end + + override :after_repository_change_head + def after_repository_change_head + ProjectCacheWorker.perform_async(self.id, [], %w[commit_count]) + + super + end + + def forked_from?(other_project) + forked? && forked_from_project == other_project + end + + def in_fork_network_of?(other_project) + return false if fork_network.nil? || other_project.fork_network.nil? + + fork_network == other_project.fork_network + end + + def origin_merge_requests + merge_requests.where(source_project_id: self.id) + end + + def ensure_repository + create_repository(force: true) unless repository_exists? + end + + # Overridden in EE + def allowed_to_share_with_group? + share_with_group_enabled? + end + + def share_with_group_enabled? + !parent.share_with_group_lock? + end + + def latest_successful_pipeline_for_default_branch + if defined?(@latest_successful_pipeline_for_default_branch) + return @latest_successful_pipeline_for_default_branch + end + + @latest_successful_pipeline_for_default_branch = + ci_pipelines.latest_successful_for_ref(default_branch) + end + + def feature_available?(feature, user = nil) + !!project_feature&.feature_available?(feature, user) + end + + def builds_enabled? + !!project_feature&.builds_enabled? + end + + def wiki_enabled? + !!project_feature&.wiki_enabled? + end + + def merge_requests_enabled? + !!project_feature&.merge_requests_enabled? + end + + def forking_enabled? + !!project_feature&.forking_enabled? + end + + def issues_enabled? + !!project_feature&.issues_enabled? + end + + def pages_enabled? + !!project_feature&.pages_enabled? + end + + def analytics_enabled? + !!project_feature&.analytics_enabled? + end + + def snippets_enabled? + !!project_feature&.snippets_enabled? + end + + def public_pages? + !private_pages? + end + + def private_pages? + return false unless Gitlab.config.pages.access_control + + pages_access_control_forced_by_ancestor? || + !!project_feature&.private_pages? + end + + def pages_access_control_forced_by_ancestor? + return true if ::Gitlab::Pages.access_control_is_forced? + + namespace.pages_access_control_forced_by_self_or_ancestor? + end + + def operations_enabled? + !!project_feature&.operations_enabled? + end + + def container_registry_enabled? + !!project_feature&.container_registry_enabled? + end + alias_method :container_registry_enabled, :container_registry_enabled? + + def enable_ci + project_feature.update_attribute(:builds_access_level, ProjectFeature::ENABLED) + end + + def shared_runners_available? + shared_runners_enabled? + end + + def shared_runners + @shared_runners ||= shared_runners_enabled? ? Ci::Runner.instance_type : Ci::Runner.none + end + + def available_shared_runners + @available_shared_runners ||= shared_runners_available? ? shared_runners : Ci::Runner.none + end + + def group_runners + @group_runners ||= group_runners_enabled? ? Ci::Runner.belonging_to_parent_groups_of_project(self.id) : Ci::Runner.none + end + + def all_runners + Ci::Runner.from_union([runners, group_runners, shared_runners]) + end + + def all_available_runners + Ci::Runner.from_union([runners, group_runners, available_shared_runners]) + end + + def active_runners + strong_memoize(:active_runners) do + all_available_runners.active + end + end + + def any_online_runners?(&block) + online_runners_with_tags.any?(&block) + end + + def valid_runners_token?(token) + self.runners_token && ActiveSupport::SecurityUtils.secure_compare(token, self.runners_token) + end + + # rubocop: disable CodeReuse/ServiceClass + def open_issues_count(current_user = nil) + return Projects::OpenIssuesCountService.new(self, current_user).count unless current_user.nil? + + BatchLoader.for(self).batch do |projects, loader| + issues_count_per_project = ::Projects::BatchOpenIssuesCountService.new(projects).refresh_cache_and_retrieve_data + + issues_count_per_project.each do |project, count| + loader.call(project, count) + end + end + end + + # rubocop: enable CodeReuse/ServiceClass + # rubocop: disable CodeReuse/ServiceClass + def open_merge_requests_count(_current_user = nil) + BatchLoader.for(self).batch do |projects, loader| + ::Projects::BatchOpenMergeRequestsCountService.new(projects) + .refresh_cache_and_retrieve_data + .each { |project, count| loader.call(project, count) } + end + end + # rubocop: enable CodeReuse/ServiceClass + + def visibility_level_allowed_as_fork?(level = self.visibility_level) + return true unless forked? + + original_project = fork_source + return true unless original_project + + level <= original_project.visibility_level + end + + def visibility_level_allowed_by_namespace?(level = self.visibility_level) + return true unless group || namespace + return level <= group.visibility_level if group + + level <= namespace.visibility_level + end + + def visibility_level_allowed?(level = self.visibility_level) + visibility_level_allowed_as_fork?(level) && visibility_level_allowed_by_namespace?(level) + end + + def runners_token + return unless namespace.allow_runner_registration_token? + + ensure_runners_token! + end + + def pages_deployed? + active_pages_deployments.exists? + end + + def pages_show_onboarding? + !(pages_metadatum&.onboarding_complete || pages_deployed?) + end + + def pages_unique_domain_enabled? + project_setting.pages_unique_domain_enabled + end + + def remove_private_deploy_keys + exclude_keys_linked_to_other_projects = <<-SQL + NOT EXISTS ( + SELECT 1 + FROM deploy_keys_projects dkp2 + WHERE dkp2.deploy_key_id = deploy_keys_projects.deploy_key_id + AND dkp2.project_id != deploy_keys_projects.project_id + ) + SQL + + deploy_keys.where(public: false) + .where(exclude_keys_linked_to_other_projects) + .delete_all + end + + def mark_pages_onboarding_complete + ensure_pages_metadatum.update!(onboarding_complete: true) + end + + def after_import + repository.expire_content_cache + repository.remove_prohibited_refs + wiki.repository.expire_content_cache + + track_project_repository + + DetectRepositoryLanguagesWorker.perform_async(id) + ProjectCacheWorker.perform_async(self.id, [], %w[repository_size wiki_size]) + AuthorizedProjectUpdate::ProjectRecalculateWorker.perform_async(id) + Issues::PlacementWorker.perform_async({ 'namespace_id' => project_namespace.work_item_positioning_root.id }) + + enqueue_record_project_target_platforms + + reset_counters_and_iids + + import_state&.finish + after_create_default_branch + join_pool_repository + refresh_markdown_cache! + end + + def reset_counters_and_iids + # The import assigns iid values on its own, e.g. by re-using GitHub ids. + # Flush existing InternalId records for this project for consistency reasons. + # Those records are going to be recreated with the next normal creation + # of a model instance (e.g. an Issue). + InternalId.flush_records!(project: self) + InternalId.flush_records!(namespace: project_namespace, usage: :issues) + update_project_counter_caches + end + + def update_project_counter_caches + classes = [ + Projects::OpenIssuesCountService, + Projects::OpenMergeRequestsCountService, + Projects::OpenWorkItemsCountService + ] + + classes.each do |klass| + klass.new(self).refresh_cache + end + end + + # rubocop: disable CodeReuse/ServiceClass + def after_create_default_branch + Projects::ProtectDefaultBranchService.new(self).execute + end + # rubocop: enable CodeReuse/ServiceClass + + # Lazy loading of the `pipeline_status` attribute + def pipeline_status + @pipeline_status ||= Gitlab::Cache::Ci::ProjectPipelineStatus.load_for_project(self) + end + + def add_export_job(current_user:, after_export_strategy: nil, params: {}) + check_project_export_limit! + check_duplicate_export!(current_user) + + params[:exported_by_admin] = current_user.can_admin_all_resources? + + job_id = Projects::ImportExport::CreateRelationExportsWorker + .perform_async(current_user.id, self.id, after_export_strategy, params) + + if job_id + Gitlab::AppLogger.info "Export job started for project ID #{self.id} with job ID #{job_id}" + else + Gitlab::AppLogger.error "Export job failed to start for project ID #{self.id}" + end + end + + def import_export_shared + @import_export_shared ||= Gitlab::ImportExport::Shared.new(self) + end + + def export_path + return unless namespace.present? || hashed_storage?(:repository) + + import_export_shared.archive_path + end + + def export_status(user) + if regeneration_in_progress?(user) + :regeneration_in_progress + elsif export_enqueued?(user) + :queued + elsif export_in_progress?(user) + :started + elsif export_file_exists?(user) + :finished + elsif export_failed?(user) + :failed + else + :none + end + end + + def export_in_progress?(user) + strong_memoize(:export_in_progress) do + ::Projects::ExportJobFinder.new(self, user, { status: :started }).execute.present? + end + end + + def export_enqueued?(user) + strong_memoize(:export_enqueued) do + ::Projects::ExportJobFinder.new(self, user, { status: :queued }).execute.present? + end + end + + def export_failed?(user) + strong_memoize(:export_failed) do + ::Projects::ExportJobFinder.new(self, user, { status: :failed }).execute.present? + end + end + + def regeneration_in_progress?(user) + (export_enqueued?(user) || export_in_progress?(user)) && export_file_exists?(user) + end + + def remove_exports + import_export_uploads.each do |import_export_upload| + next unless import_export_upload.export_file_exists? + + import_export_upload.remove_export_file! + import_export_upload.save unless import_export_upload.destroyed? + end + end + + def remove_export_for_user(user) + import_export_upload = import_export_upload_by_user(user) + return unless import_export_upload&.export_file_exists? + + import_export_upload.remove_export_file! + import_export_upload.save unless import_export_upload.destroyed? + end + + def import_export_upload_by_user(user) + import_export_uploads.find_by(user_id: user.id) + end + + def export_file_exists?(user) + import_export_upload_by_user(user)&.export_file_exists? + end + + def export_archive_exists?(user) + import_export_upload_by_user(user)&.export_archive_exists? + end + + def export_file(user) + import_export_upload_by_user(user)&.export_file + end + + def full_path_slug + Gitlab::Utils.slugify(full_path.to_s) + end + + def has_ci? + has_ci_config_file? || auto_devops_enabled? + end + + def has_ci_config_file? + strong_memoize(:has_ci_config_file) do + ci_config_for('HEAD').present? + end + end + + def predefined_variables + strong_memoize(:predefined_variables) do + Gitlab::Ci::Variables::Collection.new + .concat(predefined_ci_server_variables) + .concat(predefined_project_variables) + .concat(pages_variables) + .concat(container_registry_variables) + .concat(dependency_proxy_variables) + .concat(auto_devops_variables) + .concat(api_variables) + .concat(ci_template_variables) + end + end + + # rubocop: disable Metrics/AbcSize -- TODO: Method size will be reduced in follow-up MR, see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/199182#note_2655958320 + def predefined_project_variables + strong_memoize(:predefined_project_variables) do + Gitlab::Ci::Variables::Collection.new + .append(key: 'GITLAB_FEATURES', value: licensed_features.join(',')) + .append(key: 'CI_PROJECT_ID', value: id.to_s) + .append(key: 'CI_PROJECT_NAME', value: path) + .append(key: 'CI_PROJECT_TITLE', value: title) + .append(key: 'CI_PROJECT_DESCRIPTION', value: description) + .append(key: 'CI_PROJECT_TOPICS', value: topic_list.first(20).join(',').downcase) + .append(key: 'CI_PROJECT_PATH', value: full_path) + .append(key: 'CI_PROJECT_PATH_SLUG', value: full_path_slug) + .append(key: 'CI_PROJECT_NAMESPACE', value: namespace.full_path) + .append(key: 'CI_PROJECT_NAMESPACE_SLUG', value: Gitlab::Utils.slugify(namespace.full_path)) + .append(key: 'CI_PROJECT_NAMESPACE_ID', value: namespace.id.to_s) + .append(key: 'CI_PROJECT_ROOT_NAMESPACE', value: namespace.root_ancestor.path) + .append(key: 'CI_PROJECT_ROOT_NAMESPACE_SLUG', value: Gitlab::Utils.slugify(namespace.root_ancestor.path)) + .append(key: 'CI_PROJECT_URL', value: web_url) + .append(key: 'CI_PROJECT_VISIBILITY', value: Gitlab::VisibilityLevel.string_level(visibility_level)) + .append(key: 'CI_PROJECT_REPOSITORY_LANGUAGES', value: repository_languages.map(&:name).join(',').downcase) + .append(key: 'CI_PROJECT_CLASSIFICATION_LABEL', value: external_authorization_classification_label) + .append(key: 'CI_DEFAULT_BRANCH', value: default_branch) + .append(key: 'CI_DEFAULT_BRANCH_SLUG', value: Gitlab::Utils.slugify(default_branch.to_s)) + .append(key: 'CI_CONFIG_PATH', value: ci_config_path_or_default) + end + end + # rubocop: enable Metrics/AbcSize + + def predefined_ci_server_variables + Gitlab::Ci::Variables::Collection.new + .append(key: 'CI', value: 'true') + .append(key: 'GITLAB_CI', value: 'true') + .append(key: 'CI_SERVER_FQDN', value: Gitlab.config.gitlab.server_fqdn) + .append(key: 'CI_SERVER_URL', value: Gitlab.config.gitlab.url) + .append(key: 'CI_SERVER_HOST', value: Gitlab.config.gitlab.host) + .append(key: 'CI_SERVER_PORT', value: Gitlab.config.gitlab.port.to_s) + .append(key: 'CI_SERVER_PROTOCOL', value: Gitlab.config.gitlab.protocol) + .append(key: 'CI_SERVER_SHELL_SSH_HOST', value: Gitlab.config.gitlab_shell.ssh_host.to_s) + .append(key: 'CI_SERVER_SHELL_SSH_PORT', value: Gitlab.config.gitlab_shell.ssh_port.to_s) + .append(key: 'CI_SERVER_NAME', value: 'GitLab') + .append(key: 'CI_SERVER_VERSION', value: Gitlab::VERSION) + .append(key: 'CI_SERVER_VERSION_MAJOR', value: Gitlab.version_info.major.to_s) + .append(key: 'CI_SERVER_VERSION_MINOR', value: Gitlab.version_info.minor.to_s) + .append(key: 'CI_SERVER_VERSION_PATCH', value: Gitlab.version_info.patch.to_s) + .append(key: 'CI_SERVER_REVISION', value: Gitlab.revision) + end + + def pages_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + break unless pages_enabled? + + variables + .append(key: 'CI_PAGES_DOMAIN', value: Gitlab.config.pages.host) + .append(key: 'CI_PAGES_HOSTNAME', value: pages_hostname) + end + end + + def api_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables.append(key: 'CI_API_V4_URL', value: API::Helpers::Version.new('v4').root_url) + variables.append(key: 'CI_API_GRAPHQL_URL', value: Gitlab::Routing.url_helpers.api_graphql_url) + end + end + + def ci_template_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables.append(key: 'CI_TEMPLATE_REGISTRY_HOST', value: 'registry.gitlab.com') + end + end + + def dependency_proxy_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + break variables unless Gitlab.config.dependency_proxy.enabled + + variables.append(key: 'CI_DEPENDENCY_PROXY_SERVER', value: Gitlab.host_with_port) + variables.append( + key: 'CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX', + # The namespace path can include uppercase letters, which + # Docker doesn't allow. The proxy expects it to be downcased. + value: "#{Gitlab.host_with_port}/#{namespace.root_ancestor.path.downcase}#{DependencyProxy::URL_SUFFIX}" + ) + variables.append( + key: 'CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX', + value: "#{Gitlab.host_with_port}/#{namespace.full_path.downcase}#{DependencyProxy::URL_SUFFIX}" + ) + end + end + + def container_registry_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + break variables unless Gitlab.config.registry.enabled + + variables.append(key: 'CI_REGISTRY', value: Gitlab.config.registry.host_port) + + if container_registry_enabled? + variables.append(key: 'CI_REGISTRY_IMAGE', value: container_registry_url) + end + end + end + + def default_environment + production_first = Arel.sql("(CASE WHEN name = 'production' THEN 0 ELSE 1 END), id ASC") + + environments + .with_state(:available) + .reorder(production_first) + .first + end + + def protected_for?(ref) + raise Repository::AmbiguousRefError if repository.ambiguous_ref?(ref) + + resolved_ref = repository.expand_ref(ref) || ref + return false unless Gitlab::Git.tag_ref?(resolved_ref) || Gitlab::Git.branch_ref?(resolved_ref) + + ref_name = if resolved_ref == ref + Gitlab::Git.ref_name(resolved_ref) + else + ref + end + + if Gitlab::Git.branch_ref?(resolved_ref) + ProtectedBranch.protected?(self, ref_name) + elsif Gitlab::Git.tag_ref?(resolved_ref) + ProtectedTag.protected?(self, ref_name) + end + end + + def deployment_variables(environment:, kubernetes_namespace: nil) + platform = deployment_platform(environment: environment) + + return [] unless platform.present? + + platform.predefined_variables( + project: self, + environment_name: environment, + kubernetes_namespace: kubernetes_namespace + ) + end + + def auto_devops_variables + return [] unless auto_devops_enabled? + + (auto_devops || build_auto_devops)&.predefined_variables + end + + def route_map_for(commit_sha) + @route_maps_by_commit ||= Hash.new do |h, sha| + h[sha] = begin + data = repository.route_map_for(sha) + + Gitlab::RouteMap.new(data) if data + rescue Gitlab::RouteMap::FormatError + nil + end + end + + @route_maps_by_commit[commit_sha] + end + + def public_path_for_source_path(path, commit_sha) + map = route_map_for(commit_sha) + return unless map + + map.public_path_for_source_path(path) + end + + def parent_changed? + namespace_id_changed? + end + + def default_merge_request_target + return self if project_setting.mr_default_target_self + return self unless mr_can_target_upstream? + + forked_from_project + end + + def mr_can_target_upstream? + # When our current visibility is more restrictive than the upstream project, + # (e.g., the fork is `private` but the parent is `public`), don't allow target upstream + forked_from_project && + forked_from_project.merge_requests_enabled? && + forked_from_project.visibility_level_value <= visibility_level_value + end + + def multiple_issue_boards_available? + true + end + + def full_path_before_last_save + File.join(namespace.full_path, path_before_last_save) + end + + alias_method :name_with_namespace, :full_name + alias_method :human_name, :full_name + # @deprecated cannot remove yet because it has an index with its name in elasticsearch + alias_method :path_with_namespace, :full_path + + # rubocop: disable CodeReuse/ServiceClass + def forks_count + BatchLoader.for(self).batch do |projects, loader| + fork_count_per_project = ::Projects::BatchForksCountService.new(projects).refresh_cache_and_retrieve_data + + fork_count_per_project.each do |project, count| + loader.call(project, count) + end + end + end + # rubocop: enable CodeReuse/ServiceClass + + def legacy_storage? + [nil, 0].include?(self.storage_version) + end + + # Check if Hashed Storage is enabled for the project with at least informed feature rolled out + # + # @param [Symbol] feature that needs to be rolled out for the project (:repository, :attachments) + def hashed_storage?(feature) + raise ArgumentError, _("Invalid feature") unless HASHED_STORAGE_FEATURES.include?(feature) + + self.storage_version && self.storage_version >= HASHED_STORAGE_FEATURES[feature] + end + + def archived + super && !self_deletion_scheduled? + end + + alias_method :self_archived?, :archived + + def self_or_ancestors_archived? + self_archived? || namespace.self_or_ancestors_archived? + end + + def ancestors_archived? + ancestors.archived.exists? + end + + def self_and_ancestors_active? + self.class.where(id: id).self_and_ancestors_active.exists? + end + + def self_or_ancestors_transfer_scheduled? + project_namespace.transfer_scheduled? || namespace.self_or_ancestors_transfer_scheduled? + end + + def self_or_ancestors_transfer_in_progress? + project_namespace.transfer_in_progress? || namespace.self_or_ancestors_transfer_in_progress? + end + + def renamed? + persisted? && path_changed? + end + + def human_merge_method + if merge_method == :ff + 'Fast-forward' + else + merge_method.to_s.humanize + end + end + + def merge_method + if self.merge_requests_ff_only_enabled + :ff + elsif self.merge_requests_rebase_enabled + :rebase_merge + else + :merge + end + end + + def can_create_new_ref_commits? + merge_method != :merge + end + + def merge_method=(method) + case method.to_s + when "ff" + self.merge_requests_ff_only_enabled = true + self.merge_requests_rebase_enabled = true + when "rebase_merge" + self.merge_requests_ff_only_enabled = false + self.merge_requests_rebase_enabled = true + when "merge" + self.merge_requests_ff_only_enabled = false + self.merge_requests_rebase_enabled = false + end + end + + def ff_merge_must_be_possible? + self.merge_requests_ff_only_enabled || self.merge_requests_rebase_enabled + end + + override :git_transfer_in_progress? + def git_transfer_in_progress? + GL_REPOSITORY_TYPES.any? do |type| + reference_counter(type: type).value > 0 + end + end + + def storage_version=(value) + super + + @storage = nil if storage_version_changed? + end + + def badges + return project_badges unless group + + Badge.from_union([project_badges, GroupBadge.where(group: group.self_and_ancestors)]) + end + + def merge_requests_allowing_push_to_user(user) + return MergeRequest.none unless user + + developer_access_exists = user.project_authorizations + .where('access_level >= ? ', Gitlab::Access::DEVELOPER) + .where('project_authorizations.project_id = merge_requests.target_project_id') + .limit(1) + .select(1) + merge_requests_allowing_collaboration.where('EXISTS (?)', developer_access_exists) + end + + def any_branch_allows_collaboration?(user) + fetch_branch_allows_collaboration(user) + end + + def branch_allows_collaboration?(user, branch_name) + fetch_branch_allows_collaboration(user, branch_name) + end + + def external_authorization_classification_label + super || ::Gitlab::CurrentSettings.current_application_settings + .external_authorization_service_default_label + end + + # Overridden in EE::Project + def licensed_feature_available?(_feature) + false + end + + def licensed_features + [] + end + + def toggle_ci_cd_settings!(settings_attribute) + ci_cd_settings.toggle!(settings_attribute) + end + + def gitlab_deploy_token + strong_memoize(:gitlab_deploy_token) do + deploy_tokens.gitlab_deploy_token || group&.gitlab_deploy_token + end + end + + def any_lfs_file_locks? + lfs_file_locks.any? + end + request_cache(:any_lfs_file_locks?) { self.id } + + def auto_cancel_pending_pipelines? + auto_cancel_pending_pipelines == 'enabled' + end + + def storage + @storage ||= + if hashed_storage?(:repository) + Storage::Hashed.new(self) + else + Storage::LegacyProject.new(self) + end + end + + def snippets_visible?(user = nil) + Ability.allowed?(user, :read_snippet, self) + end + + def max_attachment_size + Gitlab::CurrentSettings.max_attachment_size.megabytes.to_i + end + + # Git objects are only poolable when the project is or has: + # - Hashed storage -> The object pool will have a remote to its members, using relative paths. + # If the repository path changes we would have to update the remote. + # - not private -> The visibility level or repository access level has to be greater than private + # to prevent fetching objects that might not exist + # - Repository -> Else the disk path will be empty, and there's nothing to pool + def git_objects_poolable? + hashed_storage?(:repository) && + visibility_level > Gitlab::VisibilityLevel::PRIVATE && + repository_access_level > ProjectFeature::PRIVATE && + repository_exists? && + Gitlab::CurrentSettings.hashed_storage_enabled + end + + def leave_pool_repository + return if pool_repository.blank? + + # Disconnecting the repository can be expensive, so let's skip it if + # this repository is being deleted anyway. + pool_repository.unlink_repository(repository, disconnect: !pending_delete?) + update_column(:pool_repository_id, nil) + end + + # After repository is moved from shard to shard, disconnect it from the previous object pool and connect to the new pool + def swap_pool_repository! + return unless repository_exists? + + old_pool_repository = pool_repository + return if old_pool_repository.blank? + return if pool_repository_shard_matches_repository?(old_pool_repository) + + new_pool_repository = PoolRepository.by_disk_path_and_shard_name(old_pool_repository.disk_path, repository_storage).take! + update!(pool_repository: new_pool_repository) + + old_pool_repository.unlink_repository(repository, disconnect: !pending_delete?) + end + + def link_pool_repository + return unless pool_repository + return if pool_repository.shard_name != repository.shard + + pool_repository.link_repository(repository) + end + + def has_pool_repository? + pool_repository.present? + end + + def access_request_approvers_to_be_notified + access_request_approvers = members.owners_and_maintainers + + recipients = access_request_approvers.connected_to_user.order_recent_sign_in.limit(Member::ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT) + + if recipients.blank? + recipients = group.access_request_approvers_to_be_notified + end + + recipients + end + + def closest_setting(name) + setting = read_attribute(name) + setting = closest_namespace_setting(name) if setting.nil? + setting = app_settings_for(name) if setting.nil? + setting + end + + def drop_visibility_level! + if group && group.visibility_level < visibility_level + self.visibility_level = group.visibility_level + end + + if Gitlab::CurrentSettings.restricted_visibility_levels.include?(visibility_level) + self.visibility_level = Gitlab::VisibilityLevel::PRIVATE + end + end + + def template_source? + false + end + + def jira_subscription_exists? + JiraConnectSubscription.for_project(self).exists? + end + + def group_protected_branches + return root_namespace.protected_branches if root_namespace.is_a?(Group) + + ProtectedBranch.none + end + + def default_branch_protected? + branch_protection = Gitlab::Access::DefaultBranchProtection.new(self.namespace.default_branch_protection_settings) + + !branch_protection.developer_can_push? + end + + def environments_for_scope(scope) + quoted_scope = ::Gitlab::SQL::Glob.q(scope) + + environments.where("name LIKE (#{::Gitlab::SQL::Glob.to_like(quoted_scope)})") # rubocop:disable GitlabSecurity/SqlInjection + end + + def batch_loaded_environment_by_name(name) + # This code path has caused N+1s in the past, since environments are only indirectly + # associated to builds and pipelines; see https://gitlab.com/gitlab-org/gitlab/-/issues/326445 + # We therefore batch-load them to prevent dormant N+1s until we found a proper solution. + BatchLoader.for(name).batch(key: id) do |names, loader, args| + Environment.where(name: names, project: args[:key]).find_each do |environment| + loader.call(environment.name, environment) + end + end + end + + def latest_jira_import + jira_imports.last + end + + def root_namespace + if namespace.has_parent? + namespace.root_ancestor + else + namespace + end + end + + def root_group + return if personal? + + root_namespace + end + + def related_group_ids + ids = invited_group_ids + + ids += group.self_and_ancestors_ids if group + + ids + end + + def default_branch_or_main + return default_branch if default_branch + + Gitlab::DefaultBranch.value(object: self) + end + + def ci_config_path_or_default + ci_config_path.presence || Ci::Pipeline::DEFAULT_CONFIG_PATH + end + + def ci_config_for(sha) + repository.blob_data_at(sha, ci_config_path_or_default) + end + + def feature_flags_client_token + instance = operations_feature_flags_client || create_operations_feature_flags_client! + instance.token + end + + override :git_garbage_collect_worker_klass + def git_garbage_collect_worker_klass + Projects::GitGarbageCollectWorker + end + + def ci_display_pipeline_variables? + return false unless ci_cd_settings + + ci_cd_settings.display_pipeline_variables? + end + + def ci_skip_branch_pipelines_for_mrs? + return false unless ci_cd_settings + + ci_cd_settings.skip_branch_pipelines_for_mrs? + end + + def ci_forward_deployment_enabled? + return false unless ci_cd_settings + + ci_cd_settings.forward_deployment_enabled? + end + + def ci_forward_deployment_rollback_allowed? + return false unless ci_cd_settings + + ci_cd_settings.forward_deployment_rollback_allowed? + end + + def ci_allow_fork_pipelines_to_run_in_parent_project? + return false unless ci_cd_settings + + ci_cd_settings.allow_fork_pipelines_to_run_in_parent_project? + end + + def ci_outbound_job_token_scope_enabled? + return false unless ci_cd_settings + + ci_cd_settings.job_token_scope_enabled? + end + + def ci_inbound_job_token_scope_enabled? + return true unless ci_cd_settings + + return true if ::Gitlab::CurrentSettings.enforce_ci_inbound_job_token_scope_enabled? + + ci_cd_settings.inbound_job_token_scope_enabled? + end + + def restrict_user_defined_variables? + return false unless ci_cd_settings + + ci_cd_settings.restrict_user_defined_variables? + end + + def override_pipeline_variables_allowed?(access_level, user) + return false unless ci_cd_settings + + ci_cd_settings.override_pipeline_variables_allowed?(access_level, user) + end + + def ci_push_repository_for_job_token_allowed? + return false unless ci_cd_settings + + ci_cd_settings.push_repository_for_job_token_allowed? + end + + def ci_cross_project_push_for_job_token_allowed? + return false unless ci_cd_settings + + ci_cd_settings.cross_project_push_for_job_token_allowed? + end + + def keep_latest_artifacts_available? + return false unless ci_cd_settings + + ci_cd_settings.keep_latest_artifacts_available? + end + + def keep_latest_artifact? + return false unless ci_cd_settings + + ci_cd_settings.keep_latest_artifact? + end + + def group_runners_enabled? + return false unless ci_cd_settings + + ci_cd_settings.group_runners_enabled? + end + + def topic_list + self.topics.map(&:name) + end + + override :after_change_head_branch_does_not_exist + def after_change_head_branch_does_not_exist(branch) + self.errors.add(:base, _("Could not change HEAD: branch '%{branch}' does not exist") % { branch: branch }) + end + + def visible_group_links(for_user:) + user = for_user + links = project_group_links_with_preload + user.max_member_access_for_group_ids(links.map(&:group_id)) if user && links.any? + + DeclarativePolicy.user_scope do + links.select { Ability.allowed?(user, :read_group, _1.group) } + end + end + + def parent_groups + Gitlab::ObjectHierarchy.new(Group.where(id: group)).base_and_ancestors + end + + def enforced_runner_token_expiration_interval + group_settings = NamespaceSetting.where(namespace_id: parent_groups) + group_interval = group_settings.where.not(project_runner_token_expiration_interval: nil).minimum(:project_runner_token_expiration_interval)&.seconds + + [ + Gitlab::CurrentSettings.project_runner_token_expiration_interval&.seconds, + group_interval + ].compact.min + end + + def merge_commit_template_or_default + merge_commit_template.presence || DEFAULT_MERGE_COMMIT_TEMPLATE + end + + def merge_commit_template_or_default=(value) + project_setting.merge_commit_template = + if value.blank? || value.delete("\r") == DEFAULT_MERGE_COMMIT_TEMPLATE + nil + else + value + end + end + + def squash_commit_template_or_default + squash_commit_template.presence || DEFAULT_SQUASH_COMMIT_TEMPLATE + end + + def squash_commit_template_or_default=(value) + project_setting.squash_commit_template = + if value.blank? || value.delete("\r") == DEFAULT_SQUASH_COMMIT_TEMPLATE + nil + else + value + end + end + + # Overriding of Namespaces::AdjournedDeletable method + override :self_deletion_in_progress? + def self_deletion_in_progress? + pending_delete? + end + + def self_deletion_in_progress_or_hidden? + self_deletion_in_progress? || hidden? + end + + def work_item_tasks_on_boards_feature_flag_enabled? + group&.work_item_tasks_on_boards_feature_flag_enabled? || Feature.enabled?(:work_item_tasks_on_boards, type: :wip) + end + + def glql_load_on_click_feature_flag_enabled? + group&.glql_load_on_click_feature_flag_enabled? || Feature.enabled?(:glql_load_on_click, self, type: :ops) + end + + def markdown_placeholders_feature_flag_enabled? + group&.markdown_placeholders_feature_flag_enabled? || Feature.enabled?(:markdown_placeholders, self, type: :gitlab_com_derisk) + end + + def allow_iframes_in_markdown_feature_flag_enabled? + group&.allow_iframes_in_markdown_feature_flag_enabled? || Feature.enabled?(:allow_iframes_in_markdown, self, type: :wip) + end + + def sscs_malware_detection_feature_flag_enabled? + group&.sscs_malware_detection_feature_flag_enabled? || Feature.enabled?(:sscs_malware_detection, type: :wip) + end + + def use_work_item_url? + return false if Feature.enabled?(:work_item_legacy_url, self, type: :gitlab_com_derisk) + return true if group.blank? + + group.use_work_item_url? + end + + def enqueue_record_project_target_platforms + return unless Gitlab.com? + + Projects::RecordTargetPlatformsWorker.perform_async(id) + end + + def dormant? + (statistics || build_statistics).storage_size > ::Gitlab::CurrentSettings.inactive_projects_min_size_mb.megabytes && + last_activity_at < ::Gitlab::CurrentSettings.inactive_projects_send_warning_email_after_months.months.ago + end + + def refreshing_build_artifacts_size? + build_artifacts_size_refresh&.started? + end + + def group_group_links + group&.shared_with_group_links_of_ancestors_and_self || GroupGroupLink.none + end + + def security_training_available? + licensed_feature_available?(:security_training) + end + + def packages_policy_subject + ::Packages::Policies::Project.new(self) + end + + def destroy_deployment_by_id(deployment_id) + deployments.where(id: deployment_id).fast_destroy_all + end + + def can_create_custom_domains? + return true if Gitlab::CurrentSettings.max_pages_custom_domains_per_project == 0 + + pages_domains.count < Gitlab::CurrentSettings.max_pages_custom_domains_per_project + end + + def pages_domain_present?(domain_url) + pages_url == domain_url || pages_domains.any? { |domain| domain.url == domain_url } + end + + # overridden in EE + def can_suggest_reviewers? + false + end + + # overridden in EE + def suggested_reviewers_available? + false + end + + def crm_enabled? + return false unless group + + group.crm_enabled? + end + + def crm_group + return unless group + + group.crm_group + end + + def supports_lock_on_merge? + group&.supports_lock_on_merge? || ::Feature.enabled?(:enforce_locked_labels_on_merge, self, type: :ops) + end + + def path_availability + base, _, host = path.partition('.') + + return unless host == Gitlab.config.pages&.dig('host') + return unless ProjectSetting.where(pages_unique_domain: base).exists? + + errors.add(:path, s_('Project|already in use')) + end + + def repository_object_format + project_repository&.object_format + end + + def instance_runner_running_jobs_count + # excluding currently started job + ::Ci::RunningBuild.instance_type.where(project_id: self.id) + .limit(INSTANCE_RUNNER_RUNNING_JOBS_MAX_BUCKET + 1).count - 1 + end + strong_memoize_attr :instance_runner_running_jobs_count + + # Overridden in EE + def allows_multiple_merge_request_assignees? + false + end + + # Overridden in EE + def allows_multiple_merge_request_reviewers? + false + end + + # Overridden in EE + def on_demand_dast_available? + false + end + + # Overridden in EE + def supports_saved_replies? + false + end + + # Overridden in EE + def merge_trains_enabled? + false + end + + def lfs_file_locks_changed_epoch + get_epoch_from(lfs_file_locks_changed_epoch_cache_key) + end + + def refresh_lfs_file_locks_changed_epoch + refresh_epoch_cache(lfs_file_locks_changed_epoch_cache_key) + end + + def placeholder_reference_store + return unless import_state + + ::Import::PlaceholderReferences::Store.new( + import_source: import_type, + import_uid: import_state.id + ) + end + + def pages_url(options = nil) + pages_url_builder(options).pages_url + end + + def pages_hostname(options = nil) + pages_url_builder(options).hostname + end + + def uploads_sharding_key + { project_id: id } + end + + def pages_url_builder(options = nil) + strong_memoize_with(:pages_url_builder, options) do + Gitlab::Pages::UrlBuilder.new(self, options) + end + end + + def has_container_registry_protected_tag_rules?(action:, access_level:) + strong_memoize_with(:has_container_registry_protected_tag_rules, action, access_level) do + container_registry_protection_tag_rules.for_actions_and_access([action], access_level).exists? + end + end + + # Overridden for EE + def licensed_ai_features_available? + false + end + + # Ensures project has a pool repository without exposing private creation logic + def ensure_pool_repository + pool_repository || create_new_pool_repository + end + + private + + def check_duplicate_export!(current_user) + return unless export_jobs.by_user_id(current_user.id).queued_or_started.exists? + + raise ExportAlreadyInProgress, _('An export is already running or queued for this project. Please try again once it\'s done.') + end + + def with_redis(&block) + Gitlab::Redis::Cache.with(&block) + end + + def lfs_file_locks_changed_epoch_cache_key + "project:#{id}:lfs_file_locks_changed_epoch" + end + + def get_epoch_from(cache_key) + with_redis { |redis| redis.get(cache_key) }&.to_i || refresh_epoch_cache(cache_key) + end + + def refresh_epoch_cache(cache_key) + # %s = seconds since the Unix Epoch + # %L = milliseconds of the second + Time.current.strftime('%s%L').to_i.tap do |epoch| + with_redis { |redis| redis.set(cache_key, epoch, ex: EPOCH_CACHE_EXPIRATION) } + end + end + + # overridden in EE + def project_group_links_with_preload + project_group_links + end + + def save_topics + topic_ids_before = self.topic_ids + update_topics + Projects::Topic.update_non_private_projects_counter(topic_ids_before, self.topic_ids, visibility_level_previously_was, visibility_level) + end + + def update_topics + return if @topic_list.nil? + + @topic_list = @topic_list.split(',') if @topic_list.instance_of?(String) + @topic_list = @topic_list.map(&:strip).uniq.reject(&:empty?) + + if @topic_list != self.topic_list + self.topics.delete_all + self.topics = @topic_list.map do |topic_name| + Projects::Topic + .in_organization(organization_id) + .where('lower(name) = ?', topic_name.downcase) + .order(total_projects_count: :desc) + .first_or_create(name: topic_name, title: topic_name, slug: Gitlab::Slug::Path.new(topic_name).generate) + end + end + + @topic_list = nil + end + + def find_integration(integrations, name) + integrations.find { _1.to_param == name } + end + + def build_from_instance(name) + instance = find_integration(integration_instances, name) + + return unless instance + + Integration.build_from_integration(instance, project_id: id) + end + + def build_integration(name) + Integration.integration_name_to_model(name).new(project_id: id) + end + + def integration_instances + @integration_instances ||= Integration.for_instance + end + + def closest_namespace_setting(name) + namespace.closest_setting(name) + end + + def app_settings_for(name) + Gitlab::CurrentSettings.send(name) # rubocop:disable GitlabSecurity/PublicSend + end + + def merge_requests_allowing_collaboration(source_branch = nil) + relation = source_of_merge_requests.from_fork.opened.where(allow_collaboration: true) + relation = relation.where(source_branch: source_branch) if source_branch + relation + end + + def create_new_pool_repository + pool = PoolRepository.safe_find_or_create_by!( + shard: Shard.by_name(repository_storage), + source_project: self) do |new_pool| + new_pool.organization = organization + end + + update!(pool_repository: pool) + + pool.schedule unless pool.scheduled? + + pool + end + + def join_pool_repository + return unless pool_repository + + ObjectPool::JoinWorker.perform_async(pool_repository.id, self.id) + end + + def use_hashed_storage + if self.new_record? && Gitlab::CurrentSettings.hashed_storage_enabled + self.storage_version = LATEST_STORAGE_VERSION + end + end + + def check_repository_absence! + return if skip_disk_validation + + if repository_storage.blank? || repository_with_same_path_already_exists? + errors.add(:base, _('There is already a repository with that name on disk')) + throw :abort # rubocop:disable Cop/BanCatchThrow + end + end + + def repository_with_same_path_already_exists? + gitlab_shell.repository_exists?(repository_storage, "#{disk_path}.git") + end + + def set_timestamps_for_create + update_columns(last_repository_updated_at: self.created_at) + end + + def cross_namespace_reference?(from) + case from + when Project + namespace_id != from.namespace_id + when Namespaces::ProjectNamespace + namespace_id != from.parent_id + when Namespace + namespace != from + when User + true + end + end + + # Check if a reference is being done cross-project + def cross_project_reference?(from) + case from + when Namespaces::ProjectNamespace + project_namespace_id != from.id + when Namespace + true + else + from && self != from + end + end + + def update_project_statistics + stats = statistics || build_statistics + stats.update(namespace_id: namespace_id) + end + + def check_pending_delete + return if valid_attribute?(:name) && valid_attribute?(:path) + return unless pending_delete_twin + + %i[route route.path name path].each do |error| + errors.delete(error) + end + + errors.add(:base, _("The project is still being deleted. Please try again later.")) + end + + def pending_delete_twin + return false unless path + + Project.pending_delete.find_by_full_path(full_path) + end + + ## + # This method is here because of support for legacy container repository + # which has exactly the same path like project does, but which might not be + # persisted in `container_repositories` table. + # + def has_root_container_repository_tags? + return false unless Gitlab.config.registry.enabled + + ContainerRepository.build_root_repository(self).has_tags? + end + + def fetch_branch_allows_collaboration(user, branch_name = nil) + return false unless user + + Gitlab::SafeRequestStore.fetch("project-#{id}:branch-#{branch_name}:user-#{user.id}:branch_allows_collaboration") do + next false if empty_repo? + + # Issue for N+1: https://gitlab.com/gitlab-org/gitlab-foss/issues/49322 + Gitlab::GitalyClient.allow_n_plus_1_calls do + merge_requests_allowing_collaboration(branch_name).any? do |merge_request| + merge_request.author.can?(:push_code, self) && + merge_request.can_be_merged_by?(user, skip_collaboration_check: true) + end + end + end + end + + def ensure_pages_metadatum + pages_metadatum || create_pages_metadatum! + rescue ActiveRecord::RecordNotUnique + reset + retry + end + + def oids(objects, oids: []) + objects = objects.where(oid: oids) if oids.any? + + [].tap do |out| + objects.each_batch { |relation| out.concat(relation.pluck(:oid)) } + end + end + + def cache_has_external_wiki + update_column(:has_external_wiki, integrations.external_wikis.any?) if Gitlab::Database.read_write? + end + + def cache_has_external_issue_tracker + update_column(:has_external_issue_tracker, integrations.external_issue_trackers.any?) if Gitlab::Database.read_write? + end + + def online_runners_with_tags + @online_runners_with_tags ||= active_runners.with_tags.online + end + + def ensure_project_namespace_in_sync + # create project_namespace when project is created + build_project_namespace if project_namespace_creation_enabled? + + project_namespace.sync_attributes_from_project(self) if sync_project_namespace? + end + + def project_namespace_creation_enabled? + new_record? && !project_namespace && self.namespace + end + + def sync_project_namespace? + (changes.keys & Namespaces::ProjectNamespace::SYNCED_ATTRIBUTES).any? && project_namespace.present? + end + + def reload_project_namespace_details + return unless (previous_changes.keys & %w[description description_html cached_markdown_version]).any? && project_namespace.namespace_details.present? + + project_namespace.namespace_details.reset + end + + # SyncEvents are created by PG triggers (with the function `insert_projects_sync_event`) + def schedule_sync_event_worker + run_after_commit do + Projects::SyncEvent.enqueue_worker + end + end + + def check_project_export_limit! + return if Gitlab::CurrentSettings.current_application_settings.max_export_size == 0 + + if self.statistics.export_size > Gitlab::CurrentSettings.current_application_settings.max_export_size.megabytes + raise ExportLimitExceeded, _('The project size exceeds the export limit.') + end + end + + def remove_leading_spaces_on_name + name&.lstrip! + end + + def set_last_activity_at + return if last_activity_at_changed? + + if new_record? || (changed & PROJECT_ACTIVITY_ATTRIBUTES).any? + self.last_activity_at = Time.current + elsif last_activity_at.nil? + self.last_activity_at = created_at + end + end + + def set_package_registry_access_level + return if !project_feature || project_feature.package_registry_access_level_changed? + + self.project_feature.package_registry_access_level = packages_enabled ? enabled_package_registry_access_level_by_project_visibility : ProjectFeature::DISABLED + end + + def enabled_package_registry_access_level_by_project_visibility + case visibility_level + when PUBLIC + ProjectFeature::PUBLIC + when INTERNAL + ProjectFeature::ENABLED + else + ProjectFeature::PRIVATE + end + end + + def runners_token_prefix + RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX + end + + def pool_repository_shard_matches_repository?(pool) + pool_repository_shard = pool.shard.name + + pool_repository_shard == repository_storage + end + + # Catalog resource SyncEvents are created by PG triggers + def enqueue_catalog_resource_sync_event_worker + run_after_commit do + ::Ci::Catalog::Resources::SyncEvent.enqueue_worker + end + end + + # Overriding of Namespaces::AdjournedDeletable method + override :ancestors_scheduled_for_deletion + def ancestors_scheduled_for_deletion + return [] unless namespace.is_a?(Group) + + cache_key = "ancestors_scheduled_for_deletion:#{namespace.traversal_ids.join(',')}" + + Gitlab::SafeRequestStore.fetch(cache_key) do + ancestors(hierarchy_order: :asc).joins(:deletion_schedule).to_a + end + end + + def validate_unsafe_import_url? + import_url.present? && import_url_changed? + end + + def unique_attributes + [:path] + end +end + +Project.prepend_mod_with('Project') diff --git a/spec/fixtures/corpus/gitlab/app/models/release_highlight.rb b/spec/fixtures/corpus/gitlab/app/models/release_highlight.rb new file mode 100644 index 0000000..5e3c5c1 --- /dev/null +++ b/spec/fixtures/corpus/gitlab/app/models/release_highlight.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +class ReleaseHighlight + CACHE_DURATION = 1.hour + + FREE_PACKAGE = 'Free' + PREMIUM_PACKAGE = 'Premium' + ULTIMATE_PACKAGE = 'Ultimate' + + def self.paginated(page: 1) + result = self.paginated_query(page: page) + result = self.paginated_query(page: result.next_page) while next_page?(result) + result + end + + def self.paginated_query(page:) + key = self.cache_key("items:page-#{page}") + + Rails.cache.fetch(key, expires_in: CACHE_DURATION) do + items = self.load_items(page: page) + + next if items.nil? + + QueryResult.new(items: items, next_page: next_page(current_page: page)) + end + end + + def self.load_items(page:) + index = page - 1 + file_path = file_paths[index] + + return if file_path.nil? + + file = File.read(file_path) + items = YAML.safe_load(file, permitted_classes: [Date]) + + items&.map! do |item| + next unless include_item?(item) + + begin + item.tap { |i| i['description'] = Banzai.render(i['description'], { project: nil }) } + rescue StandardError => e + Gitlab::ErrorTracking.track_exception(e, file_path: file_path) + + next + end + end + + items&.compact + rescue Psych::Exception => e + Gitlab::ErrorTracking.track_exception(e, file_path: file_path) + + [] + end + + def self.whats_new_path + Rails.root.join('data/whats_new/*.yml') + end + + def self.file_paths + @file_paths ||= self.relative_file_paths.map { |path| path.prepend(Rails.root.to_s) } + end + + def self.relative_file_paths + Rails.cache.fetch(self.cache_key('file_paths'), expires_in: CACHE_DURATION) do + Dir.glob(whats_new_path) + .select { |path| release_date_reached?(path) } + .reverse + .map { |path| path.delete_prefix(Rails.root.to_s) } + end + end + + def self.release_date_reached?(path) + filename = File.basename(path, '.yml') + match = filename.match(/\A(\d{4})(\d{2})(\d{2})/) + return true unless match + + release_date = Date.new(match[1].to_i, match[2].to_i, match[3].to_i) + Date.current >= release_date + rescue Date::Error + true + end + + def self.cache_key(key) + variant = Gitlab::CurrentSettings.current_application_settings.whats_new_variant + ['release_highlight', variant, key, Gitlab.revision].join(':') + end + + def self.next_page(current_page: 1) + next_page = current_page + 1 + next_index = next_page - 1 + + next_page if self.file_paths[next_index] + end + + def self.most_recent_item_count + key = self.cache_key('recent_item_count') + + Gitlab::ProcessMemoryCache.cache_backend.fetch(key, expires_in: CACHE_DURATION) do + self.paginated&.items&.count + end + end + + def self.most_recent_version_digest + key = self.cache_key('most_recent_version_digest') + + Gitlab::ProcessMemoryCache.cache_backend.fetch(key, expires_in: CACHE_DURATION) do + version = self.paginated&.items&.first&.[]('release')&.to_s + + next if version.nil? + + Digest::SHA256.hexdigest(version) + end + end + + QueryResult = Struct.new(:items, :next_page, keyword_init: true) do + include Enumerable + + delegate :each, to: :items + end + + def self.current_package + return FREE_PACKAGE unless defined?(License) + + case License.current&.plan&.downcase + when License::PREMIUM_PLAN + PREMIUM_PACKAGE + when License::ULTIMATE_PLAN + ULTIMATE_PACKAGE + else + FREE_PACKAGE + end + end + + def self.include_item?(item) + platform = Gitlab.com? ? 'gitlab-com' : 'self-managed' + + return false unless item[platform] + + return true unless Gitlab::CurrentSettings.current_application_settings.whats_new_variant_current_tier? + + item['available_in']&.include?(current_package) + end + + def self.next_page?(result) + return false unless result + + # if all items for the current page doesn't belong to the current tier + # or failed to parse current YAML, loading next page + result.items == [] && result.next_page.present? + end +end + +ReleaseHighlight.prepend_mod diff --git a/spec/fixtures/corpus/gitlab/app/policies/project_policy.rb b/spec/fixtures/corpus/gitlab/app/policies/project_policy.rb new file mode 100644 index 0000000..fc0dc87 --- /dev/null +++ b/spec/fixtures/corpus/gitlab/app/policies/project_policy.rb @@ -0,0 +1,1068 @@ +# frozen_string_literal: true + +class ProjectPolicy < BasePolicy + include ::Ci::JobAbilities + include ::Authz::RolePermissions + include ::Authn::SubgroupProvisionedServiceAccountRestriction + + define_role_permissions(:project) + + # https://docs.gitlab.com/18.2/ci/pipelines/settings/#change-which-users-can-view-your-pipelines + desc "Project-based pipeline visibility enabled" + condition(:public_builds, scope: :subject, score: 0) { project.public_builds? } + + # For guest access we use #team_member? so we can use + # project.members, which gets cached in subject scope. + # This is safe because team_access_level is guaranteed + # by ProjectAuthorization's validation to be at minimum + # GUEST + desc "User has guest access" + condition(:guest) { team_member? } + + desc "User is a project bot" + condition(:project_bot) { user.project_bot? && team_member? } + + desc "Project is public" + condition(:public_project, scope: :subject, score: 0) { project.public? } + + desc "project is private" + condition(:private_project, scope: :subject, score: 0) { project.private? } + + desc "Project is visible to internal users" + condition(:internal_access) do + next false unless user + + project.internal? && !user.external? + end + + desc "User owns the project's organization" + condition(:organization_owner) { owns_organization?(@subject.organization) } + + rule { admin | organization_owner }.enable :read_all_organization_resources + + desc "User already has access to the project or its group, or has a pending group access request" + condition(:has_project_access_or_pending_request) do + team_access_level >= Gitlab::Access::GUEST || + project_group_member? || + project_group_requester? + end + + desc "User is external" + condition(:external_user) { user.external? } + + desc "Project is archived" + condition(:archived, scope: :subject, score: 0) { project.self_or_ancestors_archived? } + + desc "Project is scheduled for deletion" + condition(:deletion_scheduled, scope: :subject) { project.marked_for_deletion_at.present? } + + desc "Project pipeline variables minimum override role is in a privileged state" + condition(:project_pipeline_override_role_privileged) { project.pipeline_override_role_privileged? } + + desc "Project is in the process of being deleted" + condition(:self_deletion_in_progress) { project.self_deletion_in_progress? } + + condition(:default_issues_tracker, scope: :subject) { project.default_issues_tracker? } + + desc "Container registry is disabled" + # Do not use the scope option here as this condition depends + # on both the user and the subject, and can lead to bugs like + # https://gitlab.com/gitlab-org/gitlab/-/issues/391551 + condition(:container_registry_disabled) do + if user.is_a?(DeployToken) + (!user.read_registry? && !user.write_registry?) || + user.revoked? || + !project.container_registry_enabled? + else + !access_allowed_to?(:container_registry) + end + end + + desc "Project has an external wiki" + condition(:has_external_wiki, scope: :subject, score: 0) { project.has_external_wiki? } + + desc "Project has request access enabled" + condition(:request_access_enabled, scope: :subject, score: 0) { project.request_access_enabled } + + desc "Has merge requests allowing pushes to user" + condition(:has_merge_requests_allowing_pushes) do + next false unless user_is_user? + + project.merge_requests_allowing_push_to_user(user).any? + end + + desc "Deploy key with read access" + condition(:download_code_deploy_key) do + user.is_a?(DeployKey) && user.has_access_to?(project) + end + + desc "Deploy key with write access" + condition(:push_code_deploy_key) do + user.is_a?(DeployKey) && user.can_push_to?(project) + end + + desc "Deploy token with read_container_image scope" + condition(:read_container_image_deploy_token) do + user.is_a?(DeployToken) && user.has_access_to?(project) && user.read_registry? + end + + desc "Deploy token with create_container_image scope" + condition(:create_container_image_deploy_token) do + user.is_a?(DeployToken) && user.has_access_to?(project) && user.write_registry? + end + + desc "Deploy token with read_package_registry scope" + condition(:read_package_registry_deploy_token) do + user.is_a?(DeployToken) && user.has_access_to?(project) && user.read_package_registry + end + + desc "Deploy token with write_package_registry scope" + condition(:write_package_registry_deploy_token) do + user.is_a?(DeployToken) && user.has_access_to?(project) && user.write_package_registry + end + + desc "Deploy token with read_repository scope and project access" + condition(:download_code_deploy_token) do + user.is_a?(DeployToken) && user.read_repository && user.has_access_to?(project) + end + + desc "If user is authenticated via CI job token then the target project should be in scope" + condition(:project_allowed_for_job_token_by_scope) do + !@user&.from_ci_job_token? || @user.ci_job_token_scope.accessible?(project) + end + + desc "Public, internal or project in the scope allowed via CI job token" + condition(:project_allowed_for_job_token) do + public_project? || internal_access? || project_allowed_for_job_token_by_scope? + end + + desc "If the project is either public or internal" + condition(:public_or_internal) do + project.public? || project.internal? + end + + desc "If the pages is public" + condition(:public_pages) do + project.public_pages? + end + + desc "If the pages is internal" + condition(:internal_pages) do + project.project_feature.pages_access_level == ProjectFeature::ENABLED + end + + condition(:private_package_registry) do + project.project_feature.package_registry_access_level == ProjectFeature::PRIVATE + end + + condition(:forking_allowed) do + @subject.feature_available?(:forking, @user) + end + + with_scope :subject + condition(:metrics_dashboard_allowed) do + access_allowed_to?(:metrics_dashboard) + end + + with_scope :global + condition(:mirror_available, score: 0) do + ::Gitlab::CurrentSettings.current_application_settings.mirror_available + end + + condition(:classification_label_authorized, score: 32) do + next true if admin? || auditor? || organization_owner? # rubocop: disable Cop/UserAdmin -- this is the admin condition + + ::Gitlab::ExternalAuthorization.access_allowed?( + @user, + @subject.external_authorization_classification_label, + @subject.full_path + ) + end + + with_scope :subject + condition(:design_management_disabled) do + !@subject.design_management_enabled? + end + + with_scope :subject + condition(:service_desk_enabled) { ::ServiceDesk.enabled?(@subject) } + + condition(:model_experiments_enabled) do + @subject.feature_available?(:model_experiments, @user) + end + + condition(:model_registry_enabled) do + @subject.feature_available?(:model_registry, @user) + end + + with_scope :subject + condition(:resource_access_token_feature_available) do + resource_access_token_feature_available? + end + condition(:resource_access_token_creation_allowed) { resource_access_token_creation_allowed? } + + # We aren't checking `:read_issue` or `:read_merge_request` in this case + # because it could be possible for a user to see an issuable-iid + # (`:read_issue_iid` or `:read_merge_request_iid`) but then wouldn't be + # allowed to read the actual issue after a more expensive `:read_issue` + # check. These checks are intended to be used alongside + # `:read_project_for_iids`. + # + # `:read_issue` & `:read_issue_iid` could diverge in gitlab-ee. + condition(:issues_visible_to_user, score: 4) do + @subject.feature_available?(:issues, @user) + end + + condition(:merge_requests_visible_to_user, score: 4) do + @subject.feature_available?(:merge_requests, @user) + end + + condition(:internal_builds_disabled) do + !@subject.builds_enabled? + end + + condition(:user_confirmed) do + @user && @user.confirmed? + end + + condition(:build_service_proxy_enabled) do + ::Feature.enabled?(:build_service_proxy, @subject) + end + + condition(:user_defined_variables_allowed) do + @subject.override_pipeline_variables_allowed?(team_access_level, @user) + end + + desc "CI job token allowed to push to project, self-referential or allowlisted cross-project" + condition(:push_repository_for_job_token_allowed) do + next false unless @user&.from_ci_job_token? + next false unless project.ci_push_repository_for_job_token_allowed? + + @user.ci_job_token_scope.self_referential?(project) || + cross_project_push_allowed_for_job_token? + end + + condition(:packages_disabled, scope: :subject) { !@subject.packages_enabled } + + condition(:runner_registration_token_enabled, scope: :subject) { @subject.namespace.allow_runner_registration_token? } + + features = %w[ + merge_requests + issues + repository + snippets + wiki + builds + pages + metrics_dashboard + analytics + operations + monitor + security_and_compliance + environments + feature_flags + releases + infrastructure + model_experiments + ] + + features.each do |f| + # these are scored high because they are unlikely + desc "Project has #{f} disabled" + condition(:"#{f}_disabled", score: 32) { !access_allowed_to?(f.to_sym) } + end + + condition(:project_runner_registration_allowed, scope: :subject) do + Gitlab::CurrentSettings.valid_runner_registrars.include?('project') && @subject.runner_registration_enabled + end + + condition :registry_enabled do + Gitlab.config.registry.enabled + end + + condition :packages_enabled do + Gitlab.config.packages.enabled + end + + condition(:dependency_proxy_enabled) do + ::Gitlab.config.dependency_proxy.enabled + end + + condition(:dependency_proxy_for_packages_available) do + @subject.licensed_feature_available?(:dependency_proxy_for_packages) + end + + condition :terraform_state_disabled do + !Gitlab.config.terraform_state.enabled + end + + condition(:allow_guest_plus_roles_to_pull_packages_enabled, scope: :subject) do + Feature.enabled?(:allow_guest_plus_roles_to_pull_packages, @subject.root_ancestor) + end + + rule { public_project }.policy do + enable(*Authz::Role.get(:public_anonymous).direct_permissions(:project)) + end + + rule { (~anonymous & public_project) | internal_access }.policy do + enable(*Authz::Role.get(:public_authenticated).permissions(:project)) + end + + # This is needed separate from the role YAML due to the + # Ability.users_that_can_read_project method + rule { guest }.enable :read_project + + rule { admin }.policy do + enable(*Authz::Role.get(:admin).permissions(:project)) + end + + rule { project_pipeline_override_role_privileged & ~can?(:_update_privileged_pipeline_variable_override_setting) } + .prevent :update_pipeline_variable_override_setting + + condition(:can_create_fork_in_namespace) do + can?(:create_project_fork, project.namespace.root_ancestor) + end + + rule { ~can_create_fork_in_namespace }.prevent :link_forked_project + + rule { internal_pages & ~anonymous & ~external_user }.policy do + enable :read_pages_content + end + + rule { public_pages }.policy do + enable :read_pages_content + end + + rule { ~can?(:create_issue) }.policy do + prevent :create_incident + prevent :create_task + prevent :create_work_item + prevent :import_issues + prevent :import_work_items + end + + rule { ~can?(:read_environment) }.prevent :read_freeze_period + + rule { ~forking_allowed }.prevent :fork_project + + rule { metrics_dashboard_disabled }.policy do + prevent(:metrics_dashboard) + end + + rule { environments_disabled }.policy do + prevent :read_environment + prevent :create_environment + prevent :update_environment + prevent :admin_environment + prevent :destroy_environment + + prevent :read_deployment + prevent :create_deployment + prevent :update_deployment + prevent :admin_deployment + prevent :destroy_deployment + end + + rule { feature_flags_disabled }.policy do + prevent :read_feature_flag + prevent :create_feature_flag + prevent :update_feature_flag + prevent :admin_feature_flag + prevent :destroy_feature_flag + + prevent(:admin_feature_flags_user_lists) + prevent(:admin_feature_flags_client) + end + + rule { releases_disabled }.policy do + prevent :read_release + prevent :create_release + prevent :update_release + prevent :admin_release + prevent :destroy_release + end + + rule { monitor_disabled }.policy do + prevent :metrics_dashboard + + prevent :read_sentry_issue + prevent :create_sentry_issue + prevent :update_sentry_issue + prevent :admin_sentry_issue + prevent :destroy_sentry_issue + + prevent :read_alert_management_alert + prevent :create_alert_management_alert + prevent :update_alert_management_alert + prevent :admin_alert_management_alert + prevent :destroy_alert_management_alert + end + + rule { infrastructure_disabled }.policy do + prevent :read_cluster + prevent :create_cluster + prevent :update_cluster + prevent :admin_cluster + prevent :destroy_cluster + + prevent(:read_pod_logs) + prevent(:read_prometheus) + prevent(:admin_project_google_cloud) + prevent(:admin_project_aws) + end + + rule { infrastructure_disabled | terraform_state_disabled }.policy do + prevent :read_terraform_state + prevent :create_terraform_state + prevent :update_terraform_state + prevent :admin_terraform_state + prevent :destroy_terraform_state + prevent :create_terraform_state_protection_rule + prevent :delete_terraform_state_protection_rule + prevent :update_terraform_state_protection_rule + end + + rule { can?(:admin_terraform_state) }.policy do + enable :create_terraform_state_protection_rule + enable :delete_terraform_state_protection_rule + enable :update_terraform_state_protection_rule + end + + rule { can?(:metrics_dashboard) }.policy do + enable :read_deployment + end + + rule { packages_disabled }.policy do + prevent :admin_dependency_proxy_packages_settings + prevent :read_package + prevent :create_package + prevent :update_package + prevent :admin_package + prevent :destroy_package + + prevent :read_composer_package + prevent :read_go_module + prevent :read_npm_package + prevent :read_npm_package_tag + prevent :read_nuget_package + prevent :read_package_pipeline + prevent :read_pypi_package + + prevent :download_debian_package + prevent :download_generic_package + prevent :download_go_module + prevent :download_helm_chart + prevent :download_maven_package_file + prevent :download_npm_package + prevent :download_nuget_package + prevent :download_package + prevent :download_pypi_package + end + + # We need this separate rule for job tokens in case the package registry is private + # Since read_package is enabled for a different access level than other feature based + # permissions we cannot use the access_allowed_to dynamic conditions + rule { ~project_allowed_for_job_token_by_scope & private_package_registry }.policy do + prevent :read_package + prevent :create_package + prevent :update_package + prevent :admin_package + prevent :destroy_package + end + + rule { has_project_access_or_pending_request }.prevent :request_access + rule { ~request_access_enabled }.prevent :request_access + + rule { ~user_confirmed }.policy do + prevent :create_build + prevent :create_pipeline + prevent :update_pipeline + prevent :cancel_pipeline + prevent :create_pipeline_schedule + end + + rule { public_project & metrics_dashboard_allowed }.policy do + enable :metrics_dashboard + end + + rule { internal_access & metrics_dashboard_allowed }.policy do + enable :metrics_dashboard + end + + rule { (mirror_available & can?(:admin_project)) | admin }.enable :admin_remote_mirror + + rule { self_deletion_in_progress }.policy do + prevent(*Authz::PermissionGroups::Internal.get('project:pending_deletion').permissions) + end + + rule { (archived | deletion_scheduled) & ~self_deletion_in_progress }.policy do + prevent(*Authz::PermissionGroups::Internal.get('project:archived').permissions) + end + + rule { issues_disabled }.policy do + prevent(*Authz::PermissionGroups::Internal.get('project:features:work_items').permissions) + end + + rule { merge_requests_disabled | repository_disabled }.policy do + prevent :approve_merge_request + prevent :create_merge_request_in + prevent :create_merge_request_from + prevent :read_merge_request + prevent :create_merge_request + prevent :update_merge_request + prevent :admin_merge_request + prevent :destroy_merge_request + + prevent :read_merge_request_approval_rule + prevent :read_merge_request_approval_state + prevent :read_merge_request_closes_issue + prevent :read_merge_request_commit + prevent :read_merge_request_context_commit + prevent :read_merge_request_diff + prevent :read_merge_request_draft_note + prevent :read_merge_request_merge_ref + prevent :read_merge_request_participant + prevent :read_merge_request_pipeline + prevent :read_merge_request_raw_diff + prevent :read_merge_request_related_issue + prevent :read_merge_request_reviewer + prevent :read_merge_request_time_statistic + end + + rule { ~can?(:download_code) }.policy do + prevent :create_merge_request_in + end + + rule { pages_disabled }.policy do + prevent :read_pages_content + prevent :read_pages + prevent :create_pages + prevent :update_pages + prevent :admin_pages + prevent :destroy_pages + end + + rule { issues_disabled & merge_requests_disabled }.policy do + prevent :read_label + prevent :create_label + prevent :update_label + prevent :admin_label + prevent :destroy_label + prevent :read_milestone + prevent :create_milestone + prevent :update_milestone + prevent :admin_milestone + prevent :destroy_milestone + prevent :read_cycle_analytics + end + + rule { snippets_disabled }.policy do + prevent :read_snippet + prevent :create_snippet + prevent :update_snippet + prevent :admin_snippet + prevent :destroy_snippet + end + + rule { analytics_disabled }.policy do + prevent(:read_analytics) + prevent(:read_insights) + prevent(:read_cycle_analytics) + prevent(:read_repository_graphs) + prevent(:read_ci_cd_analytics) + end + + rule { wiki_disabled }.policy do + prevent :read_wiki + prevent :create_wiki + prevent :update_wiki + prevent :admin_wiki + prevent :destroy_wiki + prevent :download_wiki_code + end + + rule { download_code_deploy_token }.policy do + enable :download_code + enable :download_wiki_code + end + + rule { builds_disabled | repository_disabled }.policy do + prevent(*all_job_write_abilities) + + prevent :read_build + prevent :create_build + prevent :admin_build + prevent :destroy_build + prevent :manage_trigger + prevent :admin_cicd_variables + prevent :admin_protected_environments + + prevent :read_pipeline_schedule + prevent :create_pipeline_schedule + prevent :update_pipeline_schedule + prevent :admin_pipeline_schedule + prevent :destroy_pipeline_schedule + + prevent :read_environment + prevent :create_environment + prevent :update_environment + prevent :admin_environment + prevent :destroy_environment + + prevent :read_deployment + prevent :create_deployment + prevent :update_deployment + prevent :admin_deployment + prevent :destroy_deployment + + prevent :read_resource_group + prevent :update_resource_group + end + + # There's two separate cases when builds_disabled is true: + # 1. When internal CI is disabled - builds_disabled && internal_builds_disabled + # - We do not prevent the user from accessing Pipelines to allow them to access external CI + # 2. When the user is not allowed to access CI - builds_disabled && ~internal_builds_disabled + # - We prevent the user from accessing Pipelines + rule { (builds_disabled & ~internal_builds_disabled) | repository_disabled }.policy do + prevent :read_pipeline + prevent :create_pipeline + prevent :update_pipeline + prevent :admin_pipeline + prevent :destroy_pipeline + prevent :cancel_pipeline + + prevent :read_commit_status + prevent :create_commit_status + prevent :destroy_commit_status + end + + rule { repository_disabled }.policy do + prevent :admin_tag + prevent :build_push_code + prevent :push_code + prevent :read_code + prevent :download_code + prevent :build_download_code + prevent :fork_project + prevent :read_pipeline + prevent :read_pipeline_schedule + + prevent :read_branch + prevent :read_protected_branch + prevent :read_protected_tag + + prevent :read_commit + prevent :read_commit_comment + prevent :read_commit_diff + prevent :read_commit_merge_request + prevent :read_commit_ref + prevent :read_commit_sequence + prevent :read_commit_signature + + prevent :read_repository_archive + prevent :read_repository_blob + prevent :read_repository_changelog + prevent :read_repository_comparison + prevent :read_repository_contributor + prevent :read_repository_file + prevent :read_repository_file_blame + prevent :read_repository_health + prevent :read_repository_merge_base + prevent :read_repository_tag + prevent :read_repository_tag_signature + prevent :read_repository_tree + + prevent :read_feature_flag + prevent :create_feature_flag + prevent :update_feature_flag + prevent :admin_feature_flag + prevent :destroy_feature_flag + prevent :admin_feature_flags_user_lists + + prevent :read_cluster + prevent :create_cluster + prevent :update_cluster + prevent :admin_cluster + prevent :destroy_cluster + end + + rule { container_registry_disabled }.policy do + prevent :build_read_container_image + prevent :read_container_image + prevent :create_container_image + prevent :update_container_image + prevent :admin_container_image + prevent :destroy_container_image + prevent :destroy_container_image_tag + prevent :destroy_container_registry_protection_tag_rule + end + + rule { anonymous & ~public_project }.prevent_all do + # Private projects can make packages public + # This is controlled in Packages::Policies::ProjectPolicy + # This exception is needed since Packages::Policies::ProjectPolicy delegates to this one + except :read_package + end + + # If the project is private + rule { ~project_allowed_for_job_token }.prevent_all + + rule { public_or_internal & ~project_allowed_for_job_token_by_scope }.prevent_all do + except :build_download_code + except :build_read_container_image + except :read_build + + except(*::Authz::Role.get(:public_anonymous).direct_permissions(:project)) + end + + rule { ~push_repository_for_job_token_allowed }.prevent :build_push_code + + rule { ~public_builds }.policy do + prevent :_read_public_build + prevent :_read_public_pipeline + prevent :_read_public_pipeline_schedule + prevent :_read_public_ci_cd_analytics + end + + rule { can?(:_read_public_build) }.enable :read_build + rule { can?(:_read_public_pipeline) }.enable :read_pipeline + rule { can?(:_read_public_pipeline_schedule) }.enable :read_pipeline_schedule + rule { can?(:_read_public_ci_cd_analytics) }.enable :read_ci_cd_analytics + + # Allow upstream developers to create pipelines on a fork when the fork has + # an open MR with "Allow commits from members who can merge to the target + # branch" enabled. + rule { (public_project | internal_access) & has_merge_requests_allowing_pushes }.policy do + enable :create_build + enable :create_pipeline + end + + rule do + (can?(:read_project_for_iids) & issues_visible_to_user) | can?(:read_issue) + end.enable :read_issue_iid + + rule { ~merge_requests_visible_to_user }.prevent :read_merge_request_iid + + rule { external_authorization_enabled & ~classification_label_authorized }.prevent_all do + # Preventing access here still allows the projects to be listed. Listing + # projects doesn't check the `:read_project` ability. But instead counts + # on the `project_authorizations` table. + # + # All other actions should explicitly check read project, which would + # trigger the `classification_label_authorized` condition. + # + # read_project_for_iids, read_issue_iid, and read_merge_request_iid are not prevented + # by this condition, as they are used for cross-project reference checks. + except :read_project_for_iids + except :read_issue_iid + except :read_merge_request_iid + end + + rule { blocked }.policy do + prevent :create_pipeline + end + + rule { can?(:read_issue) }.policy do + enable :read_design + enable :read_design_activity + enable :read_issue_link + enable :read_work_item + end + + rule { can?(:read_merge_request) }.policy do + enable :read_vulnerability_merge_request_link + end + + # Design abilities could also be prevented in the issue policy. + rule { design_management_disabled }.policy do + prevent :read_design + prevent :read_design_activity + prevent :create_design + prevent :update_design + prevent :destroy_design + prevent :move_design + end + + rule { download_code_deploy_key }.policy do + enable :download_code + enable :read_code + end + + rule { push_code_deploy_key }.policy do + enable :admin_tag + enable :push_code + end + + rule { read_container_image_deploy_token }.policy do + enable :read_container_image + end + + rule { create_container_image_deploy_token }.policy do + enable :create_container_image + end + + rule { read_package_registry_deploy_token }.policy do + enable :read_package + enable :read_project + enable :_read_dependency_proxy_package + end + + rule { write_package_registry_deploy_token }.policy do + enable :create_package + enable :read_package + enable :destroy_package + enable :read_project + enable :_read_dependency_proxy_package + end + + rule { ~can?(:create_pipeline) }.prevent :create_web_ide_terminal + + rule { build_service_proxy_enabled }.enable :build_service_proxy_enabled + + rule { can?(:download_code) }.policy do + enable :read_repository_graphs + end + + rule { can?(:read_build) & can?(:read_pipeline) }.policy do + enable :read_build_report_results + end + + rule { support_bot & ~service_desk_enabled }.prevent_all + + rule { ~service_desk_enabled }.prevent :create_ticket + + rule { project_bot }.enable :project_bot_access + + rule { ~resource_access_token_feature_available }.policy do + prevent :read_resource_access_tokens + prevent :destroy_resource_access_tokens + prevent :create_resource_access_tokens + prevent :manage_resource_access_tokens + end + + rule { ~resource_access_token_creation_allowed }.policy do + prevent :create_resource_access_tokens + prevent :manage_resource_access_tokens + end + + rule { can?(:project_bot_access) }.policy do + prevent :create_resource_access_tokens + prevent :manage_resource_access_tokens + end + + rule { user_defined_variables_allowed }.policy do + enable :set_pipeline_variables + end + + rule { security_and_compliance_disabled }.policy do + prevent :access_security_and_compliance + prevent :admin_vulnerability + prevent :read_compliance_framework + prevent :read_vulnerability + prevent :update_vulnerability_flag + prevent :read_security_configuration + prevent :read_security_settings + prevent :update_security_setting + prevent :update_sec_ai_workflow_settings + prevent :read_project_security_dashboard + prevent :read_security_resource + prevent :read_security_inventory + prevent :admin_security_attributes + prevent :read_security_orchestration_policies + prevent :modify_security_policy + prevent :read_compliance_dashboard + prevent :read_agent_artifacts + prevent :read_compliance_adherence_report + prevent :read_compliance_violations_report + prevent :read_project_security_exclusions + prevent :manage_project_security_exclusions + prevent :read_security_scan_profiles + prevent :apply_security_scan_profiles + prevent :read_secret_push_protection_info + prevent :enable_secret_push_protection + prevent :enable_container_scanning_for_registry + prevent :update_cvs_for_container_scanning + prevent :update_cvs_for_dependency_scanning + prevent :read_on_demand_dast_scan + prevent :create_on_demand_dast_scan + prevent :edit_on_demand_dast_scan + prevent :update_on_demand_dast_scan + prevent :_create_dast_pipeline + prevent :_run_dast_pipeline + prevent :admin_vulnerability_external_issue_link + prevent :admin_vulnerability_issue_link + prevent :create_vulnerability_export + prevent :create_vulnerability_archive_export + prevent :read_security_project_tracked_ref + prevent :read_audit_event + prevent :read_any_audit_event + end + + rule { ~admin & ~organization_owner & ~project_runner_registration_allowed }.policy do + prevent :create_runners + end + + rule { ~runner_registration_token_enabled }.policy do + prevent :read_runners_registration_token + prevent :update_runners_registration_token + end + + rule { registry_enabled & can?(:admin_container_image) }.policy do + enable :view_package_registry_project_settings + end + + rule { packages_enabled & can?(:admin_package) }.policy do + enable :view_package_registry_project_settings + end + + rule { ~packages_enabled }.prevent :admin_dependency_proxy_packages_settings + rule { ~dependency_proxy_enabled }.prevent :admin_dependency_proxy_packages_settings + rule { ~dependency_proxy_for_packages_available }.prevent :admin_dependency_proxy_packages_settings + + rule { can?(:read_project) }.policy do + enable :read_incident_management_timeline_event_tag + enable :read_project_metadata + end + + rule { public_project & model_registry_enabled }.policy do + enable :read_model_registry + end + + rule { ~public_project & guest & model_registry_enabled }.policy do + enable :read_model_registry + end + + rule { ~model_registry_enabled }.prevent :write_model_registry + + rule { public_project & model_experiments_enabled }.policy do + enable :read_model_experiments + end + + rule { ~public_project & guest & model_experiments_enabled }.policy do + enable :read_model_experiments + end + + rule { ~model_experiments_enabled }.prevent :write_model_experiments + + rule { ~private_project & guest & external_user }.policy do + enable :read_container_image + enable :build_read_container_image + end + + rule { can?(:create_pipeline_schedule) }.policy do + enable :read_ci_pipeline_schedules_plan_limit + end + + # TODO: Remove this rule and move :read_package permission from + # reporter to guest with the rollout of the FF allow_guest_plus_roles_to_pull_packages + # https://gitlab.com/gitlab-org/gitlab/-/issues/512210 + rule { guest & allow_guest_plus_roles_to_pull_packages_enabled }.enable :read_package + + rule { can?(:read_project) }.enable :read_attestation + + private + + def team_member? + return false if @user.nil? + return false unless user_is_user? + + greedy_load_subject = false + + # when scoping by subject, we want to be greedy + # and load *all* the members with one query. + greedy_load_subject ||= DeclarativePolicy.preferred_scope == :subject + + # in this case we're likely to have loaded #members already + # anyways, and #member? would fail with an error + greedy_load_subject ||= !@user.persisted? + + if greedy_load_subject + # We want to load all the members with one query. Calling #include? on + # project.team.members will perform a separate query for each user, unless + # project.team.members was loaded before somewhere else. Calling #to_a + # ensures it's always loaded before checking for membership. + project.team.members.to_a.include?(user) + else + # otherwise we just make a specific query for + # this particular user. + team_access_level >= Gitlab::Access::GUEST + end + end + + def project_group_member? + return false if @user.nil? + return false unless user_is_user? + + project.group && project.group.member?(@user) + end + + # rubocop: disable CodeReuse/ActiveRecord + def project_group_requester? + return false if @user.nil? + return false unless user_is_user? + + project.group && project.group.requesters.exists?(user_id: @user.id) + end + # rubocop: enable CodeReuse/ActiveRecord + + def team_access_level + return -1 if @user.nil? + return -1 unless user_is_user? + + @team_access_level ||= lookup_access_level! + end + alias_method :access_level, :team_access_level + + def lookup_access_level! + return ::Gitlab::Access::REPORTER if alert_bot? || support_bot? + + # NOTE: max_member_access_for_user is cached + project.max_member_access_for_user(@user) + end + + def access_allowed_to?(feature) + return false unless project.project_feature + + case project.project_feature.access_level(feature) + when ProjectFeature::DISABLED + false + when ProjectFeature::PRIVATE + return false unless project_allowed_for_job_token_by_scope? + + can?(:read_all_resources) || + can?(:read_all_organization_resources) || + team_access_level >= ProjectFeature.required_minimum_access_level(feature) + else + true + end + end + + def resource_access_token_feature_available? + true + end + + def resource_access_token_create_feature_available? + true + end + + def resource_access_token_creation_allowed? + group = project.group + + return true unless group # always enable for projects in personal namespaces + + resource_access_token_create_feature_available? && group.root_ancestor.namespace_settings.resource_access_token_creation_allowed? + end + + def cross_project_push_allowed_for_job_token? + project.ci_cross_project_push_for_job_token_allowed? && + project.ci_inbound_job_token_scope_enabled? && + @user.ci_job_token_scope.policies_allowed?(project, [:admin_repositories]) + end + + def project + @subject + end +end + +ProjectPolicy.prepend_mod_with('ProjectPolicy') diff --git a/spec/fixtures/corpus/gitlab/app/services/ci/create_pipeline_service.rb b/spec/fixtures/corpus/gitlab/app/services/ci/create_pipeline_service.rb new file mode 100644 index 0000000..533bca1 --- /dev/null +++ b/spec/fixtures/corpus/gitlab/app/services/ci/create_pipeline_service.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +module Ci + class CreatePipelineService < BaseService + attr_reader :pipeline, :logger + + LOG_MAX_DURATION_THRESHOLD = 3.seconds + LOG_MAX_PIPELINE_SIZE = 2_000 + LOG_MAX_CREATION_THRESHOLD = 20.seconds + SEQUENCE = [Gitlab::Ci::Pipeline::Chain::Build, + Gitlab::Ci::Pipeline::Chain::Validate::Abilities, + Gitlab::Ci::Pipeline::Chain::Validate::Repository, + Gitlab::Ci::Pipeline::Chain::Build::Associations, + Gitlab::Ci::Pipeline::Chain::Limit::RateLimit, + Gitlab::Ci::Pipeline::Chain::Validate::SecurityOrchestrationPolicy, + Gitlab::Ci::Pipeline::Chain::AssignPartition, + Gitlab::Ci::Pipeline::Chain::PipelineExecutionPolicies::EvaluatePolicies, + Gitlab::Ci::Pipeline::Chain::NoPipeline, + Gitlab::Ci::Pipeline::Chain::Skip, + Gitlab::Ci::Pipeline::Chain::Validate::Config, + Gitlab::Ci::Pipeline::Chain::Config::Content, + Gitlab::Ci::Pipeline::Chain::Config::Process, + Gitlab::Ci::Pipeline::Chain::StopLinting, + Gitlab::Ci::Pipeline::Chain::Validate::AfterConfig, + Gitlab::Ci::Pipeline::Chain::RemoveUnwantedChatJobs, + Gitlab::Ci::Pipeline::Chain::SeedBlock, + Gitlab::Ci::Pipeline::Chain::EvaluateWorkflowRules, + Gitlab::Ci::Pipeline::Chain::Seed, + Gitlab::Ci::Pipeline::Chain::Limit::Size, + Gitlab::Ci::Pipeline::Chain::Limit::ActiveJobs, + Gitlab::Ci::Pipeline::Chain::Limit::Deployments, + Gitlab::Ci::Pipeline::Chain::Validate::External, + Gitlab::Ci::Pipeline::Chain::SetBuildSources, + Gitlab::Ci::Pipeline::Chain::Populate, + Gitlab::Ci::Pipeline::Chain::PopulateMetadata, + Gitlab::Ci::Pipeline::Chain::PipelineExecutionPolicies::ApplyPolicies, + Gitlab::Ci::Pipeline::Chain::StopDryRun, + Gitlab::Ci::Pipeline::Chain::EnsureEnvironments, + Gitlab::Ci::Pipeline::Chain::EnsureResourceGroups, + Gitlab::Ci::Pipeline::Chain::Create, + Gitlab::Ci::Pipeline::Chain::CreateCrossDatabaseAssociations, + Gitlab::Ci::Pipeline::Chain::CancelPendingPipelines, + Gitlab::Ci::Pipeline::Chain::Metrics, + Gitlab::Ci::Pipeline::Chain::TriggerBuildHooks, + Gitlab::Ci::Pipeline::Chain::ComponentUsage, + Gitlab::Ci::Pipeline::Chain::Pipeline::Process].freeze + + # Create a new pipeline in the specified project. + # + # @param [Symbol] source What event (Ci::Pipeline.sources) triggers the pipeline + # creation. + # @param [Boolean] ignore_skip_ci Whether skipping a pipeline creation when `[skip ci]` comment + # is present in the commit body + # @param [Boolean] save_on_errors Whether persisting an invalid pipeline when it encounters an + # error during creation (e.g. invalid yaml) + # @param [Ci::PipelineSchedule] schedule The pipeline schedule triggers the pipeline creation. + # @param [MergeRequest] merge_request The merge request triggers the pipeline creation. + # @param [Ci::ExternalPullRequest] external_pull_request The external pull request triggers the pipeline creation. + # @param [Ci::Bridge] bridge The bridge job that triggers the downstream pipeline creation. + # @param [Ci::Trigger] trigger The trigger that triggers the pipeline creation. + # @param [String] content The content of .gitlab-ci.yml to override the default config + # contents (e.g. .gitlab-ci.yml in repostiry). Mainly used for + # generating a dangling pipeline. + # + # @return [Ci::Pipeline] The created Ci::Pipeline object. + # rubocop: disable Metrics/ParameterLists, Metrics/AbcSize + def execute( + source, + ignore_skip_ci: false, save_on_errors: true, schedule: nil, merge_request: nil, + external_pull_request: nil, bridge: nil, trigger: nil, inputs: {}, + **options, &block + ) + @logger = build_logger + @command_logger = Gitlab::Ci::Pipeline::CommandLogger.new + @pipeline = Ci::Pipeline.new + + return ServiceResponse.error(message: "Missing project parameter", payload: @pipeline) if project.nil? + return ServiceResponse.error(message: "Missing user parameter", payload: @pipeline) if current_user.nil? + + validate_options!(options) + + @command = Gitlab::Ci::Pipeline::Chain::Command.new( + source: source, + origin_ref: params[:ref], + checkout_sha: params[:checkout_sha], + after_sha: params[:after], + before_sha: params[:before], # The base SHA of the source branch (i.e merge_request.diff_base_sha). + source_sha: params[:source_sha], # The HEAD SHA of the source branch (i.e merge_request.diff_head_sha). + target_sha: params[:target_sha], # The HEAD SHA of the target branch. + trigger: trigger, + schedule: schedule, + merge_request: merge_request, + external_pull_request: external_pull_request, + ignore_skip_ci: ignore_skip_ci, + save_incompleted: save_on_errors, + seeds_block: block, + variables_attributes: params[:variables_attributes], + project: project, + current_user: current_user, + push_options: ::Ci::PipelineCreation::PushOptions.fabricate(params[:push_options]), + chat_data: params[:chat_data], + bridge: bridge, + logger: @logger, + partition_id: params[:partition_id], + inputs: inputs, + gitaly_context: params[:gitaly_context], + **extra_options(**options)) + + @pipeline.readonly! if @command.readonly? + + Gitlab::Ci::Pipeline::Chain::Sequence + .new(pipeline, @command, SEQUENCE) + .build! + + if pipeline.persisted? + Gitlab::EventStore.publish( + Ci::PipelineCreatedEvent.new(data: { pipeline_id: pipeline.id, partition_id: pipeline.partition_id }) + ) + + after_successful_creation_hook + else + recover_pipeline_iid + end + + if error_message = pipeline.full_error_messages.presence || pipeline.failure_reason.presence + unless params[:defer_request_completion] + ::Ci::PipelineCreation::Requests.failed(params[:pipeline_creation_request], error_message) + GraphqlTriggers.ci_pipeline_creation_requests_updated(merge_request) if merge_request + end + + ServiceResponse.error(message: error_message, payload: pipeline) + else + unless params[:defer_request_completion] + ::Ci::PipelineCreation::Requests.succeeded(params[:pipeline_creation_request], pipeline.id) + GraphqlTriggers.ci_pipeline_creation_requests_updated(merge_request) if merge_request + end + + ServiceResponse.success(payload: pipeline) + end + + ensure + @logger.commit(pipeline: pipeline, caller: self.class.name) + @command_logger.commit(pipeline: pipeline, command: @command) if @command + end + # rubocop: enable Metrics/ParameterLists, Metrics/AbcSize + + def execute_async(source, options) + pipeline_creation_request = ::Ci::PipelineCreation::Requests.start_for_project(project) + creation_params = params.merge(pipeline_creation_request: pipeline_creation_request) + + ::CreatePipelineWorker.perform_async( + project.id, current_user.id, params[:ref], source.to_s, + options.stringify_keys, creation_params.except(:ref).stringify_keys + ) + + ServiceResponse.success(payload: pipeline_creation_request['id']) + end + + def yaml_processor_result + @command.yaml_processor_result + end + + private + + def after_successful_creation_hook + # overridden in EE + end + + def recover_pipeline_iid + # Try to recover IID unless the record is frozen because it was destroyed. + pipeline.reset_project_iid unless pipeline.frozen? + end + + # rubocop:disable Gitlab/NoCodeCoverageComment + # :nocov: Tested in FOSS and fully overridden and tested in EE + def validate_options!(_) + raise ArgumentError, "Param `partition_id` is not allowed" if params[:partition_id] + end + # :nocov: + # rubocop:enable Gitlab/NoCodeCoverageComment + + def extra_options(content: nil, dry_run: false, linting: false, duo_workflow_definition: nil, trigger_api_request: false, suspend_options: nil) + { content: content, dry_run: dry_run, linting: linting, duo_workflow_definition: duo_workflow_definition, trigger_api_request: trigger_api_request, suspend_options: suspend_options } + end + + def build_logger + Gitlab::Ci::Pipeline::Logger.new(project: project) do |l| + l.log_when do |observations| + observations.any? do |name, observation| + name.to_s.end_with?('duration_s') && + Array(observation).max >= LOG_MAX_DURATION_THRESHOLD + end + end + + l.log_when do |observations| + count = observations['pipeline_size_count'] + next false unless count + + count >= LOG_MAX_PIPELINE_SIZE + end + + l.log_when do |observations| + duration = observations['pipeline_creation_duration_s'] + next false unless duration + + duration >= LOG_MAX_CREATION_THRESHOLD + end + end + end + end +end + +Ci::CreatePipelineService.prepend_mod_with('Ci::CreatePipelineService') diff --git a/spec/fixtures/corpus/gitlab/app/services/merge_requests/create_service.rb b/spec/fixtures/corpus/gitlab/app/services/merge_requests/create_service.rb new file mode 100644 index 0000000..d0ed7e9 --- /dev/null +++ b/spec/fixtures/corpus/gitlab/app/services/merge_requests/create_service.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module MergeRequests + class CreateService < MergeRequests::BaseService + def execute + set_projects! + set_default_attributes! + + merge_request = MergeRequest.new + merge_request.target_project = @project + merge_request.source_project = @source_project + merge_request.source_branch = params[:source_branch] + + merge_after = params.delete(:merge_after) + + created_merge_request = create(merge_request) + + UpdateMergeScheduleService.new(created_merge_request, merge_after: merge_after).execute + + created_merge_request + end + + def after_create(issuable) + invalidate_all_users_cache_count(issuable) + + current_user_id = current_user.id + + issuable.run_after_commit do + # Add new items to MergeRequests::AfterCreateService if they can + # be performed in Sidekiq + NewMergeRequestWorker.perform_async(issuable.id, current_user_id) + end + + issuable.mark_as_preparing + + super + end + + # expose issuable create method so it can be called from email + # handler CreateMergeRequestHandler + public :create + + private + + def before_create(merge_request) + # If the fetching of the source branch occurs in an ActiveRecord + # callback (e.g. after_create), a database transaction will be + # open while the Gitaly RPC waits. To avoid an idle in transaction + # timeout, we do this before we attempt to save the merge request. + + merge_request.source_branch_exists? + merge_request.target_branch_exists? + + merge_request.skip_ensure_merge_request_diff = true + merge_request.check_for_spam(user: current_user, action: :create) + end + + def set_projects! + # @project is used to determine whether the user can set the merge request's + # assignee, milestone and labels. Whether they can depends on their + # permissions on the target project. + @source_project = @project + @project = Project.find(params[:target_project_id]) if params[:target_project_id] + + # make sure that source/target project ids are not in + # params so it can't be overridden later when updating attributes + # from params when applying quick actions + params.delete(:source_project_id) + params.delete(:target_project_id) + + unless can?(current_user, :create_merge_request_from, @source_project) && + can?(current_user, :create_merge_request_in, @project) + + raise Gitlab::Access::AccessDeniedError + end + end + + def set_default_attributes! + set_default_squash! unless params.key?(:squash) || params.key?('squash') + set_default_force_remove_source_branch! if params[:force_remove_source_branch].nil? + end + + def set_default_force_remove_source_branch! + params[:force_remove_source_branch] = @source_project.remove_source_branch_after_merge? + end + + def set_default_squash! + params[:squash] = @project.squash_enabled_by_default? + end + end +end + +MergeRequests::CreateService.prepend_mod_with('MergeRequests::CreateService') diff --git a/spec/fixtures/corpus/gitlab/config/routes.rb b/spec/fixtures/corpus/gitlab/config/routes.rb new file mode 100644 index 0000000..3cc78eb --- /dev/null +++ b/spec/fixtures/corpus/gitlab/config/routes.rb @@ -0,0 +1,430 @@ +# frozen_string_literal: true + +require 'sidekiq/web' +require 'sidekiq/cron/web' + +InitializerConnections.warn_if_database_connection do + Rails.application.routes.draw do + # rubocop:disable Metrics/AbcSize -- listing routes is typical + def draw_all_routes + # Override devise_for to skip omniauth_callbacks when in organization scope + omniauth_options = if @organization_scoped_routes + { skip: :omniauth_callbacks } + else + {} + end + + define_singleton_method(:devise_for) do |*resources, **options| + options = options.merge(omniauth_options) + super(*resources, **options) + end + + concern :access_requestable do + get :request_access, on: :collection + post :request_access, on: :collection + post :approve_access_request, on: :member + end + + concern :awardable do + post :toggle_award_emoji, on: :member + end + + favicon_redirect = redirect do |_params, _request| + ActionController::Base.helpers.asset_url(Gitlab::Favicon.main) + end + get 'favicon.png', to: favicon_redirect + get 'favicon.ico', to: favicon_redirect + + draw :development + + use_doorkeeper do + controllers applications: 'oauth/applications', + authorized_applications: 'oauth/authorized_applications', + authorizations: 'oauth/authorizations', + token_info: 'oauth/token_info', + tokens: 'oauth/tokens' + end + put '/oauth/applications/:id/renew(.:format)' => 'oauth/applications#renew', as: :renew_oauth_application + get '/.well-known/oauth-protected-resource' => 'oauth/protected_resource_metadata#show', as: :oauth_protected_resource_metadata + post '/oauth/register(.:format)' => 'oauth/dynamic_registrations#create', as: :oauth_register + + Gitlab.ee do + draw :oauth + end + + use_doorkeeper_openid_connect do + controllers discovery: 'jwks' + end + + # MCP OAuth Discovery Support - Add path insertion endpoints + # MCP-Remote contrustcts well-known path for auth-related metadata discovery + # https://modelcontextprotocol.io/specification/draft/basic/authorization#server-metadata-discovery + # https://github.com/mcp-auth/mcp-typescript-sdk/blob/31acdcbb189056ec83d14a4f7a37ae2b1c67680e/src/client/auth.ts#L697-L702 + get '/.well-known/oauth-authorization-server/api/v4/mcp', to: 'jwks#provider' + get '/.well-known/openid-configuration/api/v4/mcp', to: 'jwks#provider' + get '/.well-known/oauth-protected-resource/api/v4/mcp', to: 'oauth/protected_resource_metadata#show' + get '/.well-known/oauth-authorization-server/api/v4/orbit/mcp', to: 'jwks#provider' + get '/.well-known/openid-configuration/api/v4/orbit/mcp', to: 'jwks#provider' + get '/.well-known/oauth-protected-resource/api/v4/orbit/mcp', to: 'oauth/protected_resource_metadata#show' + + use_doorkeeper_device_authorization_grant do + controller device_authorizations: 'oauth/device_authorizations' + end + + # Add OPTIONS method for CORS preflight requests + match '/oauth/userinfo' => 'doorkeeper/openid_connect/userinfo#show', via: :options + match '/oauth/discovery/keys' => 'jwks#keys', via: :options + match '/.well-known/openid-configuration' => 'jwks#provider', via: :options + match '/.well-known/oauth-protected-resource' => 'oauth_protected_resource_metadata#show', via: :options + match '/.well-known/webfinger' => 'jwks#webfinger', via: :options + match '/.well-known/oauth-authorization-server/api/v4/mcp', to: 'jwks#provider', via: :options + match '/.well-known/openid-configuration/api/v4/mcp', to: 'jwks#provider', via: :options + match '/.well-known/oauth-protected-resource/api/v4/mcp', to: 'oauth/protected_resource_metadata#show', via: :options + match '/.well-known/oauth-authorization-server/api/v4/orbit/mcp', to: 'jwks#provider', via: :options + match '/.well-known/openid-configuration/api/v4/orbit/mcp', to: 'jwks#provider', via: :options + match '/.well-known/oauth-protected-resource/api/v4/orbit/mcp', to: 'oauth/protected_resource_metadata#show', via: :options + + match '/oauth/token' => 'oauth/tokens#create', via: :options + match '/oauth/revoke' => 'oauth/tokens#revoke', via: :options + + match '/-/jira_connect/oauth_application_id' => 'jira_connect/oauth_application_ids#show', via: :options, as: :jira_connect_oauth_application_id_options + match '/-/jira_connect/subscriptions(.:format)' => 'jira_connect/subscriptions#index', via: :options + match '/-/jira_connect/subscriptions/:id' => 'jira_connect/subscriptions#delete', via: :options + + # Search + get 'search' => 'search#show', as: :search + get 'search/autocomplete' => 'search#autocomplete', as: :search_autocomplete + get 'search/settings' => 'search#settings' + get 'search/count' => 'search#count', as: :search_count + get 'search/opensearch' => 'search#opensearch', as: :search_opensearch + + Gitlab.ee do + get 'search/aggregations' => 'search#aggregations', as: :search_aggregations + end + + # JSON Web Token + get 'jwt/auth' => 'jwt#auth' + post 'jwt/auth', to: proc { [404, {}, ['']] } + + # Health check + get 'health_check(/:checks)' => 'health_check#index', as: :health_check + + # Terraform service discovery + get '.well-known/terraform.json' => 'terraform/services#index', as: :terraform_services + + draw_all :organizations + + # Begin of the /-/ scope. + # Use this scope for all new global routes. + scope path: '-' do + # sandbox + get '/sandbox/mermaid_v11' => 'sandbox#mermaid_v11' + get '/sandbox/swagger' => 'sandbox#swagger' + + get '/:model/:model_id/uploads/:secret/:filename', + to: 'banzai/uploads#show', + constraints: { + model: /project|group/, + filename: %r{[^/]+} + }, + as: 'banzai_upload' + get '/diagram-proxy/:key' => 'banzai/diagram_proxy#proxy', as: :diagram_proxy + + get '/whats_new' => 'whats_new#index' + post '/whats_new/mark_as_read' => 'whats_new#mark_as_read' + + get 'offline' => "pwa#offline" + get 'manifest' => "pwa#manifest", constraints: ->(req) { req.format == :json } + + scope module: 'clusters' do + scope module: 'agents' do + get '/kubernetes', to: 'dashboard#index', as: 'kubernetes_dashboard_index' + get '/kubernetes/:agent_id(/*vueroute)', to: 'dashboard#show', as: 'kubernetes_dashboard' + end + end + + # HTTP Router + # Creating a black hole for /-/http_router/version since it is taken by the + # cloudflare worker, see: https://gitlab.com/gitlab-com/gl-infra/tenant-scale/cells-infrastructure/team/-/work_items/150 + match '/http_router/version', to: proc { [204, {}, ['']] }, via: :all + # topology-service caching route, see: https://gitlab.com/gitlab-com/gl-infra/tenant-scale/cells-infrastructure/team/-/work_items/648 + match '/topology-service/__cache/*path', to: proc { [204, {}, ['']] }, via: :all + + # '/-/health' implemented by BasicHealthCheck middleware + get 'liveness' => 'health#liveness' + get 'readiness' => 'health#readiness' + controller :metrics do + get 'metrics', action: :index + get 'metrics/system', action: :system + end + mount Peek::Railtie => '/peek', as: 'peek_routes' + + get 'runner_setup/platforms' => 'runner_setup#platforms' + + get 'acme-challenge/' => 'acme_challenges#show' + + scope :ide, as: :ide, format: false do + get '/', to: 'ide#index' + get '/project', to: 'ide#index' + # note: This path has a hardcoded reference in the FE `app/assets/javascripts/ide/constants.js` + get '/oauth_redirect', to: 'ide#oauth_redirect' + + scope path: 'project/:project_id', as: :project, constraints: { project_id: Gitlab::PathRegex.full_namespace_route_regex } do + # Format: /-/ide/project/:project_id/-/*path + get '/-/*path', to: 'ide#index' + + %w[edit tree blob].each do |action| + get "/#{action}", to: 'ide#index' + get "/#{action}/*branch/-/*path", to: 'ide#index' + get "/#{action}/*branch/-", to: 'ide#index' + get "/#{action}/*branch", to: 'ide#index' + end + + get '/merge_requests/:merge_request_id', to: 'ide#index', constraints: { merge_request_id: /\d+/ } + get '/', to: 'ide#index' + end + + post '/reset_oauth_application_settings' => 'admin/applications#reset_web_ide_oauth_application_settings' + end + + Gitlab.ee do + draw :operations + end + + draw :iam + draw :jira_connect + + Gitlab.ee do + draw 'remote_development/resources' + draw :security + draw :smartcard + draw :trial_registration + draw :country + draw :country_state + draw :gitlab_subscriptions + draw :phone_verification + draw :arkose + + scope '/from_secondary/:geo_node_id' do + draw :git_http + end + end + + Gitlab.jh do + draw :global_jh + end + + if ENV['GITLAB_CHAOS_SECRET'] || Rails.env.development? || Rails.env.test? + resource :chaos, only: [] do + get :leakmem + get :cpu_spin + get :db_spin + get :sleep + get :kill + get :quit + post :gc + end + end + + resources :invites, only: [:show], constraints: { id: /[A-Za-z0-9_-]+/ } do + member do + post :accept + match :decline, via: [:get, :post] + end + end + + resources :sent_notifications, only: [], constraints: { id: /[0-9a-z\-]{1,44}/ } do + member do + match :unsubscribe, via: [:get, :post] + end + end + + resources :awarded_achievements, only: [], constraints: { id: %r{[^/]+} }, module: :achievements do + member do + get :accept + post :accept + end + end + + # Spam reports + resources :abuse_reports, only: [:create] do + collection do + post :add_category + end + end + + # JWKS (JSON Web Key Set) endpoint + # Used by third parties to verify CI_JOB_JWT + get 'jwks' => 'jwks#index' + + # Dedicated JWKS for the Cloud Connector keys (EE-only). + Gitlab.ee do + draw :cloud_connector + end + + draw :snippets + draw_all :profile + draw_all :user_settings + draw_all :autocomplete + draw_all :feature_library + + post '/mailgun/webhooks' => 'mailgun/webhooks#process_webhook' + + # Deprecated route for permanent failures + # https://gitlab.com/gitlab-org/gitlab/-/issues/362606 + post '/members/mailgun/permanent_failures' => 'mailgun/webhooks#process_webhook' + + get '/timelogs' => 'time_tracking/timelogs#index' + + post '/track_namespace_visits' => 'users/namespace_visits#create' + + get '/external_redirect' => 'external_redirect/external_redirect#index' + + post '/collect_events', to: 'event_forward/event_forward#forward', as: :event_forwarding + + if Gitlab::Utils.to_boolean(ENV['COVERBAND_ENABLED'], default: false) + mount Coverband::Reporters::Web.new, at: '/coverage' + end + + namespace :experimental do + resources :o11y_service_settings, only: [:index, :new, :create, :edit, :update, :destroy] + end + end + # End of the /-/ scope. + + concern :clusterable do + resources :clusters, only: [:index, :show, :update, :destroy] do + collection do + get :connect + get :new_cluster_docs + post :create_user + end + + resource :integration, controller: 'clusters/integrations', only: [] do + collection do + post :create_or_update + end + end + + member do + Gitlab.ee do + get :environments, format: :json + end + + get :cluster_status, format: :json + delete :clear_cache + post :migrate + put :update_migration + end + end + end + + resources :groups, only: [:index, :new, :create] + + get '/-/g/:id' => 'groups/redirect#redirect_from_id' + + draw_all :group + + resources :projects, only: [:index, :new, :create] + + get '/projects/:id' => 'projects/redirect#redirect_from_id' + get '/-/p/:id' => 'projects/redirect#redirect_from_id' + + draw :git_http + draw_all :api + draw :activity_pub + draw :customers_dot + draw :device_auth + draw :sidekiq + draw :help + draw :google_api + draw :import + draw_all :uploads + draw_all :explore + draw_all :admin + draw_all :dashboard + + Gitlab.ee do + draw :identity_verification + end + + draw_all :user + draw_all :project + draw :unmatched_project + draw :well_known + + # Issue https://gitlab.com/gitlab-org/gitlab/-/issues/210024 + scope as: 'deprecated' do + # Issue https://gitlab.com/gitlab-org/gitlab/-/issues/223719 + get '/snippets/:id/raw', + to: 'snippets#raw', + format: false, + constraints: { id: /\d+/ } + + Gitlab::Routing.redirect_legacy_paths(self, :snippets) + end + + Gitlab.ee do + get '/sitemap' => 'sitemap#show', format: :xml + draw :virtual_registries_container + end + + root to: "root#index" + + get '/-/u/:id' => 'users/redirect#redirect_from_id' + end + # rubocop:enable Metrics/AbcSize + + draw_all_routes + + # Creates shorthand helper methods for project resources. + # For example; for the `namespace_project_path` this also creates `project_path`. + # + # TODO: We don't need the `Gitlab::Routing` module at all as we can use + # the `direct` DSL method of Rails to define url helpers. Move all the + # custom url helpers to use the `direct` DSL method and remove the `Gitlab::Routing`. + # For more information: https://gitlab.com/groups/gitlab-org/-/epics/9866 + Gitlab::Application.routes.set.filter_map { |route| route.name if route.name&.include?('namespace_project') }.each do |name| + new_name = name.sub('namespace_project', 'project') + + direct(new_name) do |project, *args| + # This is due to a bug I've found in Rails. + # For more information: https://gitlab.com/gitlab-org/gitlab/-/issues/299591 + args.pop if args.last == {} + + send("#{name}_url", project&.namespace, project, *args) + end + end + + # Organization-scoped routes. + scope( + path: '/o/:organization_path', + constraints: { + organization_path: Gitlab::PathRegex.organization_route_regex + }, + as: :organization + ) do + # Clone all existing routes into this scope, skipping Devise OmniAuth callbacks + # Devise does not support scoping OmniAuth callbacks under a dynamic segment + @organization_scoped_routes = true + draw_all_routes + @organization_scoped_routes = false + end + + # Used only for sent_notifications + scope(path: '-/namespace/:namespace_id', as: :namespace, constraints: { namespace_id: /\d+/ }) do + resources :sent_notifications, only: [], constraints: { id: /[0-9a-z\-]{1,44}/ }, + controller: 'sent_notifications' do + member do + match :unsubscribe, via: [:get, :post] + end + end + end + + get '*unmatched_route', to: 'application#route_not_found', format: false + + # Load all custom URLs definitions via `direct' after the last route + # definition. + draw_all :directs + end +end diff --git a/spec/fixtures/corpus/gitlab/lib/gitlab/background_migration/backfill_award_emoji_sharding_key.rb b/spec/fixtures/corpus/gitlab/lib/gitlab/background_migration/backfill_award_emoji_sharding_key.rb new file mode 100644 index 0000000..ffe15df --- /dev/null +++ b/spec/fixtures/corpus/gitlab/lib/gitlab/background_migration/backfill_award_emoji_sharding_key.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + class BackfillAwardEmojiShardingKey < BatchedMigrationJob # rubocop:disable Metrics/ClassLength -- BBMs might need bigger classes + operation_name :set_award_emoji_sharding_key + feature_category :team_planning + + def perform + each_sub_batch do |sub_batch| + handle_issue_related_records(sub_batch) + handle_merge_request_related_records(sub_batch) + handle_epic_related_records(sub_batch) + handle_snippet_related_records(sub_batch) + handle_note_related_records(sub_batch) + delete_and_archive_award_emoji_with_no_sharding_key(sub_batch) + end + end + + private + + def common_query(sub_batch, awardable_type) + <<~SQL + WITH relation AS MATERIALIZED ( + #{sub_batch.limit(sub_batch_size).to_sql} + ), filtered_relation AS MATERIALIZED ( + SELECT * from relation WHERE "awardable_type" = '#{awardable_type}' LIMIT #{sub_batch_size} + ) + #{yield} + SQL + end + + def handle_issue_related_records(sub_batch) + connection.execute( + common_query(sub_batch, 'Issue') do + <<~SQL + UPDATE "award_emoji" + SET "namespace_id" = "issues"."namespace_id" + FROM filtered_relation INNER JOIN "issues" ON "issues"."id" = filtered_relation."awardable_id" + WHERE "award_emoji"."id" = filtered_relation."id" + SQL + end + ) + end + + def handle_merge_request_related_records(sub_batch) + connection.execute( + common_query(sub_batch, 'MergeRequest') do + <<~SQL + UPDATE "award_emoji" + SET "namespace_id" = "projects"."project_namespace_id" + FROM filtered_relation INNER JOIN "merge_requests" ON "merge_requests"."id" = filtered_relation."awardable_id" + INNER JOIN "projects" ON "projects"."id" = "merge_requests"."target_project_id" + WHERE "award_emoji"."id" = filtered_relation."id" + SQL + end + ) + end + + def handle_epic_related_records(sub_batch) + connection.execute( + common_query(sub_batch, 'Epic') do + <<~SQL + UPDATE "award_emoji" + SET "namespace_id" = "epics"."group_id" + FROM filtered_relation INNER JOIN "epics" ON "epics"."id" = filtered_relation."awardable_id" + WHERE "award_emoji"."id" = filtered_relation."id" + SQL + end + ) + end + + def handle_snippet_related_records(sub_batch) + connection.execute( + common_query(sub_batch, 'Snippet') do + <<~SQL + UPDATE "award_emoji" + SET "namespace_id" = "projects"."project_namespace_id", "organization_id" = "snippets"."organization_id" + FROM filtered_relation INNER JOIN "snippets" ON "snippets"."id" = filtered_relation."awardable_id" + LEFT JOIN "projects" ON "projects"."id" = "snippets"."project_id" + WHERE "award_emoji"."id" = filtered_relation."id" + SQL + end + ) + end + + def handle_note_related_records(sub_batch) + connection.execute( + common_query(sub_batch, 'Note') do + <<~SQL + , relation_with_sk AS MATERIALIZED ( + SELECT "filtered_relation"."id", + COALESCE("projects"."project_namespace_id", "notes"."namespace_id") AS namespace_id, + CASE + WHEN num_nonnulls("notes"."project_id", "notes"."namespace_id") >= 1 THEN NULL + ELSE "notes"."organization_id" + END AS organization_id + FROM "filtered_relation" INNER JOIN "notes" ON "notes"."id" = "filtered_relation"."awardable_id" + LEFT JOIN "projects" ON "projects"."id" = "notes"."project_id" + LIMIT #{sub_batch_size} + ) + UPDATE "award_emoji" + SET "namespace_id" = "relation_with_sk"."namespace_id", + "organization_id" = "relation_with_sk"."organization_id" + FROM "relation_with_sk" + WHERE "award_emoji"."id" = "relation_with_sk"."id" + SQL + end + ) + end + + def delete_and_archive_award_emoji_with_no_sharding_key(sub_batch) + connection.execute(<<~SQL) + WITH relation AS MATERIALIZED ( + #{sub_batch.limit(sub_batch_size).to_sql} + ), filtered_relation AS MATERIALIZED ( + SELECT * from relation WHERE "namespace_id" IS NULL AND "organization_id" IS NULL LIMIT #{sub_batch_size} + ), deleted_emoji AS MATERIALIZED ( + DELETE FROM "award_emoji" WHERE "id" IN (SELECT "id" FROM filtered_relation) + RETURNING #{award_emoji_columns_for_archive} + ) + INSERT INTO award_emoji_archived (#{award_emoji_columns_for_archive}) + SELECT #{award_emoji_columns_for_archive} + FROM deleted_emoji + SQL + end + + def award_emoji_columns_for_archive + @award_emoji_columns_for_archive ||= %w[ + id name user_id awardable_id awardable_type created_at updated_at namespace_id organization_id + ].join(', ') + end + end + end +end diff --git a/spec/fixtures/corpus/gitlab/lib/gitlab/ci/config/entry/job.rb b/spec/fixtures/corpus/gitlab/lib/gitlab/ci/config/entry/job.rb new file mode 100644 index 0000000..b7ff23e --- /dev/null +++ b/spec/fixtures/corpus/gitlab/lib/gitlab/ci/config/entry/job.rb @@ -0,0 +1,276 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + ## + # Entry that represents a concrete CI/CD job. + # + class Job < ::Gitlab::Config::Entry::Node + include ::Gitlab::Ci::Config::Entry::Processable + + ALLOWED_WHEN = %w[on_success on_failure always manual delayed].freeze + ALLOWED_KEYS = %i[tags script image services start_in artifacts + cache dependencies before_script after_script hooks + coverage retry parallel timeout inputs + release id_tokens publish pages manual_confirmation run].freeze + + PUBLIC_DIR = 'public' + + validations do + validates :config, allowed_keys: Gitlab::Ci::Config::Entry::Job.allowed_keys + PROCESSABLE_ALLOWED_KEYS + validates :config, mutually_exclusive_keys: %i[script run] + validates :config, mutually_exclusive_keys: %i[before_script run] + validates :config, mutually_exclusive_keys: %i[after_script run] + validates :script, presence: true, if: -> { config.is_a?(Hash) && !config.key?(:run) } + + with_options allow_nil: true do + validates :when, type: String, inclusion: { + in: ALLOWED_WHEN, + message: "should be one of: #{ALLOWED_WHEN.join(', ')}" + } + + validates :dependencies, array_of_strings: true + validates :allow_failure, hash_or_boolean: true + validates :manual_confirmation, type: String + validates :run, json_schema: { + base_directory: 'app/validators/json_schemas', + detail_errors: true, + filename: 'ci_run_steps', + hash_conversion: true + } + end + + validates :start_in, duration: { limit: '1 week' }, if: :delayed? + validates :start_in, absence: true, if: -> { has_rules? || !delayed? } + + validates :publish, + absence: { message: "can only be used within a `pages` job" }, + unless: -> { config.is_a?(Hash) && pages_job? } + + validate on: :composed do + next unless dependencies.present? + next unless needs_value.present? + + if needs_value[:job].nil? && needs_value[:cross_dependency].present? + errors.add(:needs, "corresponding to dependencies must be from the same pipeline") + else + missing_needs = dependencies - needs_value[:job].pluck(:name) # rubocop:disable CodeReuse/ActiveRecord -- Array#pluck + + errors.add(:dependencies, "the #{missing_needs.join(', ')} should be part of needs") if missing_needs.any? + end + end + + validate on: :composed do + next unless inputs_defined? + + inputs_spec = inputs_value || {} + + if inputs_spec.size > 50 + errors.add(:inputs, "has too many entries (maximum 50)") + next + end + + builder = ::Ci::Inputs::Builder.new(inputs_spec) + + builder.validate_input_params!({}) + + builder.errors.each do |error| + clean_error = error.sub(' input:', ':') + errors.add(:inputs, clean_error) + end + end + end + + entry :before_script, Entry::Commands, + description: 'Global before script overridden in this job.', + inherit: true + + entry :script, Entry::Commands, + description: 'Commands that will be executed in this job.', + inherit: false + + entry :after_script, Entry::Commands, + description: 'Commands that will be executed when finishing job.', + inherit: true + + entry :hooks, Entry::Hooks, + description: 'Commands that will be executed on Runner before/after some events; clone, build-script.', + inherit: true + + entry :cache, Entry::Caches, + description: 'Cache definition for this job.', + inherit: true + + entry :image, Entry::Image, + description: 'Image that will be used to execute this job.', + inherit: true + + entry :services, Entry::Services, + description: 'Services that will be used to execute this job.', + inherit: true + + entry :timeout, Entry::Timeout, + description: 'Timeout duration of this job.', + inherit: true + + entry :retry, Entry::Retry, + description: 'Retry configuration for this job.', + inherit: true + + entry :tags, Entry::Tags, + description: 'Set the tags.', + inherit: true + + entry :artifacts, Entry::Artifacts, + description: 'Artifacts configuration for this job.', + inherit: true + + entry :needs, Entry::Needs, + description: 'Needs configuration for this job.', + metadata: { allowed_needs: %i[job cross_dependency] }, + inherit: false + + entry :coverage, Entry::Coverage, + description: 'Coverage configuration for this job.', + inherit: false + + entry :release, Entry::Release, + description: 'This job will produce a release.', + inherit: false + + entry :parallel, Entry::Product::Parallel, + description: 'Parallel configuration for this job.', + inherit: false + + entry :allow_failure, ::Gitlab::Ci::Config::Entry::AllowFailure, + description: 'Indicates whether this job is allowed to fail or not.', + inherit: false + + entry :id_tokens, ::Gitlab::Config::Entry::ComposableHash, + description: 'Configured JWTs for this job.', + inherit: true, + metadata: { composable_class: ::Gitlab::Ci::Config::Entry::IdToken } + + entry :publish, Entry::Publish, + description: 'Path to be published with Pages', + inherit: false + + entry :pages, ::Gitlab::Ci::Config::Entry::Pages, + inherit: false, + description: 'Pages configuration.' + + entry :inputs, ::Gitlab::Config::Entry::ComposableHash, + description: 'Job input parameters.', + inherit: false, + metadata: { composable_class: ::Gitlab::Ci::Config::Entry::JobInput } + + attributes :script, :tags, :when, :dependencies, + :needs, :retry, :parallel, :start_in, + :timeout, :release, + :allow_failure, :publish, :pages, :manual_confirmation, :run + + def self.matching?(name, config) + !name.to_s.start_with?('.') && config.is_a?(Hash) && (config.key?(:script) || config.key?(:run)) + end + + def self.visible? + true + end + + def delayed? + self.when == 'delayed' + end + + def value + super.merge( + before_script: before_script_value, + script: script_value, + image: image_value, + services: services_value, + cache: cache_value, + tags: tags_value, + when: self.when, + start_in: self.start_in, + dependencies: dependencies, + coverage: coverage_defined? ? coverage_value : nil, + retry: retry_defined? ? retry_value : nil, + parallel: has_parallel? ? parallel_value : nil, + timeout: parsed_timeout, + artifacts: artifacts_with_pages_publish_path, + release: release_value, + after_script: after_script_value, + hooks: hooks_value, + ignore: ignored?, + allow_failure_criteria: allow_failure_criteria, + needs: needs_defined? ? needs_value : nil, + scheduling_type: needs_defined? ? :dag : :stage, + id_tokens: id_tokens_value, + publish: publish, + pages: pages, + manual_confirmation: self.manual_confirmation, + run: run, + inputs: inputs_value + ).compact + end + + def parsed_timeout + return unless has_timeout? + + ChronicDuration.parse(timeout.to_s) + end + + def ignored? + allow_failure_defined? ? static_allow_failure : manual_action? + end + + def pages_job? + return true if config[:pages].present? + + name == :pages && config[:pages] != false # legacy behavior, overridable with `pages: false` + end + + def artifacts_with_pages_publish_path + return artifacts_value unless pages_job? + + artifacts = artifacts_value || {} + artifacts[:paths] ||= [] + + return artifacts if artifacts[:paths].include?(pages_publish_path) + + artifacts.merge(paths: artifacts[:paths] + [pages_publish_path]) + end + + def self.allowed_keys + ALLOWED_KEYS + end + + private + + def allow_failure_criteria + if allow_failure_defined? && allow_failure_value.is_a?(Hash) + allow_failure_value + end + end + + def static_allow_failure + return false if allow_failure_value.is_a?(Hash) + + allow_failure_value + end + + def pages_publish_path + path = config[:publish] || PUBLIC_DIR + + return path unless config[:pages].is_a?(Hash) + + config.dig(:pages, :publish) || path + end + end + end + end + end +end + +::Gitlab::Ci::Config::Entry::Job.prepend_mod_with('Gitlab::Ci::Config::Entry::Job') diff --git a/spec/fixtures/corpus/gitlab/lib/gitlab/ci/config/entry/rules.rb b/spec/fixtures/corpus/gitlab/lib/gitlab/ci/config/entry/rules.rb new file mode 100644 index 0000000..91be1bb --- /dev/null +++ b/spec/fixtures/corpus/gitlab/lib/gitlab/ci/config/entry/rules.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + class Rules < ::Gitlab::Config::Entry::ComposableArray + include ::Gitlab::Config::Entry::Validatable + + validations do + validates :config, presence: true + validates :config, type: Array + end + + def value + [super].flatten + end + + def composable_class + Entry::Rules::Rule + end + end + end + end + end +end diff --git a/spec/fixtures/corpus/gitlab/lib/gitlab/ci/pipeline/chain/validate/abilities.rb b/spec/fixtures/corpus/gitlab/lib/gitlab/ci/pipeline/chain/validate/abilities.rb new file mode 100644 index 0000000..b3827d1 --- /dev/null +++ b/spec/fixtures/corpus/gitlab/lib/gitlab/ci/pipeline/chain/validate/abilities.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Pipeline + module Chain + module Validate + class Abilities < Chain::Base + include Gitlab::Allowable + include Chain::Helpers + include Gitlab::Utils::StrongMemoize + + GL_BUILD_ID_KEY = "glBuildId" + + def perform! + if project.pending_delete? + return error('Project is deleted!') + end + + unless project.builds_enabled? + return error('Pipelines are disabled!') + end + + if project.import_in_progress? + return error('You cannot run pipelines before project import is complete.') + end + + unless allowed_to_create_pipeline? + return error('Insufficient permissions to create a new pipeline') + end + + unless allowed_to_run_pipeline? + error("You do not have sufficient permission to run a pipeline on '#{command.ref}'. Please select a different branch or contact your administrator for assistance.") + end + + if push_from_ci? + Gitlab::AppLogger.info( + message: "Pipeline creation blocked for CI job token push", + build_id: command.gitaly_context[GL_BUILD_ID_KEY], + project_id: project.id + ) + error('Pipeline creation is not allowed when pushing from CI jobs. This prevents infinite pipeline loops.') + end + end + + def break? + @pipeline.errors.any? + end + + private + + def allowed_to_create_pipeline? + can?(current_user, :create_pipeline, command) + end + + def allowed_to_run_pipeline? + allowed_to_write_ref? + end + + def allowed_to_write_ref? + # This scenarios is when we have a MR from fork but the user has permissions to run + # a pipeline on the target project. In this case the pipeline is created using the MR ref. + # We skip the check because MR refs are created internally and there is no ref protection rules + # applicable to them. + # Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/378945 + if @command.merge_request_ref? && @command.merge_request&.for_fork? + true + elsif @command.merge_request_ref? && @command.merge_request&.for_same_project? + user_access.can_run_pipeline_on_branch?(@command.merge_request.source_branch) + elsif @command.branch? + user_access.can_run_pipeline_on_branch?(@command.ref) + elsif @command.tag? + user_access.can_create_tag?(@command.ref) + else + true # Allow it for now and we'll reject when we check ref existence + end + end + + def user_access + Gitlab::UserAccess.new(current_user, container: project) + end + strong_memoize_attr :user_access + + def push_from_ci? + command.gitaly_context&.dig(GL_BUILD_ID_KEY).present? + end + end + end + end + end + end +end + +Gitlab::Ci::Pipeline::Chain::Validate::Abilities.prepend_mod_with('Gitlab::Ci::Pipeline::Chain::Validate::Abilities') diff --git a/spec/fixtures/corpus/gitlab/lib/gitlab/database/migrations/runner_backoff/communicator.rb b/spec/fixtures/corpus/gitlab/lib/gitlab/database/migrations/runner_backoff/communicator.rb new file mode 100644 index 0000000..874dfc5 --- /dev/null +++ b/spec/fixtures/corpus/gitlab/lib/gitlab/database/migrations/runner_backoff/communicator.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Gitlab + module Database + module Migrations + module RunnerBackoff + class Communicator + EXPIRY = 1.minute + KEY = 'gitlab/database/migration/runner/backoff' + + def self.execute_with_lock(migration, &block) + new(migration).execute_with_lock(&block) + end + + def self.backoff_runner? + return false if ::Feature.disabled?(:runner_migrations_backoff, type: :ops) + + Gitlab::ExclusiveLease.new(KEY, timeout: EXPIRY).exists? + end + + def initialize(migration, logger: Gitlab::AppLogger) + @migration = migration + @logger = logger + end + + def execute_with_lock + log(message: 'Executing migration with Runner backoff') + + set_lock + yield if block_given? + ensure + remove_lock + end + + private + + attr_reader :logger, :migration + + def set_lock + raise 'Could not set backoff key' unless exclusive_lease.try_obtain + + log(message: 'Runner backoff key is set') + end + + def remove_lock + exclusive_lease.cancel + + log(message: 'Runner backoff key was removed') + end + + def exclusive_lease + @exclusive_lease ||= Gitlab::ExclusiveLease.new(KEY, timeout: EXPIRY) + end + + def log(params) + logger.info(log_params.merge(params)) + end + + def log_params + { class: migration.name } + end + end + end + end + end +end diff --git a/spec/fixtures/corpus/gitlab/spec/models/ability_spec.rb b/spec/fixtures/corpus/gitlab/spec/models/ability_spec.rb new file mode 100644 index 0000000..a174ffd --- /dev/null +++ b/spec/fixtures/corpus/gitlab/spec/models/ability_spec.rb @@ -0,0 +1,648 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ability, feature_category: :system_access do + describe '#policy_for' do + subject(:policy) { described_class.policy_for(user, subject, **options) } + + let(:user) { User.new } + let(:subject) { :global } + let(:options) { {} } + + context 'using a nil subject' do + let(:user) { nil } + let(:subject) { nil } + + it 'has no permissions' do + expect(policy).to be_banned + end + end + + context 'with request store', :request_store do + before do + ::Gitlab::SafeRequestStore.write(:example, :value) # make request store different from {} + end + + it 'caches in the request store' do + expect(DeclarativePolicy).to receive(:policy_for).with(user, subject, cache: ::Gitlab::SafeRequestStore.storage) + + policy + end + + context 'when cache: false' do + let(:options) { { cache: false } } + + it 'uses a fresh cache each time' do + expect(DeclarativePolicy).to receive(:policy_for).with(user, subject, cache: {}) + + policy + end + end + end + end + + describe '.users_that_can_read_project' do + context 'using a public project' do + it 'returns all the users' do + project = create(:project, :public) + user = build(:user) + + expect(described_class.users_that_can_read_project([user], project)) + .to eq([user]) + end + end + + context 'using an internal project' do + let(:project) { create(:project, :internal) } + + it 'returns users that are administrators' do + user = build(:user, admin: true) + + expect(described_class.users_that_can_read_project([user], project)) + .to eq([user]) + end + + it 'returns internal users while skipping external users' do + user1 = build(:user) + user2 = build(:user, external: true) + users = [user1, user2] + + expect(described_class.users_that_can_read_project(users, project)) + .to eq([user1]) + end + + it 'returns external users if they are the project owner' do + user1 = build_stubbed(:user, external: true) + user2 = build_stubbed(:user, external: true) + users = [user1, user2] + + allow(project).to receive_message_chain(:namespace, :owner_id).and_return(user1.id) + + expect(described_class.users_that_can_read_project(users, project)) + .to eq([user1]) + end + + it 'returns external users if they are project members' do + user1 = build(:user, external: true) + user2 = build(:user, external: true) + users = [user1, user2] + + expect(project.team).to receive(:members).at_least(:once).and_return([user1]) + + expect(described_class.users_that_can_read_project(users, project)) + .to eq([user1]) + end + + it 'returns an empty Array if all users are external users without access' do + user1 = build(:user, external: true) + user2 = build(:user, external: true) + users = [user1, user2] + + expect(described_class.users_that_can_read_project(users, project)) + .to eq([]) + end + end + + context 'using a private project' do + let(:project) { create(:project, :private) } + + it 'returns users that are administrators when admin mode is enabled', :enable_admin_mode do + user = build(:user, admin: true) + + expect(described_class.users_that_can_read_project([user], project)) + .to eq([user]) + end + + it 'does not return users that are administrators when admin mode is disabled' do + user = build(:user, admin: true) + + expect(described_class.users_that_can_read_project([user], project)) + .to eq([]) + end + + it 'returns external users if they are the project owner' do + user1 = build_stubbed(:user, external: true) + user2 = build_stubbed(:user, external: true) + users = [user1, user2] + + allow(project).to receive_message_chain(:namespace, :owner_id).and_return(user1.id) + + expect(described_class.users_that_can_read_project(users, project)) + .to eq([user1]) + end + + it 'returns external users if they are project members' do + user1 = build(:user, external: true) + user2 = build(:user, external: true) + users = [user1, user2] + + expect(project.team).to receive(:members).at_least(:once).and_return([user1]) + + expect(described_class.users_that_can_read_project(users, project)) + .to eq([user1]) + end + + it 'returns an empty Array if all users are internal users without access' do + user1 = build(:user) + user2 = build(:user) + users = [user1, user2] + + expect(described_class.users_that_can_read_project(users, project)) + .to eq([]) + end + + it 'returns an empty Array if all users are external users without access' do + user1 = build(:user, external: true) + user2 = build(:user, external: true) + users = [user1, user2] + + expect(described_class.users_that_can_read_project(users, project)) + .to eq([]) + end + end + end + + describe '.users_that_can_read_personal_snippet' do + def users_for_snippet(snippet) + described_class.users_that_can_read_personal_snippet(users, snippet) + end + + let_it_be(:users) { create_list(:user, 3) } + let(:author) { users[0] } + + it 'private snippet is readable only by its author' do + snippet = create(:personal_snippet, :private, author: author) + + expect(users_for_snippet(snippet)).to match_array([author]) + end + + it 'public snippet is readable by all users' do + snippet = create(:personal_snippet, :public, author: author) + + expect(users_for_snippet(snippet)).to match_array(users) + end + end + + describe '.users_that_can_read_internal_note' do + shared_examples 'filtering users that can read internal note' do + let_it_be(:guest) { create(:user) } + let_it_be(:reporter) { create(:user) } + + let(:users) { [reporter, guest] } + + before do + parent.add_guest(guest) + parent.add_reporter(reporter) + end + + it 'returns users that can read internal notes' do + result = described_class.users_that_can_read_internal_notes(users, parent) + + expect(result).to match_array([reporter]) + end + end + + context 'for groups' do + it_behaves_like 'filtering users that can read internal note' do + let(:parent) { create(:group) } + end + end + + context 'for projects' do + it_behaves_like 'filtering users that can read internal note' do + let(:parent) { create(:project) } + end + end + end + + describe '.users_that_can_read_confidential_issues' do + shared_examples 'filtering users that can read confidential issues' do + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project) } + let_it_be(:guest) { create(:user) } + let_it_be(:reporter) { create(:user) } + let_it_be(:developer) { create(:user) } + + let(:users) { [guest, reporter, developer] } + + before do + parent.add_guest(guest) + parent.add_reporter(reporter) + parent.add_developer(developer) + end + + it 'returns users that can read confidential issues' do + result = described_class.users_that_can_read_confidential_issues(users, parent) + + expect(result).to match_array([reporter, developer]) + end + + it 'returns empty array when no users can read confidential issues' do + result = described_class.users_that_can_read_confidential_issues([guest], parent) + + expect(result).to be_empty + end + end + + context 'for groups' do + it_behaves_like 'filtering users that can read confidential issues' do + let(:parent) { group } + end + end + + context 'for projects' do + it_behaves_like 'filtering users that can read confidential issues' do + let(:parent) { project } + end + end + end + + describe '.merge_requests_readable_by_user' do + context 'with an admin when admin mode is enabled', :enable_admin_mode do + it 'returns all merge requests' do + user = build(:user, admin: true) + merge_request = build(:merge_request) + + expect(described_class.merge_requests_readable_by_user([merge_request], user)) + .to eq([merge_request]) + end + end + + context 'with an admin when admin mode is disabled' do + it 'returns merge_requests that are publicly visible' do + user = build(:user, admin: true) + hidden_merge_request = build(:merge_request) + visible_merge_request = build(:merge_request, source_project: build(:project, :public)) + + merge_requests = described_class + .merge_requests_readable_by_user([hidden_merge_request, visible_merge_request], user) + + expect(merge_requests).to eq([visible_merge_request]) + end + end + + context 'without a user' do + it 'returns merge_requests that are publicly visible' do + hidden_merge_request = build(:merge_request) + visible_merge_request = build(:merge_request, source_project: build(:project, :public)) + + merge_requests = described_class + .merge_requests_readable_by_user([hidden_merge_request, visible_merge_request]) + + expect(merge_requests).to eq([visible_merge_request]) + end + end + + context 'with a user' do + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:merge_request) { create(:merge_request, source_project: project) } + let(:cross_project_merge_request) do + create(:merge_request, source_project: create(:project, :public)) + end + + let(:other_merge_request) { create(:merge_request) } + let(:all_merge_requests) do + [merge_request, cross_project_merge_request, other_merge_request] + end + + subject(:readable_merge_requests) do + described_class.merge_requests_readable_by_user(all_merge_requests, user) + end + + before do + project.add_developer(user) + end + + it 'returns projects visible to the user' do + expect(readable_merge_requests).to contain_exactly(merge_request, cross_project_merge_request) + end + + context 'when a user cannot read cross project and a filter is passed' do + before do + allow(described_class).to receive(:allowed?).and_call_original + expect(described_class).to receive(:allowed?).with(user, :read_cross_project) { false } + end + + subject(:readable_merge_requests) do + read_cross_project_filter = ->(merge_requests) do + merge_requests.select { |mr| mr.source_project == project } + end + described_class.merge_requests_readable_by_user( + all_merge_requests, user, + filters: { read_cross_project: read_cross_project_filter } + ) + end + + it 'returns only MRs of the specified project without checking access on others' do + expect(described_class).not_to receive(:allowed?).with(user, :read_merge_request, cross_project_merge_request) + + expect(readable_merge_requests).to contain_exactly(merge_request) + end + end + end + end + + describe '.issues_readable_by_user' do + it 'is aliased to .work_items_readable_by_user' do + expect(described_class.method(:issues_readable_by_user)) + .to eq(described_class.method(:work_items_readable_by_user)) + end + + context 'when the user cannot read cross project' do + let(:user) { create(:user) } + let(:issue) { create(:issue) } + let(:other_project_issue) { create(:issue) } + let(:project) { issue.project } + + before do + project.add_developer(user) + + allow(described_class).to receive(:allowed?).and_call_original + allow(described_class).to receive(:allowed?).with(user, :read_cross_project, any_args) { false } + end + + it 'excludes issues from other projects whithout checking separatly when passing a scope' do + expect(described_class).not_to receive(:allowed?).with(user, :read_issue, other_project_issue) + + filters = { read_cross_project: ->(issues) { issues.where(project: project) } } + result = described_class.issues_readable_by_user(Issue.all, user, filters: filters) + + expect(result).to contain_exactly(issue) + end + end + end + + describe '.feature_flags_readable_by_user' do + context 'without a user' do + it 'returns no feature flags' do + feature_flag_1 = build(:operations_feature_flag) + feature_flag_2 = build(:operations_feature_flag, project: build(:project, :public)) + + feature_flags = described_class + .feature_flags_readable_by_user([feature_flag_1, feature_flag_2]) + + expect(feature_flags).to eq([]) + end + end + + context 'with a user' do + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:feature_flag) { create(:operations_feature_flag, project: project) } + let(:cross_project) { create(:project) } + let(:cross_project_feature_flag) { create(:operations_feature_flag, project: cross_project) } + + let(:other_feature_flag) { create(:operations_feature_flag) } + let(:all_feature_flags) do + [feature_flag, cross_project_feature_flag, other_feature_flag] + end + + subject(:readable_feature_flags) do + described_class.feature_flags_readable_by_user(all_feature_flags, user) + end + + before do + project.add_developer(user) + cross_project.add_developer(user) + end + + it 'returns feature flags visible to the user' do + expect(readable_feature_flags).to contain_exactly(feature_flag, cross_project_feature_flag) + end + + context 'when a user cannot read cross project and a filter is passed' do + before do + allow(described_class).to receive(:allowed?).and_call_original + expect(described_class).to receive(:allowed?).with(user, :read_cross_project) { false } + end + + subject(:readable_feature_flags) do + read_cross_project_filter = ->(feature_flags) do + feature_flags.select { |flag| flag.project == project } + end + described_class.feature_flags_readable_by_user( + all_feature_flags, user, + filters: { read_cross_project: read_cross_project_filter } + ) + end + + it 'returns only feature flags of the specified project without checking access on others' do + expect(described_class).not_to receive(:allowed?).with(user, :read_feature_flag, cross_project_feature_flag) + + expect(readable_feature_flags).to contain_exactly(feature_flag) + end + end + end + end + + describe '.project_disabled_features_rules' do + let(:project) { create(:project, :wiki_disabled) } + + subject { described_class.policy_for(project.first_owner, project) } + + context 'wiki named abilities' do + it 'disables wiki abilities if the project has no wiki' do + expect(subject).not_to be_allowed(:read_wiki) + expect(subject).not_to be_allowed(:create_wiki) + expect(subject).not_to be_allowed(:update_wiki) + expect(subject).not_to be_allowed(:admin_wiki) + end + end + end + + describe '.allowed?' do + let_it_be(:group) { create(:group, :private) } + let_it_be_with_reload(:primary_user) { create(:user, :service_account) } + let_it_be(:scoped_user) { create(:user) } + + let(:request_store_key) { format(::Gitlab::Auth::Identity::COMPOSITE_IDENTITY_KEY_FORMAT, primary_user.id) } + + context 'with composite identity', :request_store do + before do + primary_user.update!(composite_identity_enforced: true) + end + + context 'with linked identity' do + before do + ::Gitlab::Auth::Identity.new(primary_user).link!(scoped_user) + end + + context 'when called with primary user' do + subject { described_class.allowed?(primary_user, :read_group, group) } + + context 'when both users are members' do + before_all do + group.add_developer(scoped_user) + group.add_developer(primary_user) + end + + it 'returns true' do + expect(subject).to be_truthy + end + end + + context 'when only primary user is a member' do + before_all do + group.add_developer(primary_user) + end + + it 'returns false' do + expect(subject).to be_falsey + end + + context 'with unenforced composite identity' do + before do + primary_user.update!(composite_identity_enforced: false) + end + + it 'returns true' do + expect(subject).to be_truthy + end + end + end + + context 'when only scoped user is a member' do + before_all do + group.add_developer(scoped_user) + end + + it 'returns false' do + expect(subject).to be_falsey + end + end + + context 'when neither user is a member' do + it 'returns false' do + expect(subject).to be_falsey + end + end + + context 'when scoped user is a composite identity' do + let_it_be(:scoped_user) { primary_user } + + it 'returns false' do + group.add_developer(primary_user) + + expect(subject).to be_falsey + end + end + end + + context 'when called with scoped user' do + subject { described_class.allowed?(scoped_user, :read_group, group) } + + context 'when both users are members' do + before_all do + group.add_developer(scoped_user) + group.add_developer(primary_user) + scoped_user.composite_identity_enforced! + end + + it 'returns true' do + expect(subject).to be_truthy + end + end + + context 'when only primary user is a member' do + before_all do + group.add_developer(primary_user) + end + + it 'returns false' do + expect(subject).to be_falsey + end + end + + context 'when only scoped user is a member' do + before_all do + scoped_user.composite_identity_enforced! + group.add_developer(scoped_user) + end + + it 'returns false' do + expect(subject).to be_falsey + end + end + + context 'when neither user is a member' do + it 'returns false' do + expect(subject).to be_falsey + end + end + end + end + + context 'without linked identity' do + before do + ::Gitlab::SafeRequestStore.delete(request_store_key) + end + + before_all do + group.add_developer(primary_user) + end + + subject { described_class.allowed?(primary_user, :read_group, group) } + + it 'returns false' do + expect(subject).to be_falsey + end + + context 'when ability check is not null safe' do + # some ability checks raise an error if the passed in user is nil + # this test has the ability check raise StandardError for a nil user to replicate this behavior + it 'returns false' do + allow(described_class).to receive(:allowed?).and_call_original + allow(described_class).to receive(:allowed?).with(nil, :read_group, group, + { composite_identity_check: false }) + .and_raise(StandardError) + + expect(subject).to be_falsey + end + end + end + end + end + + describe 'forgetting', :request_store do + it 'allows us to discard specific values from the DeclarativePolicy cache' do + user_a = build_stubbed(:user) + user_b = build_stubbed(:user) + + # expect these keys to remain + Gitlab::SafeRequestStore[:administrator] = :wibble + Gitlab::SafeRequestStore['admin'] = :wobble + described_class.allowed?(user_b, :read_all_resources) + # expect the DeclarativePolicy cache keys added by this action not to remain + described_class.forgetting(/admin/) do + described_class.allowed?(user_a, :read_all_resources) + end + + keys = Gitlab::SafeRequestStore.storage.keys + + expect(keys).to include( + :administrator, + 'admin', + "/dp/condition/BasePolicy/admin/User:#{user_b.id}" + ) + expect(keys).not_to include("/dp/condition/BasePolicy/admin/User:#{user_a.id}") + end + + # regression spec for re-entrant admin condition checks + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/332983 + context 'when bypassing the session' do + let(:user) { build_stubbed(:admin) } + let(:ability) { :admin_all_resources } # any admin-only ability is fine here. + + def check_ability + described_class.forgetting(/admin/) { described_class.allowed?(user, ability) } + end + + it 'allows us to have re-entrant evaluation of admin-only permissions' do + expect { Gitlab::Auth::CurrentUserMode.bypass_session!(user.id) } + .to change { check_ability }.from(false).to(true) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index f4a6af9..bd385ae 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -8,6 +8,7 @@ # Disable RSpec exposing methods globally on `Module` and `main` config.disable_monkey_patching! + config.exclude_pattern = "spec/fixtures/**/*_spec.rb" config.expect_with :rspec do |c| c.syntax = :expect diff --git a/spec/support/corpus_report_runner.rb b/spec/support/corpus_report_runner.rb new file mode 100644 index 0000000..20125a9 --- /dev/null +++ b/spec/support/corpus_report_runner.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +# Runs CodeKeeper against the vendored GitLab corpus with explicit settings. +module CorpusReportRunner + ROOT = "spec/fixtures/corpus/gitlab" + PARALLEL_THREAD_COUNT = 4 + RELATIVE_PATHS = %w[ + app/models/project.rb + app/models/merge_request.rb + app/models/concerns/has_repository.rb + app/services/merge_requests/create_service.rb + app/services/ci/create_pipeline_service.rb + app/graphql/types/project_type.rb + app/policies/project_policy.rb + lib/gitlab/ci/config/entry/job.rb + lib/gitlab/ci/config/entry/rules.rb + lib/gitlab/ci/pipeline/chain/validate/abilities.rb + lib/gitlab/background_migration/backfill_award_emoji_sharding_key.rb + lib/gitlab/database/migrations/runner_backoff/communicator.rb + config/routes.rb + app/models/ability.rb + app/models/concerns/issuable.rb + app/models/release_highlight.rb + app/models/concerns/ignorable_columns.rb + app/models/concerns/from_set_operator.rb + app/models/concerns/ci/partitionable.rb + spec/models/ability_spec.rb + ].freeze + FILE_PATHS = RELATIVE_PATHS.map { |path| File.join(ROOT, path) }.freeze + + module_function + + def file_paths + FILE_PATHS + end + + def report_for(paths, number_of_threads:) + CodeKeeper.configure do |config| + config.metrics = CodeKeeper::Metrics::MAPPINGS.keys + config.number_of_threads = number_of_threads + end + + CodeKeeper::Scorer.keep(Array(paths)) + end +end