Commit dbde2b33 authored by Bob Van Landuyt's avatar Bob Van Landuyt

Allow specifying code owners in a CODEOWNERS file

In a file called `CODEOWNERS` in the root of a repository, in the
`docs/`-folder or in the `.gitlab/` folder it is possible to define
users that are 'owners' for specific code paths.

A pattern can be defined on each line in the same way as it would in a
`.gitignore` file. After that, one or more users can be specified
using their username (using the `@username` format) or email address
linked to their account. Comments can be preceded with a `#`. If a
filename starts with `#` this can be escaped using `/#`.

For example:

    # All files in the `docs/` directory should be reviewed by a
    # technical writer:
    docs/* @jane @joe

    # Ruby files should be reviewed by a backend maintainer:
    *.rb alice@development.gitlab.org

The code owners will be displayed when viewing a blob, if a user for
the username/email cannot be found, nothing will be shown.

When multiple patterns match the blob being viewed, the last entry
will be used.
parent 180c091c
# Backend Maintainers are the default for all ruby files
*.rb @ayufan @DouweM @dzaporozhets @grzesiek @nick.thomas @rspeicher @rymai @smcgivern
*.rake @ayufan @DouweM @dzaporozhets @grzesiek @nick.thomas @rspeicher @rymai @smcgivern
# Technical writing team are the default reviewers for everything in `doc/`
/doc/ @axil @marcia
# Frontend maintainers should see everything in `app/assets/`
app/assets/ @annabeldunstone @ClemMakesApps @fatihacet @filipa @iamphill @mikegreiling @timzallmann
# Someone from the database team should review changes in `db/`
db/ @abrandl @NikolayS
# Feature specific owners
/ee/lib/gitlab/code_owners/ @reprazent
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
# Blob is a Rails-specific wrapper around Gitlab::Git::Blob objects # Blob is a Rails-specific wrapper around Gitlab::Git::Blob objects
class Blob < SimpleDelegator class Blob < SimpleDelegator
prepend EE::Blob
CACHE_TIME = 60 # Cache raw blobs referred to by a (mutable) ref for 1 minute CACHE_TIME = 60 # Cache raw blobs referred to by a (mutable) ref for 1 minute
CACHE_TIME_IMMUTABLE = 3600 # Cache blobs referred to by an immutable reference for 1 hour CACHE_TIME_IMMUTABLE = 3600 # Cache blobs referred to by an immutable reference for 1 hour
......
...@@ -9,23 +9,46 @@ module CaseSensitivity ...@@ -9,23 +9,46 @@ module CaseSensitivity
# #
# Unlike other ActiveRecord methods this method only operates on a Hash. # Unlike other ActiveRecord methods this method only operates on a Hash.
def iwhere(params) def iwhere(params)
criteria = self criteria = self
cast_lower = Gitlab::Database.postgresql?
params.each do |key, value| params.each do |key, value|
column = ActiveRecord::Base.connection.quote_table_name(key) criteria = case value
when Array
criteria.where(value_in(key, value))
else
criteria.where(value_equal(key, value))
end
end
criteria
end
condition = private
if cast_lower
"LOWER(#{column}) = LOWER(:value)" def value_equal(column, value)
else lower_value = lower_value(value)
"#{column} = :value"
end lower_column(arel_table[column]).eq(lower_value).to_sql
end
criteria = criteria.where(condition, value: value) def value_in(column, values)
lower_values = values.map do |value|
lower_value(value)
end end
criteria lower_column(arel_table[column]).in(lower_values).to_sql
end
def lower_value(value)
return value if Gitlab::Database.mysql?
Arel::Nodes::NamedFunction.new('LOWER', [Arel::Nodes.build_quoted(value)])
end
def lower_column(column)
return column if Gitlab::Database.mysql?
column.lower
end end
end end
end end
...@@ -266,6 +266,7 @@ class User < ActiveRecord::Base ...@@ -266,6 +266,7 @@ class User < ActiveRecord::Base
scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) } scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) }
scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) } scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) }
scope :confirmed, -> { where.not(confirmed_at: nil) } scope :confirmed, -> { where.not(confirmed_at: nil) }
scope :by_username, -> (usernames) { iwhere(username: usernames) }
# Limits the users to those that have TODOs, optionally in the given state. # Limits the users to those that have TODOs, optionally in the given state.
# #
...@@ -457,11 +458,11 @@ class User < ActiveRecord::Base ...@@ -457,11 +458,11 @@ class User < ActiveRecord::Base
end end
def find_by_username(username) def find_by_username(username)
iwhere(username: username).take by_username(username).take
end end
def find_by_username!(username) def find_by_username!(username)
iwhere(username: username).take! by_username(username).take!
end end
def find_by_personal_access_token(token_string) def find_by_personal_access_token(token_string)
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
.well-segment .well-segment
%ul.blob-commit-info %ul.blob-commit-info
= render 'projects/commits/commit', commit: @last_commit, project: @project, ref: @ref = render 'projects/commits/commit', commit: @last_commit, project: @project, ref: @ref
= render_if_exists 'projects/blob/owners', blob: blob
= render "projects/blob/auxiliary_viewer", blob: blob = render "projects/blob/auxiliary_viewer", blob: blob
#blob-content-holder.blob-content-holder #blob-content-holder.blob-content-holder
......
# Code owners
> [Introduced][introduced-mr] in [Gitlab Starter][ee] 11.3
You can use a `CODEOWNERS` file to specify users that are responsible
for a certain part of the code in a repository.
The codeowners file can be added to the root of the repository, inside
the `.gitlab/` or the `docs/` folder.
The `CODEOWNERS` is scoped to a branch. This means that with the
introduction of new files, the developer adding the new code can
specify themselves as code owner. Before the new code gets merged to
master.
## The syntax of a code owners file
Files can be specified using the same kind of patterns you would use
in the `.gitignore` file followed by the `@username` or email of one
or more users that should be owners of the file.
The order in which the paths are defined is significant: the last
pattern that matches a given path will be used to find the code
owners.
Starting a line with a `#` indicates a comment. This needs to be
escaped using `\#` to address files for which the name starts with a
`#`.
Example `CODEOWNERS` file:
```
# This is an example code owners file, lines starting with a `#` will
# be ignored.
# app/ @commented-rule
# We can specifiy a default match using wildcards:
* @default-codeowner
# Rules defined later in the file take precedence over the rules
# defined before.
# This will match all files for which the file name ends in `.rb`
*.rb @ruby-owner
# Files with a `#` can still be accesssed by escaping the pound sign
\#file_with_pound.rb @owner-file-with-pound
# Multiple codeowners can be specified, separated by whitespace
CODEOWNERS @multiple @owners @tab-separated
# Both usernames or email addresses can be used to match
# users. Everything else will be ignored. For example this will
# specify `@legal` and a user with email `janedoe@gitlab.com` as the
# owner for the LICENSE file
LICENSE @legal this does not match janedoe@gitlab.com
# Ending a path in a `/` will specify the code owners for every file
# nested in that directory, on any level
/docs/ @all-docs
# Ending a path in `/*` will specify code owners for every file in
# that directory, but not nested deeper. This will match
# `docs/index.md` but not `docs/projects/index.md`
/docs/* @root-docs
# This will make a `lib` directory nested anywhere in the repository
# match
lib/ @lib-owner
# This will only match a `config` directory in the root of the
# repository
/config/ @config-owner
# If the path contains spaces, these need to be escaped like this:
path\ with\ spaces/ @space-owner
```
[introduced-mr]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/6916
[ee]: https://about.gitlab.com/pricing/
...@@ -87,6 +87,8 @@ website with GitLab Pages ...@@ -87,6 +87,8 @@ website with GitLab Pages
your code blocks, overriding GitLab's default choice of language your code blocks, overriding GitLab's default choice of language
- [Badges](badges.md): Badges for the project overview - [Badges](badges.md): Badges for the project overview
- [Maven packages](maven_packages.md): Your private Maven repository in GitLab - [Maven packages](maven_packages.md): Your private Maven repository in GitLab
- [Code owners](code_owners.md): Specify code owners for certain files
**[STARTER]**
### Project's integrations ### Project's integrations
......
# frozen_string_literal: true
module EE
module UsersHelper
def users_sentence(users, link_class: nil)
users.map { |user| link_to(user.name, user, class: link_class) }.to_sentence.html_safe
end
end
end
# frozen_string_literal: true
module EE
module Blob
extend ActiveSupport::Concern
def owners
@owners ||= ::Gitlab::CodeOwners.for_blob(self)
end
end
end
...@@ -87,5 +87,10 @@ module EE ...@@ -87,5 +87,10 @@ module EE
def geo_updated_event_source def geo_updated_event_source
is_wiki ? Geo::RepositoryUpdatedEvent::WIKI : Geo::RepositoryUpdatedEvent::REPOSITORY is_wiki ? Geo::RepositoryUpdatedEvent::WIKI : Geo::RepositoryUpdatedEvent::REPOSITORY
end end
def code_owners_blob(ref: 'HEAD')
possible_code_owner_blobs = ::Gitlab::CodeOwners::FILE_PATHS.map { |path| [ref, path] }
blobs_at(possible_code_owner_blobs).compact.first
end
end end
end end
...@@ -9,6 +9,7 @@ class License < ActiveRecord::Base ...@@ -9,6 +9,7 @@ class License < ActiveRecord::Base
EES_FEATURES = %i[ EES_FEATURES = %i[
audit_events audit_events
burndown_charts burndown_charts
code_owners
contribution_analytics contribution_analytics
elastic_search elastic_search
export_issues export_issues
......
- return if blob.owners.empty?
.well-segment.blob-auxiliary-viewer.file-owner-content
= sprite_icon('users', size: 18, css_class: 'icon')
%strong
= _("Code owners")
= link_to icon('question-circle'), help_page_path('user/project/code_owners'), title: 'About this feature', target: '_blank'
&#58;
= users_sentence(blob.owners, link_class: 'file-owner-link')
---
title: Allow specifying code owners in a CODEOWNERS file
merge_request: 6916
author:
type: added
# frozen_string_literal: true
module Gitlab
module CodeOwners
FILE_NAME = 'CODEOWNERS'
FILE_PATHS = [FILE_NAME, "docs/#{FILE_NAME}", ".gitlab/#{FILE_NAME}"].freeze
def self.for_blob(blob)
if blob.project.feature_available?(:code_owners)
Loader.new(blob.project, blob.commit_id, blob.path).users
else
User.none
end
end
end
end
# frozen_string_literal: true
module Gitlab
module CodeOwners
class File
def initialize(blob)
@blob = blob
end
def parsed_data
@parsed_data ||= get_parsed_data
end
def empty?
parsed_data.empty?
end
def owners_for_path(path)
matching_pattern = parsed_data.keys.reverse.detect do |pattern|
path_matches?(pattern, path)
end
parsed_data[matching_pattern]
end
private
def data
if @blob && !@blob.binary?
@blob.data
else
""
end
end
def get_parsed_data
parsed = {}
data.lines.each do |line|
line = line.strip
next unless line.present?
next if line.starts_with?('#')
pattern, _separator, owners = line.partition(/(?<!\\)\s+/)
pattern = normalize_pattern(pattern)
parsed[pattern] = owners
end
parsed
end
def normalize_pattern(pattern)
# Remove `\` when escaping `\#`
pattern = pattern.sub(/\A\\#/, '#')
# Replace all whitespace preceded by a \ with a regular whitespace
pattern = pattern.gsub(/\\\s+/, ' ')
if pattern.starts_with?('/')
# Remove the leading slash when only matching root directory as the
# paths that we will be matching will always be passed in starting
# from the root of the repsitory.
pattern = pattern.sub(%r{\A/}, '')
elsif !pattern.starts_with?('*')
# If the pattern is a regular match, prepend it with ** so we match
# nested in every directory
pattern = "**#{pattern}"
end
pattern
end
def path_matches?(pattern, path)
flags = ::File::FNM_DOTMATCH
if pattern.ends_with?('/*')
# Then the pattern ends in a wildcard, we only want to go one level deep
# setting `::File::FNM_PATHNAME` makes the `*` not match directory
# separators
flags |= ::File::FNM_PATHNAME
::File.fnmatch?(pattern, path, flags)
else
# Replace a pattern ending with `/` to `/*` to match everything within
# that directory
nested_pattern = pattern.sub(%r{/\z}, '/*')
::File.fnmatch?(nested_pattern, path, flags)
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module CodeOwners
class Loader
def initialize(project, ref, path)
@project, @ref, @path = project, ref, path
end
def users
return User.none if code_owners_file.empty?
owners = code_owners_file.owners_for_path(@path)
extracted_users = Gitlab::UserExtractor.new(owners).users
@project.authorized_users.merge(extracted_users)
end
private
def code_owners_file
if RequestStore.active?
RequestStore.fetch("project-#{@project.id}:code-owners:#{@ref}") do
load_code_owners_file
end
else
load_code_owners_file
end
end
def load_code_owners_file
code_owners_blob = @project.repository.code_owners_blob(ref: @ref)
Gitlab::CodeOwners::File.new(code_owners_blob)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe 'File blob > Code owners', :js do
let(:project) { create(:project, :private, :repository) }
let(:user) { project.owner }
let(:code_owner) { create(:user, username: 'documentation-owner') }
before do
sign_in(user)
project.add_developer(code_owner)
end
def visit_blob(path, anchor: nil, ref: 'master')
visit project_blob_path(project, File.join(ref, path), anchor: anchor)
wait_for_requests
end
context 'when there is a codeowners file' do
context 'when the feature is available' do
before do
stub_licensed_features(code_owners: true)
end
it 'shows the code owners related to a file' do
visit_blob('docs/CODEOWNERS', ref: 'with-codeowners')
within('.file-owner-content') do
expect(page).to have_content('Code owners')
expect(page).to have_link(code_owner.name)
end
end
it 'does not show the code owners banner when there are no code owners' do
visit_blob('README.md')
expect(page).not_to have_content('Code owners:')
end
end
context 'when the feature is not available' do
before do
stub_licensed_features(code_owners: false)
end
it 'does not show the code owners related to a file' do
visit_blob('docs/CODEOWNERS', ref: 'with-codeowners')
expect(page).not_to have_content('Code owners')
end
end
end
end
# This is an example code owners file, lines starting with a `#` will
# be ignored.
# app/ @commented-rule
# We can specifiy a default match using wildcards:
* @default-codeowner
# Rules defined later in the file take precedence over the rules
# defined before.
# This will match all files for which the file name ends in `.rb`
*.rb @ruby-owner
# Files with a `#` can still be accesssed by escaping the pound sign
\#file_with_pound.rb @owner-file-with-pound
# Multiple codeowners can be specified, separated by whitespace
CODEOWNERS @multiple @owners @tab-separated
# Both usernames or email addresses can be used to match
# users. Everything else will be ignored. For example this will
# specify `@legal` and a user with email `janedoe@gitlab.com` as the
# owner for the LICENSE file
LICENSE @legal this does not match janedoe@gitlab.com
# Ending a path in a `/` will specify the code owners for every file
# nested in that directory, on any level
/docs/ @all-docs
# Ending a path in `/*` will specify code owners for every file in
# that directory, but not nested deeper. This will match
# `docs/index.md` but not `docs/projects/index.md`
/docs/* @root-docs
# This will make a `lib` directory nested anywhere in the repository
# match
lib/ @lib-owner
# This will only match a `config` directory in the root of the
# repository
/config/ @config-owner
# If the path contains spaces, these need to be escaped like this:
path\ with\ spaces/ @space-owner
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::CodeOwners::File do
include FakeBlobHelpers
let(:project) { build(:project) }
let(:file_content) do
File.read(Rails.root.join('ee', 'spec', 'fixtures', 'codeowners_example'))
end
let(:blob) { fake_blob(path: 'CODEOWNERS', data: file_content) }
subject(:file) { described_class.new(blob) }
describe '#parsed_data' do
it 'parses all the required lines' do
expected_patterns = [
'*', '**#file_with_pound.rb', '*.rb', '**CODEOWNERS', '**LICENSE', 'docs/',
'docs/*', 'config/', '**lib/', '**path with spaces/'
]
expect(file.parsed_data.keys)
.to contain_exactly(*expected_patterns)
end
it 'allows usernames and emails' do
expect(file.parsed_data['**LICENSE']).to include('legal', 'janedoe@gitlab.com')
end
context 'when there are entries that do not look like user references' do
let(:file_content) do
"a-path/ this is all random @username and email@gitlab.org"
end
it 'ignores the entries' do
expect(file.parsed_data['**a-path/']).to include('username', 'email@gitlab.org')
end
end
end
describe '#empty?' do
subject { file.empty? }
it { is_expected.to be(false) }
context 'when there is no content' do
let(:file_content) { "" }
it { is_expected.to be(true) }
end
context 'when the file is binary' do
let(:blob) { fake_blob(binary: true) }
it { is_expected.to be(true) }
end
context 'when the file did not exist' do
let(:blob) { nil }
it { is_expected.to be(true) }
end
end
describe '#owners_for_path' do
context 'for a path without matches' do
let(:file_content) do
<<~CONTENT
# Simulating a CODOWNERS without entries
CONTENT
end
it 'returns an nil for an unmatched path' do
owners = file.owners_for_path('no_matches')
expect(owners).to be_nil
end
end
it 'matches random files to a pattern' do
owners = file.owners_for_path('app/assets/something.vue')
expect(owners).to include('default-codeowner')
end
it 'uses the last pattern if multiple patterns match' do
owners = file.owners_for_path('hello.rb')
expect(owners).to eq('@ruby-owner')
end
it 'returns the usernames for a file matching a pattern with a glob' do
owners = file.owners_for_path('app/models/repository.rb')
expect(owners).to eq('@ruby-owner')
end
it 'allows specifying multiple users' do
owners = file.owners_for_path('CODEOWNERS')
expect(owners).to include('multiple', 'owners', 'tab-separated')
end
it 'returns emails and usernames for a matched pattern' do
owners = file.owners_for_path('LICENSE')
expect(owners).to include('legal', 'janedoe@gitlab.com')
end
it 'allows escaping the pound sign used for comments' do
owners = file.owners_for_path('examples/#file_with_pound.rb')
expect(owners).to include('owner-file-with-pound')
end
it 'returns the usernames for a file nested in a directory' do
owners = file.owners_for_path('docs/projects/index.md')
expect(owners).to include('all-docs')
end
it 'returns the usernames for a pattern matched with a glob in a folder' do
owners = file.owners_for_path('docs/index.md')
expect(owners).to include('root-docs')
end
it 'allows matching files nested anywhere in the repository', :aggregate_failures do
lib_owners = file.owners_for_path('lib/gitlab/git/repository.rb')
other_lib_owners = file.owners_for_path('ee/lib/gitlab/git/repository.rb')
expect(lib_owners).to include('lib-owner')
expect(other_lib_owners).to include('lib-owner')
end
it 'allows allows limiting the matching files to the root of the repository', :aggregate_failures do
config_owners = file.owners_for_path('config/database.yml')
other_config_owners = file.owners_for_path('other/config/database.yml')
expect(config_owners).to include('config-owner')
expect(other_config_owners).to eq('@default-codeowner')
end
it 'correctly matches paths with spaces' do
owners = file.owners_for_path('path with spaces/README.md')
expect(owners).to eq('@space-owner')
end
context 'paths with whitespaces and username lookalikes' do
let(:file_content) do
'a/weird\ path\ with/\ @username\ /\ and-email@lookalikes.com\ / @user-1 email@gitlab.org @user-2'
end
it 'parses correctly' do
owners = file.owners_for_path('a/weird path with/ @username / and-email@lookalikes.com /test.rb')
expect(owners).to include('user-1', 'user-2', 'email@gitlab.org')
expect(owners).not_to include('username', 'and-email@lookalikes.com')
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::CodeOwners::Loader do
include FakeBlobHelpers
set(:group) { create(:group) }
set(:project) { create(:project, namespace: group) }
subject(:loader) { described_class.new(project, 'with-codeowners', path) }
let!(:owner_1) { create(:user, username: 'owner-1') }
let!(:email_owner) { create(:user, username: 'owner-2') }
let!(:owner_3) { create(:user, username: 'owner-3') }
let!(:documentation_owner) { create(:user, username: 'documentation-owner') }
let(:codeowner_content) do
<<~CODEOWNERS
docs/* @documentation-owner
docs/CODEOWNERS @owner-1 owner2@gitlab.org @owner-3 @documentation-owner
CODEOWNERS
end
let(:codeowner_blob) { fake_blob(path: 'CODEOWNERS', data: codeowner_content) }
let(:path) { 'docs/CODEOWNERS' }
before do
create(:email, user: email_owner, email: 'owner2@gitlab.org')
project.add_developer(owner_1)
project.add_developer(email_owner)
project.add_developer(documentation_owner)
allow(project.repository).to receive(:code_owners_blob).and_return(codeowner_blob)
end
describe '#users' do
context 'with a CODEOWNERS file' do
context 'for a path with code owners' do
it 'returns all existing users that are members of the project' do
expect(loader.users).to contain_exactly(owner_1, email_owner, documentation_owner)
end
it 'does not return users that are not members of the project' do
expect(loader.users).not_to include(owner_3)
end
it 'includes group members of the project' do
group.add_developer(owner_3)
expect(loader.users).to include(owner_3)
end
end
context 'for another path' do
let(:path) { 'no-codeowner' }
it 'returns no users' do
expect(loader.users).to be_empty
end
end
end
context 'when there is no codeowners file' do
let(:codeowner_blob) { nil }
it 'returns no users without failing' do
expect(loader.users).to be_empty
end
end
context 'with the request store', :request_store do
it 'only calls out to the repository once' do
expect(project.repository).to receive(:code_owners_blob).once
2.times { loader.users }
end
it 'only processes the file once' do
code_owners_file = loader.__send__(:code_owners_file)
expect(code_owners_file).to receive(:get_parsed_data).once.and_call_original
2.times { loader.users }
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::CodeOwners do
include FakeBlobHelpers
let!(:code_owner) { create(:user, username: 'owner-1') }
let(:project) { create(:project, :repository) }
let(:blob) do
project.repository.blob_at(TestEnv::BRANCH_SHA['with-codeowners'], 'docs/CODEOWNERS')
end
let(:codeowner_content) { "docs/CODEOWNERS @owner-1" }
let(:codeowner_blob) { fake_blob(path: 'CODEOWNERS', data: codeowner_content) }
before do
project.add_developer(code_owner)
allow(project.repository).to receive(:code_owners_blob).and_return(codeowner_blob)
end
describe '.for_blob' do
context 'when the feature is available' do
before do
stub_licensed_features(code_owners: true)
end
it 'returns users for a blob' do
expect(described_class.for_blob(blob)).to include(code_owner)
end
end
context 'when the feature is not available' do
before do
stub_licensed_features(code_owners: false)
end
it 'returns no users' do
expect(described_class.for_blob(blob)).to be_empty
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Blob do
let(:project) { create(:project, :repository) }
let(:blob) do
project.repository.blob_at(TestEnv::BRANCH_SHA['with-codeowners'], 'docs/CODEOWNERS')
end
let(:code_owner) { create(:user, username: 'documentation-owner') }
before do
project.add_developer(code_owner)
end
describe '#owners' do
context 'when the feature is available' do
before do
stub_licensed_features(code_owners: true)
end
it 'returns the owners from the file' do
expect(blob.owners).to include(code_owner)
end
end
context 'when the feature is not available' do
before do
stub_licensed_features(code_owners: false)
end
it 'returns no code owners' do
expect(blob.owners).to be_empty
end
end
end
end
...@@ -154,4 +154,24 @@ describe Repository do ...@@ -154,4 +154,24 @@ describe Repository do
end end
end end
end end
describe '#code_owners_blob' do
it 'returns nil if there is no codeowners file' do
expect(repository.code_owners_blob(ref: 'master')).to be_nil
end
it 'returns the content of the codeowners file when it is found' do
expect(repository.code_owners_blob(ref: 'with-codeowners').data).to include('example CODEOWNERS file')
end
it 'requests the CODOWNER blobs in batch in the correct order' do
expect(repository).to receive(:blobs_at)
.with([%w(HEAD CODEOWNERS),
%w(HEAD docs/CODEOWNERS),
%w(HEAD .gitlab/CODEOWNERS)])
.and_call_original
repository.code_owners_blob
end
end
end end
# frozen_string_literal: true
# This class extracts all users found in a piece of text by the username or the
# email adress
module Gitlab
class UserExtractor
# Not using `Devise.email_regexp` to filter out any chars that an email
# does not end with and not pinning the email to a start of end of a string.
EMAIL_REGEXP = /(?<email>([^@\s]+@[^@\s]+(?<!\W)))/
USERNAME_REGEXP = User.reference_pattern
def initialize(text)
@text = text
end
def users
return User.none unless @text.present?
@users ||= User.from("(#{union.to_sql}) users")
end
def usernames
matches[:usernames]
end
def emails
matches[:emails]
end
def references
@references ||= matches.values.flatten
end
def matches
@matches ||= {
emails: @text.scan(EMAIL_REGEXP).flatten.uniq,
usernames: @text.scan(USERNAME_REGEXP).flatten.uniq
}
end
private
def union
relations = []
relations << User.by_any_email(emails) if emails.any?
relations << User.by_username(usernames) if usernames.any?
Gitlab::SQL::Union.new(relations)
end
end
end
...@@ -1946,6 +1946,9 @@ msgstr "" ...@@ -1946,6 +1946,9 @@ msgstr ""
msgid "ClusterIntegration|sign up" msgid "ClusterIntegration|sign up"
msgstr "" msgstr ""
msgid "Code owners"
msgstr ""
msgid "Cohorts" msgid "Cohorts"
msgstr "" msgstr ""
......
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::UserExtractor do
let(:text) do
<<~TXT
This is a long texth that mentions some users.
@user-1, @user-2 and user@gitlab.org take a walk in the park.
There they meet @user-4 that was out with other-user@gitlab.org.
@user-1 thought it was late, so went home straight away
TXT
end
subject(:extractor) { described_class.new(text) }
describe '#users' do
it 'returns an empty relation when nil was passed' do
extractor = described_class.new(nil)
expect(extractor.users).to be_empty
expect(extractor.users).to be_a(ActiveRecord::Relation)
end
it 'returns the user case insensitive for usernames' do
user = create(:user, username: "USER-4")
expect(extractor.users).to include(user)
end
it 'returns users by primary email' do
user = create(:user, email: 'user@gitlab.org')
expect(extractor.users).to include(user)
end
it 'returns users by secondary email' do
user = create(:email, email: 'other-user@gitlab.org').user
expect(extractor.users).to include(user)
end
end
describe '#matches' do
it 'includes all mentioned email adresses' do
expect(extractor.matches[:emails]).to contain_exactly('user@gitlab.org', 'other-user@gitlab.org')
end
it 'includes all mentioned usernames' do
expect(extractor.matches[:usernames]).to contain_exactly('user-1', 'user-2', 'user-4')
end
end
describe '#references' do
it 'includes all user-references once' do
expect(extractor.references).to contain_exactly('user-1', 'user-2', 'user@gitlab.org', 'user-4', 'other-user@gitlab.org')
end
end
end
...@@ -3,186 +3,50 @@ require 'spec_helper' ...@@ -3,186 +3,50 @@ require 'spec_helper'
describe CaseSensitivity do describe CaseSensitivity do
describe '.iwhere' do describe '.iwhere' do
let(:connection) { ActiveRecord::Base.connection } let(:connection) { ActiveRecord::Base.connection }
let(:model) { Class.new { include CaseSensitivity } } let(:model) do
Class.new(ActiveRecord::Base) do
describe 'using PostgreSQL' do include CaseSensitivity
before do self.table_name = 'namespaces'
allow(Gitlab::Database).to receive(:postgresql?).and_return(true)
allow(Gitlab::Database).to receive(:mysql?).and_return(false)
end
describe 'with a single column/value pair' do
it 'returns the criteria for a column and a value' do
criteria = double(:criteria)
expect(connection).to receive(:quote_table_name)
.with(:foo)
.and_return('"foo"')
expect(model).to receive(:where)
.with(%q{LOWER("foo") = LOWER(:value)}, value: 'bar')
.and_return(criteria)
expect(model.iwhere(foo: 'bar')).to eq(criteria)
end
it 'returns the criteria for a column with a table, and a value' do
criteria = double(:criteria)
expect(connection).to receive(:quote_table_name)
.with(:'foo.bar')
.and_return('"foo"."bar"')
expect(model).to receive(:where)
.with(%q{LOWER("foo"."bar") = LOWER(:value)}, value: 'bar')
.and_return(criteria)
expect(model.iwhere('foo.bar'.to_sym => 'bar')).to eq(criteria)
end
end
describe 'with multiple column/value pairs' do
it 'returns the criteria for a column and a value' do
initial = double(:criteria)
final = double(:criteria)
expect(connection).to receive(:quote_table_name)
.with(:foo)
.and_return('"foo"')
expect(connection).to receive(:quote_table_name)
.with(:bar)
.and_return('"bar"')
expect(model).to receive(:where)
.with(%q{LOWER("foo") = LOWER(:value)}, value: 'bar')
.and_return(initial)
expect(initial).to receive(:where)
.with(%q{LOWER("bar") = LOWER(:value)}, value: 'baz')
.and_return(final)
got = model.iwhere(foo: 'bar', bar: 'baz')
expect(got).to eq(final)
end
it 'returns the criteria for a column with a table, and a value' do
initial = double(:criteria)
final = double(:criteria)
expect(connection).to receive(:quote_table_name)
.with(:'foo.bar')
.and_return('"foo"."bar"')
expect(connection).to receive(:quote_table_name)
.with(:'foo.baz')
.and_return('"foo"."baz"')
expect(model).to receive(:where)
.with(%q{LOWER("foo"."bar") = LOWER(:value)}, value: 'bar')
.and_return(initial)
expect(initial).to receive(:where)
.with(%q{LOWER("foo"."baz") = LOWER(:value)}, value: 'baz')
.and_return(final)
got = model.iwhere('foo.bar'.to_sym => 'bar',
'foo.baz'.to_sym => 'baz')
expect(got).to eq(final)
end
end end
end end
describe 'using MySQL' do let!(:model_1) { model.create(path: 'mOdEl-1', name: 'mOdEl 1') }
before do let!(:model_2) { model.create(path: 'mOdEl-2', name: 'mOdEl 2') }
allow(Gitlab::Database).to receive(:postgresql?).and_return(false)
allow(Gitlab::Database).to receive(:mysql?).and_return(true)
end
describe 'with a single column/value pair' do
it 'returns the criteria for a column and a value' do
criteria = double(:criteria)
expect(connection).to receive(:quote_table_name)
.with(:foo)
.and_return('`foo`')
expect(model).to receive(:where)
.with(%q{`foo` = :value}, value: 'bar')
.and_return(criteria)
expect(model.iwhere(foo: 'bar')).to eq(criteria) it 'finds a single instance by a single attribute regardless of case' do
end expect(model.iwhere(path: 'MODEL-1')).to contain_exactly(model_1)
end
it 'returns the criteria for a column with a table, and a value' do it 'finds multiple instances by a single attribute regardless of case' do
criteria = double(:criteria) expect(model.iwhere(path: %w(MODEL-1 model-2))).to contain_exactly(model_1, model_2)
end
expect(connection).to receive(:quote_table_name) it 'finds instances by multiple attributes' do
.with(:'foo.bar') expect(model.iwhere(path: %w(MODEL-1 model-2), name: 'model 1'))
.and_return('`foo`.`bar`') .to contain_exactly(model_1)
end
expect(model).to receive(:where) # Using `mysql` & `postgresql` metadata-tags here because both adapters build
.with(%q{`foo`.`bar` = :value}, value: 'bar') # the query slightly differently
.and_return(criteria) context 'for MySQL', :mysql do
it 'builds a simple query' do
query = model.iwhere(path: %w(MODEL-1 model-2), name: 'model 1').to_sql
expected_query = <<~QRY.strip
SELECT `namespaces`.* FROM `namespaces` WHERE (`namespaces`.`path` IN ('MODEL-1', 'model-2')) AND (`namespaces`.`name` = 'model 1')
QRY
expect(model.iwhere('foo.bar'.to_sym => 'bar')) expect(query).to eq(expected_query)
.to eq(criteria)
end
end end
end
describe 'with multiple column/value pairs' do context 'for PostgreSQL', :postgresql do
it 'returns the criteria for a column and a value' do it 'builds a query using LOWER' do
initial = double(:criteria) query = model.iwhere(path: %w(MODEL-1 model-2), name: 'model 1').to_sql
final = double(:criteria) expected_query = <<~QRY.strip
SELECT \"namespaces\".* FROM \"namespaces\" WHERE (LOWER(\"namespaces\".\"path\") IN (LOWER('MODEL-1'), LOWER('model-2'))) AND (LOWER(\"namespaces\".\"name\") = LOWER('model 1'))
expect(connection).to receive(:quote_table_name) QRY
.with(:foo)
.and_return('`foo`')
expect(connection).to receive(:quote_table_name)
.with(:bar)
.and_return('`bar`')
expect(model).to receive(:where)
.with(%q{`foo` = :value}, value: 'bar')
.and_return(initial)
expect(initial).to receive(:where)
.with(%q{`bar` = :value}, value: 'baz')
.and_return(final)
got = model.iwhere(foo: 'bar', bar: 'baz')
expect(got).to eq(final)
end
it 'returns the criteria for a column with a table, and a value' do
initial = double(:criteria)
final = double(:criteria)
expect(connection).to receive(:quote_table_name)
.with(:'foo.bar')
.and_return('`foo`.`bar`')
expect(connection).to receive(:quote_table_name)
.with(:'foo.baz')
.and_return('`foo`.`baz`')
expect(model).to receive(:where)
.with(%q{`foo`.`bar` = :value}, value: 'bar')
.and_return(initial)
expect(initial).to receive(:where)
.with(%q{`foo`.`baz` = :value}, value: 'baz')
.and_return(final)
got = model.iwhere('foo.bar'.to_sym => 'bar',
'foo.baz'.to_sym => 'baz')
expect(got).to eq(final) expect(query).to eq(expected_query)
end
end end
end end
end end
......
...@@ -433,6 +433,23 @@ describe User do ...@@ -433,6 +433,23 @@ describe User do
end end
end end
end end
describe '.by_username' do
it 'finds users regardless of the case passed' do
user = create(:user, username: 'CaMeLcAsEd')
user2 = create(:user, username: 'UPPERCASE')
expect(described_class.by_username(%w(CAMELCASED uppercase)))
.to contain_exactly(user, user2)
end
it 'finds a single user regardless of the case passed' do
user = create(:user, username: 'CaMeLcAsEd')
expect(described_class.by_username('CAMELCASED'))
.to contain_exactly(user)
end
end
end end
describe "Respond to" do describe "Respond to" do
......
...@@ -52,7 +52,8 @@ module TestEnv ...@@ -52,7 +52,8 @@ module TestEnv
'add_images_and_changes' => '010d106', 'add_images_and_changes' => '010d106',
'update-gitlab-shell-v-6-0-1' => '2f61d70', 'update-gitlab-shell-v-6-0-1' => '2f61d70',
'update-gitlab-shell-v-6-0-3' => 'de78448', 'update-gitlab-shell-v-6-0-3' => 'de78448',
'2-mb-file' => 'bf12d25' '2-mb-file' => 'bf12d25',
'with-codeowners' => '219560e'
}.freeze }.freeze
# gitlab-test-fork is a fork of gitlab-fork, but we don't necessarily # gitlab-test-fork is a fork of gitlab-fork, but we don't necessarily
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment