summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFilipa Lacerda <filipa@gitlab.com>2017-08-30 19:51:51 +0100
committerFilipa Lacerda <filipa@gitlab.com>2017-08-30 19:51:51 +0100
commitb6502103d4fd08a4046907b4b076b3611f017cb1 (patch)
tree784bb8e46e66a7e9a6b646fbbbef19b824cf03e7
parent9d25efd5c0c37982a16c3636e27cd3e5da0d7fdb (diff)
parent81f08d30e641dc1a6666022ab1f5d36dbcdced7e (diff)
downloadgitlab-ce-36917-branch-tooltip.tar.gz
Merge branch 'master' into 36917-branch-tooltip36917-branch-tooltip
* master: (26 commits) Fix invalid attribute used for time-ago-tooltip component Update latest artifacts doc Update share project with groups docs Remove tooltips from new sidebar Use `git update-ref --stdin -z` to delete refs Don't use public_send in destroy_conditionally! helper Fix MySQL failure for emoji autocomplete Replace 'project/user_lookup.feature' spinach test with an rspec analog Add changelog Add time stats to issue and merge request API end points Resolve new N+1 by adding preloads and metadata to issues end points Add missing N+1 test to issues spec Add time stats to API schema for issue and merge request end points Add time stats documentation to issue and merge request end points Improve migrations using triggers Increase z-index of dropdowns Just use a block Fix tests Try to make reserved ref names more obvious Resolve feedback on the MR: ...
-rw-r--r--app/assets/javascripts/issue_show/components/edited.vue2
-rw-r--r--app/assets/javascripts/new_sidebar.js5
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss2
-rw-r--r--app/controllers/autocomplete_controller.rb2
-rw-r--r--app/helpers/tab_helper.rb4
-rw-r--r--app/models/environment.rb2
-rw-r--r--app/models/merge_request.rb2
-rw-r--r--app/models/project.rb6
-rw-r--r--app/models/repository.rb20
-rw-r--r--app/services/projects/after_import_service.rb29
-rw-r--r--app/services/projects/housekeeping_service.rb2
-rw-r--r--app/views/layouts/nav/_new_admin_sidebar.html.haml24
-rw-r--r--app/views/layouts/nav/_new_group_sidebar.html.haml10
-rw-r--r--app/views/layouts/nav/_new_profile_sidebar.html.haml24
-rw-r--r--app/views/layouts/nav/_new_project_sidebar.html.haml18
-rw-r--r--changelogs/unreleased/28453-add-time-estimate-time-spent-to-api-issue-output.yml4
-rw-r--r--changelogs/unreleased/36807-gc-unwanted-refs-after-import.yml5
-rw-r--r--changelogs/unreleased/check-trigger-permissions.yml5
-rw-r--r--changelogs/unreleased/replace_spinach_user_lookup-feature.yml5
-rw-r--r--doc/api/issues.md56
-rw-r--r--doc/api/merge_requests.md62
-rw-r--r--doc/user/project/members/img/other_group_sees_shared_project.pngbin30182 -> 21154 bytes
-rw-r--r--doc/user/project/members/img/share_project_with_groups.pngbin30307 -> 37405 bytes
-rw-r--r--doc/user/project/members/img/share_project_with_groups_tab.pngbin0 -> 36482 bytes
-rw-r--r--doc/user/project/members/share_project_with_groups.md19
-rw-r--r--doc/user/project/pipelines/job_artifacts.md17
-rw-r--r--features/project/commits/user_lookup.feature16
-rw-r--r--features/steps/project/commits/user_lookup.rb49
-rw-r--r--lib/api/branches.rb2
-rw-r--r--lib/api/entities.rb44
-rw-r--r--lib/api/helpers.rb6
-rw-r--r--lib/api/issues.rb28
-rw-r--r--lib/api/merge_requests.rb2
-rw-r--r--lib/api/tags.rb2
-rw-r--r--lib/gitlab/database.rb8
-rw-r--r--lib/gitlab/database/grant.rb34
-rw-r--r--lib/gitlab/database/migration_helpers.rb36
-rw-r--r--lib/gitlab/git/repository.rb25
-rw-r--r--lib/tasks/gitlab/cleanup.rake2
-rw-r--r--spec/features/projects/commits/rss_spec.rb (renamed from spec/features/projects/commit/rss_spec.rb)0
-rw-r--r--spec/features/projects/commits/user_browses_commits_spec.rb44
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/issues.json8
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/merge_requests.json8
-rw-r--r--spec/javascripts/issue_show/components/edited_spec.js10
-rw-r--r--spec/lib/gitlab/database/grant_spec.rb30
-rw-r--r--spec/lib/gitlab/database/migration_helpers_spec.rb32
-rw-r--r--spec/lib/gitlab/git/repository_spec.rb34
-rw-r--r--spec/models/project_spec.rb13
-rw-r--r--spec/requests/api/issues_spec.rb12
-rw-r--r--spec/services/projects/after_import_service_spec.rb55
-rw-r--r--spec/services/projects/housekeeping_service_spec.rb13
51 files changed, 675 insertions, 163 deletions
diff --git a/app/assets/javascripts/issue_show/components/edited.vue b/app/assets/javascripts/issue_show/components/edited.vue
index d59e6d11032..992b7064c13 100644
--- a/app/assets/javascripts/issue_show/components/edited.vue
+++ b/app/assets/javascripts/issue_show/components/edited.vue
@@ -37,7 +37,7 @@ export default {
Edited
<time-ago-tooltip
v-if="updatedAt"
- placement="bottom"
+ tooltip-placement="bottom"
:time="updatedAt"
/>
<span
diff --git a/app/assets/javascripts/new_sidebar.js b/app/assets/javascripts/new_sidebar.js
index b18d12b48b5..05e3f33f5ed 100644
--- a/app/assets/javascripts/new_sidebar.js
+++ b/app/assets/javascripts/new_sidebar.js
@@ -15,6 +15,7 @@ export default class NewNavSidebar {
this.$openSidebar = $('.toggle-mobile-nav');
this.$closeSidebar = $('.close-nav-button');
this.$sidebarToggle = $('.js-toggle-sidebar');
+ this.$topLevelLinks = $('.sidebar-top-level-items > li > a');
}
bindEvents() {
@@ -50,6 +51,10 @@ export default class NewNavSidebar {
this.$page.toggleClass('page-with-icon-sidebar', breakpoint === 'sm' ? true : collapsed);
}
NewNavSidebar.setCollapsedCookie(collapsed);
+
+ this.$topLevelLinks.attr('title', function updateTopLevelTitle() {
+ return collapsed ? this.getAttribute('aria-label') : '';
+ });
}
render() {
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 4cf2f46c6d3..1024295bbd6 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -189,7 +189,7 @@
width: auto;
top: 100%;
left: 0;
- z-index: 9;
+ z-index: 200;
min-width: 240px;
max-width: 500px;
margin-top: 2px;
diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb
index 54f78fc8719..59be955599d 100644
--- a/app/controllers/autocomplete_controller.rb
+++ b/app/controllers/autocomplete_controller.rb
@@ -55,7 +55,7 @@ class AutocompleteController < ApplicationController
.limit(AWARD_EMOJI_MAX)
.where(user: current_user)
.group(:name)
- .order(count: :desc, name: :asc)
+ .order('count_all DESC, name ASC')
.count
# Transform from hash to array to guarantee json order
diff --git a/app/helpers/tab_helper.rb b/app/helpers/tab_helper.rb
index ee701076a14..3308ab0c259 100644
--- a/app/helpers/tab_helper.rb
+++ b/app/helpers/tab_helper.rb
@@ -119,4 +119,8 @@ module TabHelper
'active' if current_controller?('oauth/applications')
end
+
+ def sidebar_link(href, title: nil, css: nil, &block)
+ link_to capture(&block), href, title: (title if collapsed_sidebar?), class: css, aria: { label: title }
+ end
end
diff --git a/app/models/environment.rb b/app/models/environment.rb
index e9ebf0637f3..435eeaf0e2e 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -114,7 +114,7 @@ class Environment < ActiveRecord::Base
end
def ref_path
- "refs/environments/#{Shellwords.shellescape(name)}"
+ "refs/#{Repository::REF_ENVIRONMENTS}/#{Shellwords.shellescape(name)}"
end
def formatted_external_url
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 7f73de67625..5be2f6d4e82 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -803,7 +803,7 @@ class MergeRequest < ActiveRecord::Base
end
def ref_path
- "refs/merge-requests/#{iid}/head"
+ "refs/#{Repository::REF_MERGE_REQUEST}/#{iid}/head"
end
def ref_fetched?
diff --git a/app/models/project.rb b/app/models/project.rb
index d5324ceac31..9d7bea4eb66 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -372,11 +372,7 @@ class Project < ActiveRecord::Base
if Gitlab::ImportSources.importer_names.include?(project.import_type) && project.repo_exists?
project.run_after_commit do
- begin
- Projects::HousekeepingService.new(project).execute
- rescue Projects::HousekeepingService::LeaseTaken => e
- Rails.logger.info("Could not perform housekeeping for project #{project.full_path} (#{project.id}): #{e}")
- end
+ Projects::AfterImportService.new(project).execute
end
end
end
diff --git a/app/models/repository.rb b/app/models/repository.rb
index 090e3c896bd..d29d2a83708 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -1,6 +1,18 @@
require 'securerandom'
class Repository
+ REF_MERGE_REQUEST = 'merge-requests'.freeze
+ REF_KEEP_AROUND = 'keep-around'.freeze
+ REF_ENVIRONMENTS = 'environments'.freeze
+
+ RESERVED_REFS_NAMES = %W[
+ heads
+ tags
+ #{REF_ENVIRONMENTS}
+ #{REF_KEEP_AROUND}
+ #{REF_ENVIRONMENTS}
+ ].freeze
+
include Gitlab::ShellAdapter
include RepositoryMirroring
@@ -242,10 +254,10 @@ class Repository
begin
write_ref(keep_around_ref_name(sha), sha)
rescue Rugged::ReferenceError => ex
- Rails.logger.error "Unable to create keep-around reference for repository #{path}: #{ex}"
+ Rails.logger.error "Unable to create #{REF_KEEP_AROUND} reference for repository #{path}: #{ex}"
rescue Rugged::OSError => ex
raise unless ex.message =~ /Failed to create locked file/ && ex.message =~ /File exists/
- Rails.logger.error "Unable to create keep-around reference for repository #{path}: #{ex}"
+ Rails.logger.error "Unable to create #{REF_KEEP_AROUND} reference for repository #{path}: #{ex}"
end
end
@@ -1164,7 +1176,7 @@ class Repository
end
def keep_around_ref_name(sha)
- "refs/keep-around/#{sha}"
+ "refs/#{REF_KEEP_AROUND}/#{sha}"
end
def repository_event(event, tags = {})
@@ -1234,6 +1246,6 @@ class Repository
yield commit(sha)
ensure
- rugged.references.delete(tmp_ref) if tmp_ref
+ delete_refs(tmp_ref) if tmp_ref
end
end
diff --git a/app/services/projects/after_import_service.rb b/app/services/projects/after_import_service.rb
new file mode 100644
index 00000000000..e6a68d983ef
--- /dev/null
+++ b/app/services/projects/after_import_service.rb
@@ -0,0 +1,29 @@
+module Projects
+ class AfterImportService
+ RESERVED_REFS_REGEXP =
+ %r{\Arefs/(?:#{Regexp.union(*Repository::RESERVED_REFS_NAMES)})/}
+
+ def initialize(project)
+ @project = project
+ end
+
+ def execute
+ Projects::HousekeepingService.new(@project).execute do
+ repository.delete_refs(*garbage_refs)
+ end
+ rescue Projects::HousekeepingService::LeaseTaken => e
+ Rails.logger.info(
+ "Could not perform housekeeping for project #{@project.full_path} (#{@project.id}): #{e}")
+ end
+
+ private
+
+ def garbage_refs
+ @garbage_refs ||= repository.all_ref_names_except(RESERVED_REFS_REGEXP)
+ end
+
+ def repository
+ @repository ||= @project.repository
+ end
+ end
+end
diff --git a/app/services/projects/housekeeping_service.rb b/app/services/projects/housekeeping_service.rb
index d66ef676088..dcef8b66215 100644
--- a/app/services/projects/housekeeping_service.rb
+++ b/app/services/projects/housekeeping_service.rb
@@ -26,6 +26,8 @@ module Projects
lease_uuid = try_obtain_lease
raise LeaseTaken unless lease_uuid.present?
+ yield if block_given?
+
execute_gitlab_shell_gc(lease_uuid)
end
diff --git a/app/views/layouts/nav/_new_admin_sidebar.html.haml b/app/views/layouts/nav/_new_admin_sidebar.html.haml
index 9294529f496..3b53117deb6 100644
--- a/app/views/layouts/nav/_new_admin_sidebar.html.haml
+++ b/app/views/layouts/nav/_new_admin_sidebar.html.haml
@@ -7,7 +7,7 @@
.sidebar-context-title Admin Area
%ul.sidebar-top-level-items
= nav_link(controller: %w(dashboard admin projects users groups jobs runners cohorts), html_options: {class: 'home'}) do
- = link_to admin_root_path, title: 'Overview', class: 'shortcuts-tree' do
+ = sidebar_link admin_root_path, title: _('Overview'), css: 'shortcuts-tree' do
.nav-icon-container
= custom_icon('overview')
%span.nav-item-name
@@ -48,7 +48,7 @@
ConvDev Index
= nav_link(controller: %w(conversational_development_index system_info background_jobs logs health_check requests_profiles)) do
- = link_to admin_conversational_development_index_path, title: 'Monitoring' do
+ = sidebar_link admin_conversational_development_index_path, title: _('Monitoring') do
.nav-icon-container
= custom_icon('monitoring')
%span.nav-item-name
@@ -77,28 +77,28 @@
Requests Profiles
= nav_link(controller: :broadcast_messages) do
- = link_to admin_broadcast_messages_path, title: 'Messages' do
+ = sidebar_link admin_broadcast_messages_path, title: _('Messages') do
.nav-icon-container
= custom_icon('messages')
%span.nav-item-name
Messages
= nav_link(controller: [:hooks, :hook_logs]) do
- = link_to admin_hooks_path, title: 'Hooks' do
+ = sidebar_link admin_hooks_path, title: _('Hooks') do
.nav-icon-container
= custom_icon('system_hooks')
%span.nav-item-name
System Hooks
= nav_link(controller: :applications) do
- = link_to admin_applications_path, title: 'Applications' do
+ = sidebar_link admin_applications_path, title: _('Applications') do
.nav-icon-container
= custom_icon('applications')
%span.nav-item-name
Applications
= nav_link(controller: :abuse_reports) do
- = link_to admin_abuse_reports_path, title: "Abuse Reports" do
+ = sidebar_link admin_abuse_reports_path, title: _("Abuse Reports") do
.nav-icon-container
= custom_icon('abuse_reports')
%span.nav-item-name
@@ -107,42 +107,42 @@
- if akismet_enabled?
= nav_link(controller: :spam_logs) do
- = link_to admin_spam_logs_path, title: "Spam Logs" do
+ = sidebar_link admin_spam_logs_path, title: _("Spam Logs") do
.nav-icon-container
= custom_icon('spam_logs')
%span.nav-item-name
Spam Logs
= nav_link(controller: :deploy_keys) do
- = link_to admin_deploy_keys_path, title: 'Deploy Keys' do
+ = sidebar_link admin_deploy_keys_path, title: _('Deploy Keys') do
.nav-icon-container
= custom_icon('key')
%span.nav-item-name
Deploy Keys
= nav_link(controller: :services) do
- = link_to admin_application_settings_services_path, title: 'Service Templates' do
+ = sidebar_link admin_application_settings_services_path, title: _('Service Templates') do
.nav-icon-container
= custom_icon('service_templates')
%span.nav-item-name
Service Templates
= nav_link(controller: :labels) do
- = link_to admin_labels_path, title: 'Labels' do
+ = sidebar_link admin_labels_path, title: _('Labels') do
.nav-icon-container
= custom_icon('labels')
%span.nav-item-name
Labels
= nav_link(controller: :appearances) do
- = link_to admin_appearances_path, title: 'Appearances' do
+ = sidebar_link admin_appearances_path, title: _('Appearances') do
.nav-icon-container
= custom_icon('appearance')
%span.nav-item-name
Appearance
= nav_link(controller: :application_settings) do
- = link_to admin_application_settings_path, title: 'Settings' do
+ = sidebar_link admin_application_settings_path, title: _('Settings') do
.nav-icon-container
= custom_icon('settings')
%span.nav-item-name
diff --git a/app/views/layouts/nav/_new_group_sidebar.html.haml b/app/views/layouts/nav/_new_group_sidebar.html.haml
index d90aea2e361..5a1511b262f 100644
--- a/app/views/layouts/nav/_new_group_sidebar.html.haml
+++ b/app/views/layouts/nav/_new_group_sidebar.html.haml
@@ -8,7 +8,7 @@
= @group.name
%ul.sidebar-top-level-items
= nav_link(path: ['groups#show', 'groups#activity', 'groups#subgroups'], html_options: { class: 'home' }) do
- = link_to group_path(@group), title: 'Group overview' do
+ = sidebar_link group_path(@group), title: _('Group overview') do
.nav-icon-container
= custom_icon('project')
%span.nav-item-name
@@ -26,7 +26,7 @@
Activity
= nav_link(path: ['groups#issues', 'labels#index', 'milestones#index']) do
- = link_to issues_group_path(@group), title: 'Issues' do
+ = sidebar_link issues_group_path(@group), title: _('Issues') do
.nav-icon-container
= custom_icon('issues')
%span.nav-item-name
@@ -51,7 +51,7 @@
Milestones
= nav_link(path: 'groups#merge_requests') do
- = link_to merge_requests_group_path(@group), title: 'Merge Requests' do
+ = sidebar_link merge_requests_group_path(@group), title: _('Merge Requests') do
.nav-icon-container
= custom_icon('mr_bold')
%span.nav-item-name
@@ -59,14 +59,14 @@
Merge Requests
%span.badge.count= number_with_delimiter(merge_requests.count)
= nav_link(path: 'group_members#index') do
- = link_to group_group_members_path(@group), title: 'Members' do
+ = sidebar_link group_group_members_path(@group), title: _('Members') do
.nav-icon-container
= custom_icon('members')
%span.nav-item-name
Members
- if current_user && can?(current_user, :admin_group, @group)
= nav_link(path: %w[groups#projects groups#edit ci_cd#show]) do
- = link_to edit_group_path(@group), title: 'Settings' do
+ = sidebar_link edit_group_path(@group), title: _('Settings') do
.nav-icon-container
= custom_icon('settings')
%span.nav-item-name
diff --git a/app/views/layouts/nav/_new_profile_sidebar.html.haml b/app/views/layouts/nav/_new_profile_sidebar.html.haml
index 85b2c7630c8..ccb6d1492f1 100644
--- a/app/views/layouts/nav/_new_profile_sidebar.html.haml
+++ b/app/views/layouts/nav/_new_profile_sidebar.html.haml
@@ -7,76 +7,76 @@
.sidebar-context-title User Settings
%ul.sidebar-top-level-items
= nav_link(path: 'profiles#show', html_options: {class: 'home'}) do
- = link_to profile_path, title: 'Profile Settings' do
+ = sidebar_link profile_path, title: _('Profile Settings') do
.nav-icon-container
= custom_icon('profile')
%span.nav-item-name
Profile
= nav_link(controller: [:accounts, :two_factor_auths]) do
- = link_to profile_account_path, title: 'Account' do
+ = sidebar_link profile_account_path, title: _('Account') do
.nav-icon-container
= custom_icon('account')
%span.nav-item-name
Account
- if current_application_settings.user_oauth_applications?
= nav_link(controller: 'oauth/applications') do
- = link_to applications_profile_path, title: 'Applications' do
+ = sidebar_link applications_profile_path, title: _('Applications') do
.nav-icon-container
= custom_icon('applications')
%span.nav-item-name
Applications
= nav_link(controller: :chat_names) do
- = link_to profile_chat_names_path, title: 'Chat' do
+ = sidebar_link profile_chat_names_path, title: _('Chat') do
.nav-icon-container
= custom_icon('chat')
%span.nav-item-name
Chat
= nav_link(controller: :personal_access_tokens) do
- = link_to profile_personal_access_tokens_path, title: 'Access Tokens' do
+ = sidebar_link profile_personal_access_tokens_path, title: _('Access Tokens') do
.nav-icon-container
= custom_icon('access_tokens')
%span.nav-item-name
Access Tokens
= nav_link(controller: :emails) do
- = link_to profile_emails_path, title: 'Emails' do
+ = sidebar_link profile_emails_path, title: _('Emails') do
.nav-icon-container
= custom_icon('emails')
%span.nav-item-name
Emails
- unless current_user.ldap_user?
= nav_link(controller: :passwords) do
- = link_to edit_profile_password_path, title: 'Password' do
+ = sidebar_link edit_profile_password_path, title: _('Password') do
.nav-icon-container
= custom_icon('lock')
%span.nav-item-name
Password
= nav_link(controller: :notifications) do
- = link_to profile_notifications_path, title: 'Notifications' do
+ = sidebar_link profile_notifications_path, title: _('Notifications') do
.nav-icon-container
= custom_icon('notifications')
%span.nav-item-name
Notifications
= nav_link(controller: :keys) do
- = link_to profile_keys_path, title: 'SSH Keys' do
+ = sidebar_link profile_keys_path, title: _('SSH Keys') do
.nav-icon-container
= custom_icon('key')
%span.nav-item-name
SSH Keys
= nav_link(controller: :gpg_keys) do
- = link_to profile_gpg_keys_path, title: 'GPG Keys' do
+ = sidebar_link profile_gpg_keys_path, title: _('GPG Keys') do
.nav-icon-container
= custom_icon('key_2')
%span.nav-item-name
GPG Keys
= nav_link(controller: :preferences) do
- = link_to profile_preferences_path, title: 'Preferences' do
+ = sidebar_link profile_preferences_path, title: _('Preferences') do
.nav-icon-container
= custom_icon('preferences')
%span.nav-item-name
Preferences
= nav_link(path: 'profiles#audit_log') do
- = link_to audit_log_profile_path, title: 'Authentication log' do
+ = sidebar_link audit_log_profile_path, title: _('Authentication log') do
.nav-icon-container
= custom_icon('authentication_log')
%span.nav-item-name
diff --git a/app/views/layouts/nav/_new_project_sidebar.html.haml b/app/views/layouts/nav/_new_project_sidebar.html.haml
index 341943cf833..53dbf9e2f2b 100644
--- a/app/views/layouts/nav/_new_project_sidebar.html.haml
+++ b/app/views/layouts/nav/_new_project_sidebar.html.haml
@@ -9,7 +9,7 @@
= @project.name
%ul.sidebar-top-level-items
= nav_link(path: ['projects#show', 'projects#activity', 'cycle_analytics#show'], html_options: { class: 'home' }) do
- = link_to project_path(@project), title: 'Project overview', class: 'shortcuts-project' do
+ = sidebar_link project_path(@project), title: _('Project overview'), css: 'shortcuts-project' do
.nav-icon-container
= custom_icon('project')
%span.nav-item-name
@@ -31,7 +31,7 @@
- if project_nav_tab? :files
= nav_link(controller: %w(tree blob blame edit_tree new_tree find_file commit commits compare projects/repositories tags branches releases graphs network)) do
- = link_to project_tree_path(@project), title: 'Repository', class: 'shortcuts-tree' do
+ = sidebar_link project_tree_path(@project), title: _('Repository'), css: 'shortcuts-tree' do
.nav-icon-container
= custom_icon('doc_text')
%span.nav-item-name
@@ -72,7 +72,7 @@
- if project_nav_tab? :container_registry
= nav_link(controller: %w[projects/registry/repositories]) do
- = link_to project_container_registry_index_path(@project), title: 'Container Registry', class: 'shortcuts-container-registry' do
+ = sidebar_link project_container_registry_index_path(@project), title: _('Container Registry'), css: 'shortcuts-container-registry' do
.nav-icon-container
= custom_icon('container_registry')
%span.nav-item-name
@@ -80,7 +80,7 @@
- if project_nav_tab? :issues
= nav_link(controller: @project.issues_enabled? ? [:issues, :labels, :milestones, :boards] : :issues) do
- = link_to project_issues_path(@project), title: 'Issues', class: 'shortcuts-issues' do
+ = sidebar_link project_issues_path(@project), title: _('Issues'), css: 'shortcuts-issues' do
.nav-icon-container
= custom_icon('issues')
%span.nav-item-name
@@ -112,7 +112,7 @@
- if project_nav_tab? :merge_requests
= nav_link(controller: @project.issues_enabled? ? :merge_requests : [:merge_requests, :labels, :milestones]) do
- = link_to project_merge_requests_path(@project), title: 'Merge Requests', class: 'shortcuts-merge_requests' do
+ = sidebar_link project_merge_requests_path(@project), title: _('Merge Requests'), css: 'shortcuts-merge_requests' do
.nav-icon-container
= custom_icon('mr_bold')
%span.nav-item-name
@@ -122,7 +122,7 @@
- if project_nav_tab? :pipelines
= nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :environments, :artifacts]) do
- = link_to project_pipelines_path(@project), title: 'CI / CD', class: 'shortcuts-pipelines' do
+ = sidebar_link project_pipelines_path(@project), title: _('CI / CD'), css: 'shortcuts-pipelines' do
.nav-icon-container
= custom_icon('pipeline')
%span.nav-item-name
@@ -161,7 +161,7 @@
- if project_nav_tab? :wiki
= nav_link(controller: :wikis) do
- = link_to get_project_wiki_path(@project), title: 'Wiki', class: 'shortcuts-wiki' do
+ = sidebar_link get_project_wiki_path(@project), title: _('Wiki'), css: 'shortcuts-wiki' do
.nav-icon-container
= custom_icon('wiki')
%span.nav-item-name
@@ -169,7 +169,7 @@
- if project_nav_tab? :snippets
= nav_link(controller: :snippets) do
- = link_to project_snippets_path(@project), title: 'Snippets', class: 'shortcuts-snippets' do
+ = sidebar_link project_snippets_path(@project), title: _('Snippets'), css: 'shortcuts-snippets' do
.nav-icon-container
= custom_icon('snippets')
%span.nav-item-name
@@ -177,7 +177,7 @@
- if project_nav_tab? :settings
= nav_link(path: %w[projects#edit project_members#index integrations#show services#edit repository#show ci_cd#show pages#show]) do
- = link_to edit_project_path(@project), title: 'Settings', class: 'shortcuts-tree' do
+ = sidebar_link edit_project_path(@project), title: _('Settings'), css: 'shortcuts-tree' do
.nav-icon-container
= custom_icon('settings')
%span.nav-item-name
diff --git a/changelogs/unreleased/28453-add-time-estimate-time-spent-to-api-issue-output.yml b/changelogs/unreleased/28453-add-time-estimate-time-spent-to-api-issue-output.yml
new file mode 100644
index 00000000000..129cf505a3f
--- /dev/null
+++ b/changelogs/unreleased/28453-add-time-estimate-time-spent-to-api-issue-output.yml
@@ -0,0 +1,4 @@
+---
+title: Add time stats to Issue and Merge Request API
+merge_request: 13335
+author: @travismiller
diff --git a/changelogs/unreleased/36807-gc-unwanted-refs-after-import.yml b/changelogs/unreleased/36807-gc-unwanted-refs-after-import.yml
new file mode 100644
index 00000000000..a37de4325bb
--- /dev/null
+++ b/changelogs/unreleased/36807-gc-unwanted-refs-after-import.yml
@@ -0,0 +1,5 @@
+---
+title: Remove unwanted refs after importing a project
+merge_request: 13766
+author:
+type: other
diff --git a/changelogs/unreleased/check-trigger-permissions.yml b/changelogs/unreleased/check-trigger-permissions.yml
new file mode 100644
index 00000000000..e0809cea9bf
--- /dev/null
+++ b/changelogs/unreleased/check-trigger-permissions.yml
@@ -0,0 +1,5 @@
+---
+title: Improve migrations using triggers
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/replace_spinach_user_lookup-feature.yml b/changelogs/unreleased/replace_spinach_user_lookup-feature.yml
new file mode 100644
index 00000000000..36248c54d99
--- /dev/null
+++ b/changelogs/unreleased/replace_spinach_user_lookup-feature.yml
@@ -0,0 +1,5 @@
+---
+title: Replace 'project/user_lookup.feature' spinach test with an rspec analog
+merge_request: 13863
+author: Vitaliy @blackst0ne Klachkov
+type: other
diff --git a/doc/api/issues.md b/doc/api/issues.md
index f30ed08d0fa..14635114a31 100644
--- a/doc/api/issues.md
+++ b/doc/api/issues.md
@@ -101,6 +101,12 @@ Example response:
"user_notes_count": 1,
"due_date": "2016-07-22",
"web_url": "http://example.com/example/example/issues/6",
+ "time_stats": {
+ "time_estimate": 0,
+ "total_time_spent": 0,
+ "human_time_estimate": null,
+ "human_total_time_spent": null
+ },
"confidential": false
}
]
@@ -198,6 +204,12 @@ Example response:
"user_notes_count": 1,
"due_date": null,
"web_url": "http://example.com/example/example/issues/1",
+ "time_stats": {
+ "time_estimate": 0,
+ "total_time_spent": 0,
+ "human_time_estimate": null,
+ "human_total_time_spent": null
+ },
"confidential": false
}
]
@@ -296,6 +308,12 @@ Example response:
"user_notes_count": 1,
"due_date": "2016-07-22",
"web_url": "http://example.com/example/example/issues/1",
+ "time_stats": {
+ "time_estimate": 0,
+ "total_time_spent": 0,
+ "human_time_estimate": null,
+ "human_total_time_spent": null
+ },
"confidential": false
}
]
@@ -372,6 +390,12 @@ Example response:
"user_notes_count": 1,
"due_date": null,
"web_url": "http://example.com/example/example/issues/1",
+ "time_stats": {
+ "time_estimate": 0,
+ "total_time_spent": 0,
+ "human_time_estimate": null,
+ "human_total_time_spent": null
+ },
"confidential": false,
"_links": {
"self": "http://example.com/api/v4/projects/1/issues/2",
@@ -440,6 +464,12 @@ Example response:
"user_notes_count": 0,
"due_date": null,
"web_url": "http://example.com/example/example/issues/14",
+ "time_stats": {
+ "time_estimate": 0,
+ "total_time_spent": 0,
+ "human_time_estimate": null,
+ "human_total_time_spent": null
+ },
"confidential": false,
"_links": {
"self": "http://example.com/api/v4/projects/1/issues/2",
@@ -509,6 +539,12 @@ Example response:
"user_notes_count": 0,
"due_date": "2016-07-22",
"web_url": "http://example.com/example/example/issues/15",
+ "time_stats": {
+ "time_estimate": 0,
+ "total_time_spent": 0,
+ "human_time_estimate": null,
+ "human_total_time_spent": null
+ },
"confidential": false,
"_links": {
"self": "http://example.com/api/v4/projects/1/issues/2",
@@ -601,6 +637,12 @@ Example response:
},
"due_date": null,
"web_url": "http://example.com/example/example/issues/11",
+ "time_stats": {
+ "time_estimate": 0,
+ "total_time_spent": 0,
+ "human_time_estimate": null,
+ "human_total_time_spent": null
+ },
"confidential": false,
"_links": {
"self": "http://example.com/api/v4/projects/1/issues/2",
@@ -672,6 +714,12 @@ Example response:
},
"due_date": null,
"web_url": "http://example.com/example/example/issues/11",
+ "time_stats": {
+ "time_estimate": 0,
+ "total_time_spent": 0,
+ "human_time_estimate": null,
+ "human_total_time_spent": null
+ },
"confidential": false,
"_links": {
"self": "http://example.com/api/v4/projects/1/issues/2",
@@ -1001,7 +1049,13 @@ Example response:
"user_notes_count": 1,
"should_remove_source_branch": null,
"force_remove_source_branch": false,
- "web_url": "https://gitlab.example.com/gitlab-org/gitlab-test/merge_requests/6432"
+ "web_url": "https://gitlab.example.com/gitlab-org/gitlab-test/merge_requests/6432",
+ "time_stats": {
+ "time_estimate": 0,
+ "total_time_spent": 0,
+ "human_time_estimate": null,
+ "human_total_time_spent": null
+ }
}
]
```
diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md
index 802e5362d70..4f67aa4b9d4 100644
--- a/doc/api/merge_requests.md
+++ b/doc/api/merge_requests.md
@@ -92,7 +92,13 @@ Parameters:
"user_notes_count": 1,
"should_remove_source_branch": true,
"force_remove_source_branch": false,
- "web_url": "http://example.com/example/example/merge_requests/1"
+ "web_url": "http://example.com/example/example/merge_requests/1",
+ "time_stats": {
+ "time_estimate": 0,
+ "total_time_spent": 0,
+ "human_time_estimate": null,
+ "human_total_time_spent": null
+ }
}
]
```
@@ -181,7 +187,13 @@ Parameters:
"user_notes_count": 1,
"should_remove_source_branch": true,
"force_remove_source_branch": false,
- "web_url": "http://example.com/example/example/merge_requests/1"
+ "web_url": "http://example.com/example/example/merge_requests/1",
+ "time_stats": {
+ "time_estimate": 0,
+ "total_time_spent": 0,
+ "human_time_estimate": null,
+ "human_total_time_spent": null
+ }
}
]
```
@@ -250,7 +262,13 @@ Parameters:
"user_notes_count": 1,
"should_remove_source_branch": true,
"force_remove_source_branch": false,
- "web_url": "http://example.com/example/example/merge_requests/1"
+ "web_url": "http://example.com/example/example/merge_requests/1",
+ "time_stats": {
+ "time_estimate": 0,
+ "total_time_spent": 0,
+ "human_time_estimate": null,
+ "human_total_time_spent": null
+ }
}
```
@@ -356,6 +374,12 @@ Parameters:
"should_remove_source_branch": true,
"force_remove_source_branch": false,
"web_url": "http://example.com/example/example/merge_requests/1",
+ "time_stats": {
+ "time_estimate": 0,
+ "total_time_spent": 0,
+ "human_time_estimate": null,
+ "human_total_time_spent": null
+ }
"changes": [
{
"old_path": "VERSION",
@@ -442,7 +466,13 @@ POST /projects/:id/merge_requests
"user_notes_count": 0,
"should_remove_source_branch": true,
"force_remove_source_branch": false,
- "web_url": "http://example.com/example/example/merge_requests/1"
+ "web_url": "http://example.com/example/example/merge_requests/1",
+ "time_stats": {
+ "time_estimate": 0,
+ "total_time_spent": 0,
+ "human_time_estimate": null,
+ "human_total_time_spent": null
+ }
}
```
@@ -519,7 +549,13 @@ Must include at least one non-required attribute from above.
"user_notes_count": 1,
"should_remove_source_branch": true,
"force_remove_source_branch": false,
- "web_url": "http://example.com/example/example/merge_requests/1"
+ "web_url": "http://example.com/example/example/merge_requests/1",
+ "time_stats": {
+ "time_estimate": 0,
+ "total_time_spent": 0,
+ "human_time_estimate": null,
+ "human_total_time_spent": null
+ }
}
```
@@ -617,7 +653,13 @@ Parameters:
"user_notes_count": 1,
"should_remove_source_branch": true,
"force_remove_source_branch": false,
- "web_url": "http://example.com/example/example/merge_requests/1"
+ "web_url": "http://example.com/example/example/merge_requests/1",
+ "time_stats": {
+ "time_estimate": 0,
+ "total_time_spent": 0,
+ "human_time_estimate": null,
+ "human_total_time_spent": null
+ }
}
```
@@ -687,7 +729,13 @@ Parameters:
"user_notes_count": 1,
"should_remove_source_branch": true,
"force_remove_source_branch": false,
- "web_url": "http://example.com/example/example/merge_requests/1"
+ "web_url": "http://example.com/example/example/merge_requests/1",
+ "time_stats": {
+ "time_estimate": 0,
+ "total_time_spent": 0,
+ "human_time_estimate": null,
+ "human_total_time_spent": null
+ }
}
```
diff --git a/doc/user/project/members/img/other_group_sees_shared_project.png b/doc/user/project/members/img/other_group_sees_shared_project.png
index 67af27043eb..e4c93a13abb 100644
--- a/doc/user/project/members/img/other_group_sees_shared_project.png
+++ b/doc/user/project/members/img/other_group_sees_shared_project.png
Binary files differ
diff --git a/doc/user/project/members/img/share_project_with_groups.png b/doc/user/project/members/img/share_project_with_groups.png
index 3cb4796f9f7..0907438cb84 100644
--- a/doc/user/project/members/img/share_project_with_groups.png
+++ b/doc/user/project/members/img/share_project_with_groups.png
Binary files differ
diff --git a/doc/user/project/members/img/share_project_with_groups_tab.png b/doc/user/project/members/img/share_project_with_groups_tab.png
new file mode 100644
index 00000000000..fc489aae003
--- /dev/null
+++ b/doc/user/project/members/img/share_project_with_groups_tab.png
Binary files differ
diff --git a/doc/user/project/members/share_project_with_groups.md b/doc/user/project/members/share_project_with_groups.md
index 4c1ddcdcba8..25e5b897825 100644
--- a/doc/user/project/members/share_project_with_groups.md
+++ b/doc/user/project/members/share_project_with_groups.md
@@ -5,7 +5,7 @@ possible to add a group of users to a project with a single action.
## Groups as collections of users
-Groups are used primarily to [create collections of projects](../user/group/index.md), but you can also
+Groups are used primarily to [create collections of projects](../../group/index.md), but you can also
take advantage of the fact that groups define collections of _users_, namely the group
members.
@@ -16,20 +16,23 @@ say 'Project Acme', in GitLab is to make the 'Engineering' group the owner of 'P
Acme'. But what if 'Project Acme' already belongs to another group, say 'Open Source'?
This is where the group sharing feature can be of use.
-To share 'Project Acme' with the 'Engineering' group, go to the project settings page for 'Project Acme' and use the left navigation menu to go to the 'Groups' section.
+To share 'Project Acme' with the 'Engineering' group, go to the project settings page for 'Project Acme' and use the left navigation menu to go to the **Settings > Members** section.
-![The 'Groups' section in the project settings screen](img/share_project_with_groups.png)
+![share project with groups](img/share_project_with_groups.png)
-Now you can add the 'Engineering' group with the maximum access level of your choice.
-After sharing 'Project Acme' with 'Engineering', the project is listed on the group dashboard.
+Then select the 'Share with group' tab by clicking it.
+
+Now you can add the 'Engineering' group with the maximum access level of your choice. Click 'Share' to share it.
+
+![share project with groups tab](img/share_project_with_groups_tab.png)
+
+After sharing 'Project Acme' with 'Engineering', the project will be listed on the group dashboard.
!['Project Acme' is listed as a shared project for 'Engineering'](img/other_group_sees_shared_project.png)
## Maximum access level
-!['Project Acme' is shared with 'Engineering' with a maximum access level of 'Developer'](img/max_access_level.png)
-
-In the screenshot above, the maximum access level of 'Developer' for members from 'Engineering' means that users with higher access levels in 'Engineering' ('Master' or 'Owner') will only have 'Developer' access to 'Project Acme'.
+In the example above, the maximum access level of 'Developer' for members from 'Engineering' means that users with higher access levels in 'Engineering' ('Master' or 'Owner') will only have 'Developer' access to 'Project Acme'.
## Share project with group lock (EES/EEP)
diff --git a/doc/user/project/pipelines/job_artifacts.md b/doc/user/project/pipelines/job_artifacts.md
index e853bfff444..4e93e680fd2 100644
--- a/doc/user/project/pipelines/job_artifacts.md
+++ b/doc/user/project/pipelines/job_artifacts.md
@@ -100,16 +100,21 @@ inside GitLab that make that possible.
It is possible to download the latest artifacts of a job via a well known URL
so you can use it for scripting purposes.
+>**Note:**
+The latest artifacts are considered as the artifacts created by jobs in the
+latest pipeline that succeeded for the specific ref.
+Artifacts for other pipelines can be accessed with direct access to them.
+
The structure of the URL to download the whole artifacts archive is the following:
```
-https://example.com/<namespace>/<project>/builds/artifacts/<ref>/download?job=<job_name>
+https://example.com/<namespace>/<project>/-/jobs/artifacts/<ref>/download?job=<job_name>
```
To download a single file from the artifacts use the following URL:
```
-https://example.com/<namespace>/<project>/builds/artifacts/<ref>/raw/<path_to_file>?job=<job_name>
+https://example.com/<namespace>/<project>/-/jobs/artifacts/<ref>/raw/<path_to_file>?job=<job_name>
```
For example, to download the latest artifacts of the job named `coverage` of
@@ -117,26 +122,26 @@ the `master` branch of the `gitlab-ce` project that belongs to the `gitlab-org`
namespace, the URL would be:
```
-https://gitlab.com/gitlab-org/gitlab-ce/builds/artifacts/master/download?job=coverage
+https://gitlab.com/gitlab-org/gitlab-ce/-/jobs/artifacts/master/download?job=coverage
```
To download the file `coverage/index.html` from the same
artifacts use the following URL:
```
-https://gitlab.com/gitlab-org/gitlab-ce/builds/artifacts/master/raw/coverage/index.html?job=coverage
+https://gitlab.com/gitlab-org/gitlab-ce/-/jobs/artifacts/master/raw/coverage/index.html?job=coverage
```
There is also a URL to browse the latest job artifacts:
```
-https://example.com/<namespace>/<project>/builds/artifacts/<ref>/browse?job=<job_name>
+https://example.com/<namespace>/<project>/-/jobs/artifacts/<ref>/browse?job=<job_name>
```
For example:
```
-https://gitlab.com/gitlab-org/gitlab-ce/builds/artifacts/master/browse?job=coverage
+https://gitlab.com/gitlab-org/gitlab-ce/-/jobs/artifacts/master/browse?job=coverage
```
The latest builds are also exposed in the UI in various places. Specifically,
diff --git a/features/project/commits/user_lookup.feature b/features/project/commits/user_lookup.feature
deleted file mode 100644
index c18f4e070f3..00000000000
--- a/features/project/commits/user_lookup.feature
+++ /dev/null
@@ -1,16 +0,0 @@
-@project_commits
-Feature: Project Commits User Lookup
- Background:
- Given I sign in as a user
- And I own a project
- And I visit my project's commits page
-
- Scenario: I browse commit from list
- Given I have user with primary email
- When I click on commit link
- Then I see author based on primary email
-
- Scenario: I browse another commit from list
- Given I have user with secondary email
- When I click on another commit link
- Then I see author based on secondary email
diff --git a/features/steps/project/commits/user_lookup.rb b/features/steps/project/commits/user_lookup.rb
deleted file mode 100644
index 4599e0d032a..00000000000
--- a/features/steps/project/commits/user_lookup.rb
+++ /dev/null
@@ -1,49 +0,0 @@
-class Spinach::Features::ProjectCommitsUserLookup < Spinach::FeatureSteps
- include SharedAuthentication
- include SharedProject
- include SharedPaths
-
- step 'I click on commit link' do
- visit project_commit_path(@project, sample_commit.id)
- end
-
- step 'I click on another commit link' do
- visit project_commit_path(@project, sample_commit.parent_id)
- end
-
- step 'I have user with primary email' do
- user_primary
- end
-
- step 'I have user with secondary email' do
- user_secondary
- end
-
- step 'I see author based on primary email' do
- check_author_link(sample_commit.author_email, user_primary)
- end
-
- step 'I see author based on secondary email' do
- check_author_link(sample_commit.author_email, user_secondary)
- end
-
- def check_author_link(email, user)
- author_link = find('.commit-author-link')
-
- expect(author_link['href']).to eq user_path(user)
- expect(author_link['title']).to eq email
- expect(find('.commit-author-name').text).to eq user.name
- end
-
- def user_primary
- @user_primary ||= create(:user, email: 'dmitriy.zaporozhets@gmail.com')
- end
-
- def user_secondary
- @user_secondary ||= begin
- user = create(:user, email: 'dzaporozhets@example.com')
- create(:email, { user: user, email: 'dmitriy.zaporozhets@gmail.com' })
- user
- end
- end
-end
diff --git a/lib/api/branches.rb b/lib/api/branches.rb
index b87f7cdbad1..a989394ad91 100644
--- a/lib/api/branches.rb
+++ b/lib/api/branches.rb
@@ -130,7 +130,7 @@ module API
commit = user_project.repository.commit(branch.dereferenced_target)
- destroy_conditionally!(commit, last_update_field: :authored_date) do
+ destroy_conditionally!(commit, last_updated: commit.authored_date) do
result = DeleteBranchService.new(user_project, current_user)
.execute(params[:branch])
diff --git a/lib/api/entities.rb b/lib/api/entities.rb
index e8dd61e493f..803b48dd88a 100644
--- a/lib/api/entities.rb
+++ b/lib/api/entities.rb
@@ -320,7 +320,10 @@ module API
end
class IssueBasic < ProjectEntity
- expose :label_names, as: :labels
+ expose :labels do |issue, options|
+ # Avoids an N+1 query since labels are preloaded
+ issue.labels.map(&:title).sort
+ end
expose :milestone, using: Entities::Milestone
expose :assignees, :author, using: Entities::UserBasic
@@ -329,13 +332,32 @@ module API
end
expose :user_notes_count
- expose :upvotes, :downvotes
+ expose :upvotes do |issue, options|
+ if options[:issuable_metadata]
+ # Avoids an N+1 query when metadata is included
+ options[:issuable_metadata][issue.id].upvotes
+ else
+ issue.upvotes
+ end
+ end
+ expose :downvotes do |issue, options|
+ if options[:issuable_metadata]
+ # Avoids an N+1 query when metadata is included
+ options[:issuable_metadata][issue.id].downvotes
+ else
+ issue.downvotes
+ end
+ end
expose :due_date
expose :confidential
expose :web_url do |issue, options|
Gitlab::UrlBuilder.build(issue)
end
+
+ expose :time_stats, using: 'API::Entities::IssuableTimeStats' do |issue|
+ issue
+ end
end
class Issue < IssueBasic
@@ -365,10 +387,22 @@ module API
end
class IssuableTimeStats < Grape::Entity
+ format_with(:time_tracking_formatter) do |time_spent|
+ Gitlab::TimeTrackingFormatter.output(time_spent)
+ end
+
expose :time_estimate
expose :total_time_spent
expose :human_time_estimate
- expose :human_total_time_spent
+
+ with_options(format_with: :time_tracking_formatter) do
+ expose :total_time_spent, as: :human_total_time_spent
+ end
+
+ def total_time_spent
+ # Avoids an N+1 query since timelogs are preloaded
+ object.timelogs.map(&:time_spent).sum
+ end
end
class ExternalIssue < Grape::Entity
@@ -418,6 +452,10 @@ module API
expose :web_url do |merge_request, options|
Gitlab::UrlBuilder.build(merge_request)
end
+
+ expose :time_stats, using: 'API::Entities::IssuableTimeStats' do |merge_request|
+ merge_request
+ end
end
class MergeRequest < MergeRequestBasic
diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb
index 84980864151..3d377fdb9eb 100644
--- a/lib/api/helpers.rb
+++ b/lib/api/helpers.rb
@@ -19,8 +19,10 @@ module API
end
end
- def destroy_conditionally!(resource, last_update_field: :updated_at)
- check_unmodified_since!(resource.public_send(last_update_field))
+ def destroy_conditionally!(resource, last_updated: nil)
+ last_updated ||= resource.updated_at
+
+ check_unmodified_since!(last_updated)
status 204
if block_given?
diff --git a/lib/api/issues.rb b/lib/api/issues.rb
index 6503629e2a2..0297023226f 100644
--- a/lib/api/issues.rb
+++ b/lib/api/issues.rb
@@ -4,6 +4,8 @@ module API
before { authenticate! }
+ helpers ::Gitlab::IssuableMetadata
+
helpers do
def find_issues(args = {})
args = params.merge(args)
@@ -13,6 +15,7 @@ module API
args[:label_name] = args.delete(:labels)
issues = IssuesFinder.new(current_user, args).execute
+ .preload(:assignees, :labels, :notes, :timelogs)
issues.reorder(args[:order_by] => args[:sort])
end
@@ -65,7 +68,13 @@ module API
get do
issues = find_issues
- present paginate(issues), with: Entities::IssueBasic, current_user: current_user
+ options = {
+ with: Entities::IssueBasic,
+ current_user: current_user,
+ issuable_metadata: issuable_meta_data(issues, 'Issue')
+ }
+
+ present paginate(issues), options
end
end
@@ -86,7 +95,13 @@ module API
issues = find_issues(group_id: group.id)
- present paginate(issues), with: Entities::IssueBasic, current_user: current_user
+ options = {
+ with: Entities::IssueBasic,
+ current_user: current_user,
+ issuable_metadata: issuable_meta_data(issues, 'Issue')
+ }
+
+ present paginate(issues), options
end
end
@@ -109,7 +124,14 @@ module API
issues = find_issues(project_id: project.id)
- present paginate(issues), with: Entities::IssueBasic, current_user: current_user, project: user_project
+ options = {
+ with: Entities::IssueBasic,
+ current_user: current_user,
+ project: user_project,
+ issuable_metadata: issuable_meta_data(issues, 'Issue')
+ }
+
+ present paginate(issues), options
end
desc 'Get a single project issue' do
diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb
index 969c6064662..eec8d9357aa 100644
--- a/lib/api/merge_requests.rb
+++ b/lib/api/merge_requests.rb
@@ -21,7 +21,7 @@ module API
return merge_requests if args[:view] == 'simple'
merge_requests
- .preload(:notes, :author, :assignee, :milestone, :merge_request_diff, :labels)
+ .preload(:notes, :author, :assignee, :milestone, :merge_request_diff, :labels, :timelogs)
end
params :merge_requests_params do
diff --git a/lib/api/tags.rb b/lib/api/tags.rb
index 81b17935b81..912415e3a7f 100644
--- a/lib/api/tags.rb
+++ b/lib/api/tags.rb
@@ -70,7 +70,7 @@ module API
commit = user_project.repository.commit(tag.dereferenced_target)
- destroy_conditionally!(commit, last_update_field: :authored_date) do
+ destroy_conditionally!(commit, last_updated: commit.authored_date) do
result = ::Tags::DestroyService.new(user_project, current_user)
.execute(params[:tag_name])
diff --git a/lib/gitlab/database.rb b/lib/gitlab/database.rb
index e001d25e7b7..a6ec75da385 100644
--- a/lib/gitlab/database.rb
+++ b/lib/gitlab/database.rb
@@ -9,6 +9,14 @@ module Gitlab
ActiveRecord::Base.configurations[Rails.env]
end
+ def self.username
+ config['username'] || ENV['USER']
+ end
+
+ def self.database_name
+ config['database']
+ end
+
def self.adapter_name
config['adapter']
end
diff --git a/lib/gitlab/database/grant.rb b/lib/gitlab/database/grant.rb
new file mode 100644
index 00000000000..aee3981e79a
--- /dev/null
+++ b/lib/gitlab/database/grant.rb
@@ -0,0 +1,34 @@
+module Gitlab
+ module Database
+ # Model that can be used for querying permissions of a SQL user.
+ class Grant < ActiveRecord::Base
+ self.table_name =
+ if Database.postgresql?
+ 'information_schema.role_table_grants'
+ else
+ 'mysql.user'
+ end
+
+ def self.scope_to_current_user
+ if Database.postgresql?
+ where('grantee = user')
+ else
+ where("CONCAT(User, '@', Host) = current_user()")
+ end
+ end
+
+ # Returns true if the current user can create and execute triggers on the
+ # given table.
+ def self.create_and_execute_trigger?(table)
+ priv =
+ if Database.postgresql?
+ where(privilege_type: 'TRIGGER', table_name: table)
+ else
+ where(Trigger_priv: 'Y')
+ end
+
+ priv.scope_to_current_user.any?
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb
index 5e2c6cc5cad..fb14798efe6 100644
--- a/lib/gitlab/database/migration_helpers.rb
+++ b/lib/gitlab/database/migration_helpers.rb
@@ -358,6 +358,8 @@ module Gitlab
raise 'rename_column_concurrently can not be run inside a transaction'
end
+ check_trigger_permissions!(table)
+
old_col = column_for(table, old)
new_type = type || old_col.type
@@ -430,6 +432,8 @@ module Gitlab
def cleanup_concurrent_column_rename(table, old, new)
trigger_name = rename_trigger_name(table, old, new)
+ check_trigger_permissions!(table)
+
if Database.postgresql?
remove_rename_triggers_for_postgresql(table, trigger_name)
else
@@ -485,14 +489,14 @@ module Gitlab
# Removes the triggers used for renaming a PostgreSQL column concurrently.
def remove_rename_triggers_for_postgresql(table, trigger)
- execute("DROP TRIGGER #{trigger} ON #{table}")
- execute("DROP FUNCTION #{trigger}()")
+ execute("DROP TRIGGER IF EXISTS #{trigger} ON #{table}")
+ execute("DROP FUNCTION IF EXISTS #{trigger}()")
end
# Removes the triggers used for renaming a MySQL column concurrently.
def remove_rename_triggers_for_mysql(trigger)
- execute("DROP TRIGGER #{trigger}_insert")
- execute("DROP TRIGGER #{trigger}_update")
+ execute("DROP TRIGGER IF EXISTS #{trigger}_insert")
+ execute("DROP TRIGGER IF EXISTS #{trigger}_update")
end
# Returns the (base) name to use for triggers when renaming columns.
@@ -625,6 +629,30 @@ module Gitlab
conn.llen("queue:#{queue_name}")
end
end
+
+ def check_trigger_permissions!(table)
+ unless Grant.create_and_execute_trigger?(table)
+ dbname = Database.database_name
+ user = Database.username
+
+ raise <<-EOF
+Your database user is not allowed to create, drop, or execute triggers on the
+table #{table}.
+
+If you are using PostgreSQL you can solve this by logging in to the GitLab
+database (#{dbname}) using a super user and running:
+
+ ALTER #{user} WITH SUPERUSER
+
+For MySQL you instead need to run:
+
+ GRANT ALL PRIVILEGES ON *.* TO #{user}@'%'
+
+Both queries will grant the user super user permissions, ensuring you don't run
+into similar problems in the future (e.g. when new tables are created).
+ EOF
+ end
+ end
end
end
end
diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb
index 03e2bec84dd..fb6504bdea0 100644
--- a/lib/gitlab/git/repository.rb
+++ b/lib/gitlab/git/repository.rb
@@ -17,6 +17,7 @@ module Gitlab
NoRepository = Class.new(StandardError)
InvalidBlobName = Class.new(StandardError)
InvalidRef = Class.new(StandardError)
+ GitError = Class.new(StandardError)
class << self
# Unlike `new`, `create` takes the storage path, not the storage name
@@ -246,6 +247,13 @@ module Gitlab
branch_names + tag_names
end
+ # Returns an Array of all ref names, except when it's matching pattern
+ #
+ # regexp - The pattern for ref names we don't want
+ def all_ref_names_except(regexp)
+ rugged.references.reject { |ref| ref.name =~ regexp }.map(&:name)
+ end
+
# Discovers the default branch based on the repository's available branches
#
# - If no branches are present, returns nil
@@ -591,6 +599,23 @@ module Gitlab
rugged.branches.delete(branch_name)
end
+ def delete_refs(*ref_names)
+ instructions = ref_names.map do |ref|
+ "delete #{ref}\x00\x00"
+ end
+
+ command = %W[#{Gitlab.config.git.bin_path} update-ref --stdin -z]
+ message, status = Gitlab::Popen.popen(
+ command,
+ path) do |stdin|
+ stdin.write(instructions.join)
+ end
+
+ unless status.zero?
+ raise GitError.new("Could not delete refs #{ref_names}: #{message}")
+ end
+ end
+
# Create a new branch named **ref+ based on **stat_point+, HEAD by default
#
# Examples:
diff --git a/lib/tasks/gitlab/cleanup.rake b/lib/tasks/gitlab/cleanup.rake
index f76bef5f4bf..8ae1b6a626a 100644
--- a/lib/tasks/gitlab/cleanup.rake
+++ b/lib/tasks/gitlab/cleanup.rake
@@ -111,7 +111,7 @@ namespace :gitlab do
next unless id > max_iid
project.deployments.find(id).create_ref
- rugged.references.delete(ref)
+ project.repository.delete_refs(ref)
end
end
end
diff --git a/spec/features/projects/commit/rss_spec.rb b/spec/features/projects/commits/rss_spec.rb
index db958346f06..db958346f06 100644
--- a/spec/features/projects/commit/rss_spec.rb
+++ b/spec/features/projects/commits/rss_spec.rb
diff --git a/spec/features/projects/commits/user_browses_commits_spec.rb b/spec/features/projects/commits/user_browses_commits_spec.rb
new file mode 100644
index 00000000000..41f3c15a94c
--- /dev/null
+++ b/spec/features/projects/commits/user_browses_commits_spec.rb
@@ -0,0 +1,44 @@
+require 'spec_helper'
+
+describe 'User broweses commits' do
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :repository, namespace: user.namespace) }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+ end
+
+ context 'primary email' do
+ it 'finds a commit by a primary email' do
+ user = create(:user, email: 'dmitriy.zaporozhets@gmail.com')
+
+ visit(project_commit_path(project, RepoHelpers.sample_commit.id))
+
+ check_author_link(RepoHelpers.sample_commit.author_email, user)
+ end
+ end
+
+ context 'secondary email' do
+ it 'finds a commit by a secondary email' do
+ user =
+ create(:user) do |user|
+ create(:email, { user: user, email: 'dmitriy.zaporozhets@gmail.com' })
+ end
+
+ visit(project_commit_path(project, RepoHelpers.sample_commit.parent_id))
+
+ check_author_link(RepoHelpers.sample_commit.author_email, user)
+ end
+ end
+end
+
+private
+
+def check_author_link(email, author)
+ author_link = find('.commit-author-link')
+
+ expect(author_link['href']).to eq(user_path(author))
+ expect(author_link['title']).to eq(email)
+ expect(find('.commit-author-name').text).to eq(author.name)
+end
diff --git a/spec/fixtures/api/schemas/public_api/v4/issues.json b/spec/fixtures/api/schemas/public_api/v4/issues.json
index bd6bfc03199..8acd9488215 100644
--- a/spec/fixtures/api/schemas/public_api/v4/issues.json
+++ b/spec/fixtures/api/schemas/public_api/v4/issues.json
@@ -78,7 +78,13 @@
"downvotes": { "type": "integer" },
"due_date": { "type": ["date", "null"] },
"confidential": { "type": "boolean" },
- "web_url": { "type": "uri" }
+ "web_url": { "type": "uri" },
+ "time_stats": {
+ "time_estimate": { "type": "integer" },
+ "total_time_spent": { "type": "integer" },
+ "human_time_estimate": { "type": ["string", "null"] },
+ "human_total_time_spent": { "type": ["string", "null"] }
+ }
},
"required": [
"id", "iid", "project_id", "title", "description",
diff --git a/spec/fixtures/api/schemas/public_api/v4/merge_requests.json b/spec/fixtures/api/schemas/public_api/v4/merge_requests.json
index 60aa47c1259..31b3f4ba946 100644
--- a/spec/fixtures/api/schemas/public_api/v4/merge_requests.json
+++ b/spec/fixtures/api/schemas/public_api/v4/merge_requests.json
@@ -72,7 +72,13 @@
"user_notes_count": { "type": "integer" },
"should_remove_source_branch": { "type": ["boolean", "null"] },
"force_remove_source_branch": { "type": ["boolean", "null"] },
- "web_url": { "type": "uri" }
+ "web_url": { "type": "uri" },
+ "time_stats": {
+ "time_estimate": { "type": "integer" },
+ "total_time_spent": { "type": "integer" },
+ "human_time_estimate": { "type": ["string", "null"] },
+ "human_total_time_spent": { "type": ["string", "null"] }
+ }
},
"required": [
"id", "iid", "project_id", "title", "description",
diff --git a/spec/javascripts/issue_show/components/edited_spec.js b/spec/javascripts/issue_show/components/edited_spec.js
index a0d0750ae34..2061def699b 100644
--- a/spec/javascripts/issue_show/components/edited_spec.js
+++ b/spec/javascripts/issue_show/components/edited_spec.js
@@ -46,4 +46,14 @@ describe('edited', () => {
expect(editedComponent.$el.querySelector('.author_link')).toBeFalsy();
expect(editedComponent.$el.querySelector('time')).toBeTruthy();
});
+
+ it('renders time ago tooltip at the bottom', () => {
+ const editedComponent = new EditedComponent({
+ propsData: {
+ updatedAt: '2017-05-15T12:31:04.428Z',
+ },
+ }).$mount();
+
+ expect(editedComponent.$el.querySelector('time').dataset.placement).toEqual('bottom');
+ });
});
diff --git a/spec/lib/gitlab/database/grant_spec.rb b/spec/lib/gitlab/database/grant_spec.rb
new file mode 100644
index 00000000000..651da3e8476
--- /dev/null
+++ b/spec/lib/gitlab/database/grant_spec.rb
@@ -0,0 +1,30 @@
+require 'spec_helper'
+
+describe Gitlab::Database::Grant do
+ describe '.scope_to_current_user' do
+ it 'scopes the relation to the current user' do
+ user = Gitlab::Database.username
+ column = Gitlab::Database.postgresql? ? :grantee : :User
+ names = described_class.scope_to_current_user.pluck(column).uniq
+
+ expect(names).to eq([user])
+ end
+ end
+
+ describe '.create_and_execute_trigger' do
+ it 'returns true when the user can create and execute a trigger' do
+ # We assume the DB/user is set up correctly so that triggers can be
+ # created, which is necessary anyway for other tests to work.
+ expect(described_class.create_and_execute_trigger?('users')).to eq(true)
+ end
+
+ it 'returns false when the user can not create and/or execute a trigger' do
+ allow(described_class).to receive(:scope_to_current_user)
+ .and_return(described_class.none)
+
+ result = described_class.create_and_execute_trigger?('kittens')
+
+ expect(result).to eq(false)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb
index c25fd459dd7..1bcdc369c44 100644
--- a/spec/lib/gitlab/database/migration_helpers_spec.rb
+++ b/spec/lib/gitlab/database/migration_helpers_spec.rb
@@ -450,6 +450,8 @@ describe Gitlab::Database::MigrationHelpers do
it 'renames a column concurrently' do
allow(Gitlab::Database).to receive(:postgresql?).and_return(false)
+ expect(model).to receive(:check_trigger_permissions!).with(:users)
+
expect(model).to receive(:install_rename_triggers_for_mysql)
.with(trigger_name, 'users', 'old', 'new')
@@ -477,6 +479,8 @@ describe Gitlab::Database::MigrationHelpers do
it 'renames a column concurrently' do
allow(Gitlab::Database).to receive(:postgresql?).and_return(true)
+ expect(model).to receive(:check_trigger_permissions!).with(:users)
+
expect(model).to receive(:install_rename_triggers_for_postgresql)
.with(trigger_name, 'users', 'old', 'new')
@@ -506,6 +510,8 @@ describe Gitlab::Database::MigrationHelpers do
it 'cleans up the renaming procedure for PostgreSQL' do
allow(Gitlab::Database).to receive(:postgresql?).and_return(true)
+ expect(model).to receive(:check_trigger_permissions!).with(:users)
+
expect(model).to receive(:remove_rename_triggers_for_postgresql)
.with(:users, /trigger_.{12}/)
@@ -517,6 +523,8 @@ describe Gitlab::Database::MigrationHelpers do
it 'cleans up the renaming procedure for MySQL' do
allow(Gitlab::Database).to receive(:postgresql?).and_return(false)
+ expect(model).to receive(:check_trigger_permissions!).with(:users)
+
expect(model).to receive(:remove_rename_triggers_for_mysql)
.with(/trigger_.{12}/)
@@ -573,8 +581,8 @@ describe Gitlab::Database::MigrationHelpers do
describe '#remove_rename_triggers_for_postgresql' do
it 'removes the function and trigger' do
- expect(model).to receive(:execute).with('DROP TRIGGER foo ON bar')
- expect(model).to receive(:execute).with('DROP FUNCTION foo()')
+ expect(model).to receive(:execute).with('DROP TRIGGER IF EXISTS foo ON bar')
+ expect(model).to receive(:execute).with('DROP FUNCTION IF EXISTS foo()')
model.remove_rename_triggers_for_postgresql('bar', 'foo')
end
@@ -582,8 +590,8 @@ describe Gitlab::Database::MigrationHelpers do
describe '#remove_rename_triggers_for_mysql' do
it 'removes the triggers' do
- expect(model).to receive(:execute).with('DROP TRIGGER foo_insert')
- expect(model).to receive(:execute).with('DROP TRIGGER foo_update')
+ expect(model).to receive(:execute).with('DROP TRIGGER IF EXISTS foo_insert')
+ expect(model).to receive(:execute).with('DROP TRIGGER IF EXISTS foo_update')
model.remove_rename_triggers_for_mysql('foo')
end
@@ -890,4 +898,20 @@ describe Gitlab::Database::MigrationHelpers do
end
end
end
+
+ describe '#check_trigger_permissions!' do
+ it 'does nothing when the user has the correct permissions' do
+ expect { model.check_trigger_permissions!('users') }
+ .not_to raise_error(RuntimeError)
+ end
+
+ it 'raises RuntimeError when the user does not have the correct permissions' do
+ allow(Gitlab::Database::Grant).to receive(:create_and_execute_trigger?)
+ .with('kittens')
+ .and_return(false)
+
+ expect { model.check_trigger_permissions!('kittens') }
+ .to raise_error(RuntimeError, /Your database user is not allowed/)
+ end
+ end
end
diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb
index 6b9773c9b63..4cfb4b7d357 100644
--- a/spec/lib/gitlab/git/repository_spec.rb
+++ b/spec/lib/gitlab/git/repository_spec.rb
@@ -433,6 +433,40 @@ describe Gitlab::Git::Repository, seed_helper: true do
end
end
+ describe '#delete_refs' do
+ before(:all) do
+ @repo = Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '')
+ end
+
+ it 'deletes the ref' do
+ @repo.delete_refs('refs/heads/feature')
+
+ expect(@repo.rugged.references['refs/heads/feature']).to be_nil
+ end
+
+ it 'deletes all refs' do
+ refs = %w[refs/heads/wip refs/tags/v1.1.0]
+ @repo.delete_refs(*refs)
+
+ refs.each do |ref|
+ expect(@repo.rugged.references[ref]).to be_nil
+ end
+ end
+
+ it 'raises an error if it failed' do
+ expect(Gitlab::Popen).to receive(:popen).and_return(['Error', 1])
+
+ expect do
+ @repo.delete_refs('refs/heads/fix')
+ end.to raise_error(Gitlab::Git::Repository::GitError)
+ end
+
+ after(:all) do
+ FileUtils.rm_rf(TEST_MUTABLE_REPO_PATH)
+ ensure_seeds
+ end
+ end
+
describe "#refs_hash" do
let(:refs) { repository.refs_hash }
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 2e613c44357..11717ba39e8 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -1563,10 +1563,18 @@ describe Project do
describe 'project import state transitions' do
context 'state transition: [:started] => [:finished]' do
- let(:housekeeping_service) { spy }
+ let(:after_import_service) { spy(:after_import_service) }
+ let(:housekeeping_service) { spy(:housekeeping_service) }
before do
- allow(Projects::HousekeepingService).to receive(:new) { housekeeping_service }
+ allow(Projects::AfterImportService)
+ .to receive(:new) { after_import_service }
+
+ allow(after_import_service)
+ .to receive(:execute) { housekeeping_service.execute }
+
+ allow(Projects::HousekeepingService)
+ .to receive(:new) { housekeeping_service }
end
it 'resets project import_error' do
@@ -1581,6 +1589,7 @@ describe Project do
project.import_finish
+ expect(after_import_service).to have_received(:execute)
expect(housekeeping_service).to have_received(:execute)
end
diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb
index 9a0c62467d3..dee75c96b86 100644
--- a/spec/requests/api/issues_spec.rb
+++ b/spec/requests/api/issues_spec.rb
@@ -509,6 +509,18 @@ describe API::Issues, :mailer do
describe "GET /projects/:id/issues" do
let(:base_url) { "/projects/#{project.id}" }
+ it 'avoids N+1 queries' do
+ control_count = ActiveRecord::QueryRecorder.new do
+ get api("/projects/#{project.id}/issues", user)
+ end.count
+
+ create(:issue, author: user, project: project)
+
+ expect do
+ get api("/projects/#{project.id}/issues", user)
+ end.not_to exceed_query_limit(control_count)
+ end
+
it 'returns 404 when project does not exist' do
get api('/projects/1000/issues', non_member)
diff --git a/spec/services/projects/after_import_service_spec.rb b/spec/services/projects/after_import_service_spec.rb
new file mode 100644
index 00000000000..c6678fc1f5c
--- /dev/null
+++ b/spec/services/projects/after_import_service_spec.rb
@@ -0,0 +1,55 @@
+require 'spec_helper'
+
+describe Projects::AfterImportService do
+ subject { described_class.new(project) }
+
+ let(:project) { create(:project, :repository) }
+ let(:repository) { project.repository }
+ let(:sha) { project.commit.sha }
+ let(:housekeeping_service) { double(:housekeeping_service) }
+
+ describe '#execute' do
+ before do
+ allow(Projects::HousekeepingService)
+ .to receive(:new).with(project).and_return(housekeeping_service)
+
+ allow(housekeeping_service)
+ .to receive(:execute).and_yield
+ end
+
+ it 'performs housekeeping' do
+ subject.execute
+
+ expect(housekeeping_service).to have_received(:execute)
+ end
+
+ context 'with some refs in refs/pull/**/*' do
+ before do
+ repository.write_ref('refs/pull/1/head', sha)
+ repository.write_ref('refs/pull/1/merge', sha)
+
+ subject.execute
+ end
+
+ it 'removes refs/pull/**/*' do
+ expect(repository.rugged.references.map(&:name))
+ .not_to include(%r{\Arefs/pull/})
+ end
+ end
+
+ Repository::RESERVED_REFS_NAMES.each do |name|
+ context "with a ref in refs/#{name}/tmp" do
+ before do
+ repository.write_ref("refs/#{name}/tmp", sha)
+
+ subject.execute
+ end
+
+ it "does not remove refs/#{name}/tmp" do
+ expect(repository.rugged.references.map(&:name))
+ .to include("refs/#{name}/tmp")
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/projects/housekeeping_service_spec.rb b/spec/services/projects/housekeeping_service_spec.rb
index 385f56e447f..9386c110385 100644
--- a/spec/services/projects/housekeeping_service_spec.rb
+++ b/spec/services/projects/housekeeping_service_spec.rb
@@ -23,6 +23,12 @@ describe Projects::HousekeepingService do
expect(project.reload.pushes_since_gc).to eq(0)
end
+ it 'yields the block if given' do
+ expect do |block|
+ subject.execute(&block)
+ end.to yield_with_no_args
+ end
+
context 'when no lease can be obtained' do
before do
expect(subject).to receive(:try_obtain_lease).and_return(false)
@@ -39,6 +45,13 @@ describe Projects::HousekeepingService do
expect { subject.execute }.to raise_error(Projects::HousekeepingService::LeaseTaken)
end.not_to change { project.pushes_since_gc }
end
+
+ it 'does not yield' do
+ expect do |block|
+ expect { subject.execute(&block) }
+ .to raise_error(Projects::HousekeepingService::LeaseTaken)
+ end.not_to yield_with_no_args
+ end
end
end