diff options
321 files changed, 6251 insertions, 2116 deletions
diff --git a/.gitignore b/.gitignore index 1eb785451f4..8f861d76a37 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ .sass-cache/ .secret .vagrant +.byebug_history Vagrantfile backups/* config/aws.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c477721f9da..b4dad768fa4 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -12,16 +12,18 @@ cache: variables: MYSQL_ALLOW_EMPTY_PASSWORD: "1" + # retry tests only in CI environment + RSPEC_RETRY_RETRY_COUNT: "3" before_script: - source ./scripts/prepare_build.sh - ruby -v - which ruby - - gem install bundler --no-ri --no-rdoc + - retry gem install bundler --no-ri --no-rdoc - cp config/gitlab.yml.example config/gitlab.yml - touch log/application.log - touch log/test.log - - bundle install --without postgres production --jobs $(nproc) "${FLAGS[@]}" + - retry bundle install --without postgres production --jobs $(nproc) "${FLAGS[@]}" - RAILS_ENV=test bundle exec rake db:drop db:create db:schema:load db:migrate stages: @@ -69,15 +71,6 @@ spec:services: - ruby - mysql -spec:benchmark: - stage: test - script: - - RAILS_ENV=test bundle exec rake spec:benchmark - tags: - - ruby - - mysql - allow_failure: true - spec:other: stage: test script: @@ -155,13 +148,14 @@ flay: bundler:audit: stage: test + only: + - master script: - "bundle exec bundle-audit update" - - "bundle exec bundle-audit check" + - "bundle exec bundle-audit check --ignore OSVDB-115941" tags: - ruby - mysql - allow_failure: true # Ruby 2.2 jobs @@ -169,7 +163,7 @@ spec:feature:ruby22: stage: test image: ruby:2.2 only: - - master + - master script: - RAILS_ENV=test bundle exec rake assets:precompile 2>/dev/null - RAILS_ENV=test SIMPLECOV=true bundle exec rake spec:feature @@ -241,22 +235,6 @@ spec:services:ruby22: - ruby - mysql -spec:benchmark:ruby22: - stage: test - image: ruby:2.2 - only: - - master - script: - - RAILS_ENV=test bundle exec rake spec:benchmark - cache: - key: "ruby22" - paths: - - vendor - tags: - - ruby - - mysql - allow_failure: true - spec:other:ruby22: stage: test image: ruby:2.2 @@ -330,4 +308,4 @@ notify:slack: - master@gitlab-org/gitlab-ce - tags@gitlab-org/gitlab-ce - master@gitlab-org/gitlab-ee - - tags@gitlab-org/gitlab-ee
\ No newline at end of file + - tags@gitlab-org/gitlab-ee diff --git a/CHANGELOG b/CHANGELOG index 658ed5aef72..a5364178bab 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,9 @@ Please view this file on the master branch, on stable branches it's out of date. v 8.6.0 (unreleased) + - Bump gitlab_git to 9.0.3 (Stan Hu) + - Support Golang subpackage fetching (Stan Hu) + - Bump Capybara gem to 2.6.2 (Stan Hu) - Contributions to forked projects are included in calendar - Improve the formatting for the user page bio (Connor Shea) - Removed the default password from the initial admin account created during @@ -9,17 +12,40 @@ v 8.6.0 (unreleased) - Fix issue when pushing to projects ending in .wiki - Fix avatar stretching by providing a cropping feature (Johann Pardanaud) - Don't load all of GitLab in mail_room + - Update `omniauth-saml` to 1.5.0 to allow for custom response attributes to be set + - Memoize @group in Admin::GroupsController (Yatish Mehta) - Indicate how much an MR diverged from the target branch (Pierre de La Morinerie) - Strip leading and trailing spaces in URL validator (evuez) + - Add "last_sign_in_at" and "confirmed_at" to GET /users/* API endpoints for admins (evuez) - Return empty array instead of 404 when commit has no statuses in commit status API + - Decrease the font size and the padding of the `.anchor` icons used in the README (Roberto Dip) + - Rewrite logo to simplify SVG code (Sean Lang) + - Allow to use YAML anchors when parsing the `.gitlab-ci.yml` (Pascal Bach) + - Ignore jobs that start with `.` (hidden jobs) + - Allow to pass name of created artifacts archive in `.gitlab-ci.yml` + - Refactor and greatly improve search performance - Add support for cross-project label references + - Ensure "new SSH key" email do not ends up as dead Sidekiq jobs - Update documentation to reflect Guest role not being enforced on internal projects - Allow search for logged out users + - Allow to define on which builds the current one depends on + - Fix bug where Bitbucket `closed` issues were imported as `opened` (Iuri de Silvio) - Don't show Issues/MRs from archived projects in Groups view - Fix wrong "iid of max iid" in Issuable sidebar for some merged MRs - Increase the notes polling timeout over time (Roberto Dip) + - Add shortcut to toggle markdown preview (Florent Baldino) - Show labels in dashboard and group milestone views - Add main language of a project in the list of projects (Tiago Botelho) + - Add ability to show archived projects on dashboard, explore and group pages + - Move group activity to separate page + - Continue parameters are checked to ensure redirection goes to the same instance + +v 8.5.5 + - Ensure removing a project removes associated Todo entries + - Prevent a 500 error in Todos when author was removed + - Fix pagination for filtered dashboard and explore pages + - Fix "Show all" link behavior + - Add #upcoming filter to Milestone filter (Tiago Botelho) v 8.5.4 - Do not cache requests for badges (including builds badge) @@ -29,6 +55,7 @@ v 8.5.3 - Sort starred projects on dashboard based on last activity by default - Show commit message in JIRA mention comment - Makes issue page and merge request page usable on mobile browsers. + - Improved UI for profile settings v 8.5.2 - Fix sidebar overlapping content when screen width was below 1200px @@ -70,7 +97,7 @@ v 8.5.1 v 8.5.0 - Fix duplicate "me" in tooltip of the "thumbsup" awards Emoji (Stan Hu) - Cache various Repository methods to improve performance (Yorick Peterse) - - Fix duplicated branch creation/deletion Web hooks/service notifications when using Web UI (Stan Hu) + - Fix duplicated branch creation/deletion Webhooks/service notifications when using Web UI (Stan Hu) - Ensure rake tasks that don't need a DB connection can be run without one - Update New Relic gem to 3.14.1.311 (Stan Hu) - Add "visibility" flag to GET /projects api endpoint @@ -203,7 +230,7 @@ v 8.4.0 - Add housekeeping function to project settings page - The default GitLab logo now acts as a loading indicator - Fix caching issue where build status was not updating in project dashboard (Stan Hu) - - Accept 2xx status codes for successful Web hook triggers (Stan Hu) + - Accept 2xx status codes for successful Webhook triggers (Stan Hu) - Fix missing date of month in network graph when commits span a month (Stan Hu) - Expire view caches when application settings change (e.g. Gravatar disabled) (Stan Hu) - Don't notify users twice if they are both project watchers and subscribers (Stan Hu) @@ -303,7 +330,7 @@ v 8.3.0 - Fix broken group avatar upload under "New group" (Stan Hu) - Update project repositorize size and commit count during import:repos task (Stan Hu) - Fix API setting of 'public' attribute to false will make a project private (Stan Hu) - - Handle and report SSL errors in Web hook test (Stan Hu) + - Handle and report SSL errors in Webhook test (Stan Hu) - Bump Redis requirement to 2.8 for Sidekiq 4 (Stan Hu) - Fix: Assignee selector is empty when 'Unassigned' is selected (Jose Corcuera) - WIP identifier on merge requests no longer requires trailing space @@ -523,7 +550,7 @@ v 8.1.0 - Ensure code blocks are properly highlighted after a note is updated - Fix wrong access level badge on MR comments - Hide password in the service settings form - - Move CI web hooks page to project settings area + - Move CI webhooks page to project settings area - Fix User Identities API. It now allows you to properly create or update user's identities. - Add user preference to change layout width (Peter Göbel) - Use commit status in merge request widget as preferred source of CI status @@ -566,7 +593,7 @@ v 8.0.3 - Fix URL shown in Slack notifications - Fix bug where projects would appear to be stuck in the forked import state (Stan Hu) - Fix Error 500 in creating merge requests with > 1000 diffs (Stan Hu) - - Add work_in_progress key to MR web hooks (Ben Boeckel) + - Add work_in_progress key to MR webhooks (Ben Boeckel) v 8.0.2 - Fix default avatar not rendering in network graph (Stan Hu) @@ -857,7 +884,7 @@ v 7.12.0 - Fix milestone "Browse Issues" button. - Set milestone on new issue when creating issue from index with milestone filter active. - Make namespace API available to all users (Stan Hu) - - Add web hook support for note events (Stan Hu) + - Add webhook support for note events (Stan Hu) - Disable "New Issue" and "New Merge Request" buttons when features are disabled in project settings (Stan Hu) - Remove Rack Attack monkey patches and bump to version 4.3.0 (Stan Hu) - Fix clone URL losing selection after a single click in Safari and Chrome (Stan Hu) @@ -964,7 +991,7 @@ v 7.11.0 - Add "Create Merge Request" buttons to commits and branches pages and push event. - Show user roles by comments. - Fix automatic blocking of auto-created users from Active Directory. - - Call merge request web hook for each new commits (Arthur Gautier) + - Call merge request webhook for each new commits (Arthur Gautier) - Use SIGKILL by default in Sidekiq::MemoryKiller - Fix mentioning of private groups. - Add style for <kbd> element in markdown @@ -1138,7 +1165,7 @@ v 7.9.0 - Add brakeman (security scanner for Ruby on Rails) - Slack username and channel options - Add grouped milestones from all projects to dashboard. - - Web hook sends pusher email as well as commiter + - Webhook sends pusher email as well as commiter - Add Bitbucket omniauth provider. - Add Bitbucket importer. - Support referencing issues to a project whose name starts with a digit @@ -1261,7 +1288,7 @@ v 7.8.0 - Allow notification email to be set separately from primary email. - API: Add support for editing an existing project (Mika Mäenpää and Hannes Rosenögger) - Don't have Markdown preview fail for long comments/wiki pages. - - When test web hook - show error message instead of 500 error page if connection to hook url was reset + - When test webhook - show error message instead of 500 error page if connection to hook url was reset - Added support for firing system hooks on group create/destroy and adding/removing users to group (Boyan Tabakov) - Added persistent collapse button for left side nav bar (Jason Blanchard) - Prevent losing unsaved comments by automatically restoring them when comment page is loaded again. @@ -1278,7 +1305,7 @@ v 7.8.0 - Show projects user contributed to on user page. Show stars near project on user page. - Improve database performance for GitLab - Add Asana service (Jeremy Benoist) - - Improve project web hooks with extra data + - Improve project webhooks with extra data v 7.7.2 - Update GitLab Shell to version 2.4.2 that fixes a bug when developers can push to protected branch @@ -1763,7 +1790,7 @@ v 6.4.0 - Side-by-side diff view (Steven Thonus) - Internal projects (Jason Hollingsworth) - Allow removal of avatar (Drew Blessing) - - Project web hooks now support issues and merge request events + - Project webhooks now support issues and merge request events - Visiting project page while not logged in will redirect to sign-in instead of 404 (Jason Hollingsworth) - Expire event cache on avatar creation/removal (Drew Blessing) - Archiving old projects (Steven Thonus) @@ -1833,7 +1860,7 @@ v 6.2.0 - Added search for projects by name to api (Izaak Alpert) - Make default user theme configurable (Izaak Alpert) - Update logic for validates_merge_request for tree of MR (Andrew Kumanyaev) - - Rake tasks for web hooks management (Jonhnny Weslley) + - Rake tasks for webhooks management (Jonhnny Weslley) - Extended User API to expose admin and can_create_group for user creation/updating (Boyan Tabakov) - API: Remove group - API: Remove project @@ -2036,7 +2063,7 @@ v 4.2.0 - Async gitolite calls - added satellites logs - can_create_group, can_create_team booleans for User - - Process web hooks async + - Process webhooks async - GFM: Fix images escaped inside links - Network graph improved - Switchable branches for network graph @@ -2070,7 +2097,7 @@ v 4.1.0 v 4.0.0 - Remove project code and path from API. Use id instead - - Return valid cloneable url to repo for web hook + - Return valid cloneable url to repo for webhook - Fixed backup issue - Reorganized settings - Fixed commits compare diff --git a/GITLAB_SHELL_VERSION b/GITLAB_SHELL_VERSION index a04abec9149..bc02b8685c1 100644 --- a/GITLAB_SHELL_VERSION +++ b/GITLAB_SHELL_VERSION @@ -1 +1 @@ -2.6.10 +2.6.11 @@ -30,7 +30,7 @@ gem 'omniauth-github', '~> 1.1.1' gem 'omniauth-gitlab', '~> 1.0.0' gem 'omniauth-google-oauth2', '~> 0.2.0' gem 'omniauth-kerberos', '~> 0.3.0', group: :kerberos -gem 'omniauth-saml', '~> 1.4.2' +gem 'omniauth-saml', '~> 1.5.0' gem 'omniauth-shibboleth', '~> 1.2.0' gem 'omniauth-twitter', '~> 1.2.0' gem 'omniauth_crowd', '~> 2.2.0' @@ -263,7 +263,9 @@ group :development, :test do gem 'database_cleaner', '~> 1.4.0' gem 'factory_girl_rails', '~> 4.6.0' gem 'rspec-rails', '~> 3.3.0' + gem 'rspec-retry' gem 'spinach-rails', '~> 0.2.1' + gem 'spinach-rerun-reporter', '~> 0.0.2' # Prevent occasions where minitest is not bundled in packaged versions of ruby (see #3826) gem 'minitest', '~> 5.7.0' @@ -271,9 +273,9 @@ group :development, :test do # Generate Fake data gem 'ffaker', '~> 2.0.0' - gem 'capybara', '~> 2.4.0' + gem 'capybara', '~> 2.6.2' gem 'capybara-screenshot', '~> 1.0.0' - gem 'poltergeist', '~> 1.8.1' + gem 'poltergeist', '~> 1.9.0' gem 'teaspoon', '~> 1.0.0' gem 'teaspoon-jasmine', '~> 2.2.0' diff --git a/Gemfile.lock b/Gemfile.lock index 22c86e4ae8f..9772e7fdd38 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -108,7 +108,8 @@ GEM thor (~> 0.18) byebug (8.2.1) cal-heatmap-rails (3.5.1) - capybara (2.4.4) + capybara (2.6.2) + addressable mime-types (>= 1.16) nokogiri (>= 1.3.3) rack (>= 1.0.0) @@ -358,7 +359,7 @@ GEM posix-spawn (~> 0.3) gitlab_emoji (0.3.1) gemojione (~> 2.2, >= 2.2.1) - gitlab_git (9.0.0) + gitlab_git (9.0.3) activesupport (~> 4.0) charlock_holmes (~> 0.7.3) github-linguist (~> 4.7.0) @@ -532,8 +533,8 @@ GEM omniauth-oauth2 (1.3.1) oauth2 (~> 1.0) omniauth (~> 1.2) - omniauth-saml (1.4.2) - omniauth (~> 1.1) + omniauth-saml (1.5.0) + omniauth (~> 1.3) ruby-saml (~> 1.1, >= 1.1.1) omniauth-shibboleth (1.2.1) omniauth (>= 1.0.0) @@ -552,7 +553,7 @@ GEM parser (2.2.3.0) ast (>= 1.1, < 3.0) pg (0.18.4) - poltergeist (1.8.1) + poltergeist (1.9.0) capybara (~> 2.1) cliver (~> 0.3.1) multi_json (~> 1.0) @@ -679,6 +680,8 @@ GEM rspec-expectations (~> 3.3.0) rspec-mocks (~> 3.3.0) rspec-support (~> 3.3.0) + rspec-retry (0.4.5) + rspec-core rspec-support (3.3.0) rubocop (0.35.1) astrolabe (~> 1.3) @@ -690,7 +693,7 @@ GEM ruby-fogbugz (0.2.1) crack (~> 0.4) ruby-progressbar (1.7.5) - ruby-saml (1.1.1) + ruby-saml (1.1.2) nokogiri (>= 1.5.10) uuid (~> 2.3) ruby2ruby (2.2.0) @@ -764,6 +767,8 @@ GEM capybara (>= 2.0.0) railties (>= 3) spinach (>= 0.4) + spinach-rerun-reporter (0.0.2) + spinach (~> 0.8) spring (1.6.4) spring-commands-rspec (1.0.4) spring (>= 0.9.1) @@ -897,7 +902,7 @@ DEPENDENCIES bundler-audit byebug cal-heatmap-rails (~> 3.5.0) - capybara (~> 2.4.0) + capybara (~> 2.6.2) capybara-screenshot (~> 1.0.0) carrierwave (~> 0.10.0) charlock_holmes (~> 0.7.3) @@ -971,14 +976,14 @@ DEPENDENCIES omniauth-gitlab (~> 1.0.0) omniauth-google-oauth2 (~> 0.2.0) omniauth-kerberos (~> 0.3.0) - omniauth-saml (~> 1.4.2) + omniauth-saml (~> 1.5.0) omniauth-shibboleth (~> 1.2.0) omniauth-twitter (~> 1.2.0) omniauth_crowd (~> 2.2.0) org-ruby (~> 0.9.12) paranoia (~> 2.0) pg (~> 0.18.2) - poltergeist (~> 1.8.1) + poltergeist (~> 1.9.0) pry-rails quiet_assets (~> 1.0.2) rack-attack (~> 4.3.1) @@ -999,6 +1004,7 @@ DEPENDENCIES rouge (~> 1.10.1) rqrcode-rails3 (~> 0.1.7) rspec-rails (~> 3.3.0) + rspec-retry rubocop (~> 0.35.0) ruby-fogbugz (~> 0.2.1) sanitize (~> 2.0) @@ -1017,6 +1023,7 @@ DEPENDENCIES six (~> 0.2.0) slack-notifier (~> 1.2.0) spinach-rails (~> 0.2.1) + spinach-rerun-reporter (~> 0.0.2) spring (~> 1.6.4) spring-commands-rspec (~> 1.0.4) spring-commands-spinach (~> 1.0.0) diff --git a/app/assets/javascripts/api.js.coffee b/app/assets/javascripts/api.js.coffee index 3e0fdb3f795..2ddf8612db3 100644 --- a/app/assets/javascripts/api.js.coffee +++ b/app/assets/javascripts/api.js.coffee @@ -4,6 +4,7 @@ namespaces_path: "/api/:version/namespaces.json" group_projects_path: "/api/:version/groups/:id/projects.json" projects_path: "/api/:version/projects.json" + labels_path: "/api/:version/projects/:id/labels" group: (group_id, callback) -> url = Api.buildUrl(Api.group_path) @@ -61,6 +62,19 @@ ).done (projects) -> callback(projects) + newLabel: (project_id, data, callback) -> + url = Api.buildUrl(Api.labels_path) + url = url.replace(':id', project_id) + + data.private_token = gon.api_token + $.ajax( + url: url + type: "POST" + data: data + dataType: "json" + ).done (label) -> + callback(label) + # Return group projects list. Filtered by query groupProjects: (group_id, query, callback) -> url = Api.buildUrl(Api.group_projects_path) diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index 321da10a009..e9c6196e926 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -108,6 +108,8 @@ window.onload = -> setTimeout shiftWindow, 100 $ -> + bootstrapBreakpoint = bp.getBreakpointSize() + $(".nicescroll").niceScroll(cursoropacitymax: '0.4', cursorcolor: '#FFF', cursorborder: "1px solid #FFF") # Click a .js-select-on-focus field, select the contents @@ -220,17 +222,17 @@ $ -> .off 'breakpoint:change' .on 'breakpoint:change', (e, breakpoint) -> if breakpoint is 'sm' or breakpoint is 'xs' - $gutterIcon = $('aside .gutter-toggle').find('i') + $gutterIcon = $('.js-sidebar-toggle').find('i') if $gutterIcon.hasClass('fa-angle-double-right') $gutterIcon.closest('a').trigger('click') $(document) - .off 'click', 'aside .gutter-toggle' - .on 'click', 'aside .gutter-toggle', (e, triggered) -> + .off 'click', '.js-sidebar-toggle' + .on 'click', '.js-sidebar-toggle', (e, triggered) -> e.preventDefault() $this = $(this) $thisIcon = $this.find 'i' - $allGutterToggleIcons = $('.gutter-toggle i') + $allGutterToggleIcons = $('.js-sidebar-toggle i') if $thisIcon.hasClass('fa-angle-double-right') $allGutterToggleIcons .removeClass('fa-angle-double-right') @@ -256,35 +258,14 @@ $ -> $('.right-sidebar') .hasClass('right-sidebar-collapsed'), { path: '/' }) - bootstrapBreakpoint = undefined; - checkBootstrapBreakpoints = -> - if $('.device-xs').is(':visible') - bootstrapBreakpoint = "xs" - else if $('.device-sm').is(':visible') - bootstrapBreakpoint = "sm" - else if $('.device-md').is(':visible') - bootstrapBreakpoint = "md" - else if $('.device-lg').is(':visible') - bootstrapBreakpoint = "lg" - - setBootstrapBreakpoints = -> - if $('.device-xs').length - return - - $("body") - .append('<div class="device-xs visible-xs"></div>'+ - '<div class="device-sm visible-sm"></div>'+ - '<div class="device-md visible-md"></div>'+ - '<div class="device-lg visible-lg"></div>') - checkBootstrapBreakpoints() - fitSidebarForSize = -> oldBootstrapBreakpoint = bootstrapBreakpoint - checkBootstrapBreakpoints() + bootstrapBreakpoint = bp.getBreakpointSize() if bootstrapBreakpoint != oldBootstrapBreakpoint $(document).trigger('breakpoint:change', [bootstrapBreakpoint]) checkInitialSidebarSize = -> + bootstrapBreakpoint = bp.getBreakpointSize() if bootstrapBreakpoint is "xs" or "sm" $(document).trigger('breakpoint:change', [bootstrapBreakpoint]) @@ -293,6 +274,5 @@ $ -> .on "resize", (e) -> fitSidebarForSize() - setBootstrapBreakpoints() checkInitialSidebarSize() new Aside() diff --git a/app/assets/javascripts/awards_handler.coffee b/app/assets/javascripts/awards_handler.coffee index 8f89d3e61a2..03a44874161 100644 --- a/app/assets/javascripts/awards_handler.coffee +++ b/app/assets/javascripts/awards_handler.coffee @@ -1,6 +1,6 @@ class @AwardsHandler constructor: (@post_emoji_url, @noteable_type, @noteable_id, @aliases) -> - $(".add-award").click (event) => + $(".js-add-award").on "click", (event) => event.stopPropagation() event.preventDefault() @@ -9,27 +9,46 @@ class @AwardsHandler $("html").on 'click', (event) -> if !$(event.target).closest(".emoji-menu").length if $(".emoji-menu").is(":visible") - $(".emoji-menu").hide() + $(".emoji-menu").removeClass "is-visible" + + $(".awards") + .off "click" + .on "click", ".js-emoji-btn", @handleClick @renderFrequentlyUsedBlock() - @setupSearch() + + handleClick: (e) -> + e.preventDefault() + emoji = $(this) + .find(".icon") + .data "emoji" + awards_handler.addAward emoji showEmojiMenu: -> if $(".emoji-menu").length - $(".emoji-menu").show() - $("#emoji_search").focus() - else - $.get "/emojis", (response) -> - $(".add-award").after response - $(".emoji-menu").show() + if $(".emoji-menu").is ".is-visible" + $(".emoji-menu").removeClass "is-visible" + $("#emoji_search").blur() + else + $(".emoji-menu").addClass "is-visible" $("#emoji_search").focus() + else + $('.js-add-award').addClass "is-loading" + $.get "/emojis", (response) => + $('.js-add-award').removeClass "is-loading" + $(".js-award-holder").append response + setTimeout => + $(".emoji-menu").addClass "is-visible" + $("#emoji_search").focus() + @setupSearch() + , 200 addAward: (emoji) -> emoji = @normilizeEmojiName(emoji) @postEmoji emoji, => @addAwardToEmojiBar(emoji) - $(".emoji-menu").hide() + $(".emoji-menu").removeClass "is-visible" addAwardToEmojiBar: (emoji) -> @addEmojiToFrequentlyUsedList(emoji) @@ -39,7 +58,7 @@ class @AwardsHandler if @isActive(emoji) @decrementCounter(emoji) else - counter = @findEmojiIcon(emoji).siblings(".counter") + counter = @findEmojiIcon(emoji).siblings(".js-counter") counter.text(parseInt(counter.text()) + 1) counter.parent().addClass("active") @addMeToAuthorList(emoji) @@ -53,7 +72,7 @@ class @AwardsHandler @findEmojiIcon(emoji).parent().hasClass("active") decrementCounter: (emoji) -> - counter = @findEmojiIcon(emoji).siblings(".counter") + counter = @findEmojiIcon(emoji).siblings(".js-counter") emojiIcon = counter.parent() if parseInt(counter.text()) > 1 counter.text(parseInt(counter.text()) - 1) @@ -70,9 +89,13 @@ class @AwardsHandler removeMeFromAuthorList: (emoji) -> award_block = @findEmojiIcon(emoji).parent() - authors = award_block.attr("data-original-title").split(", ") + authors = award_block + .attr("data-original-title") + .split(", ") authors.splice(authors.indexOf("me"),1) - award_block.closest(".award").attr("data-original-title", authors.join(", ")) + award_block + .closest(".js-emoji-btn") + .attr("data-original-title", authors.join(", ")) @resetTooltip(award_block) addMeToAuthorList: (emoji) -> @@ -98,14 +121,18 @@ class @AwardsHandler emojiCssClass = @resolveNameToCssClass(emoji) nodes = [] - nodes.push("<div class='award active' title='me'>") - nodes.push("<div class='icon emoji-icon #{emojiCssClass}' data-emoji='#{emoji}'></div>") - nodes.push("<div class='counter'>1</div>") - nodes.push("</div>") - - emoji_node = $(nodes.join("\n")).insertBefore(".awards-controls").find(".emoji-icon").data("emoji", emoji) - - $(".award").tooltip() + nodes.push( + "<button class='btn award-control js-emoji-btn has_tooltip active' title='me'>", + "<div class='icon emoji-icon #{emojiCssClass}' data-emoji='#{emoji}'></div>", + "<span class='award-control-text js-counter'>1</span>", + "</button>" + ) + + emoji_node = $(nodes.join("\n")) + .insertBefore(".js-award-holder") + .find(".emoji-icon") + .data("emoji", emoji) + $('.award-control').tooltip() resolveNameToCssClass: (emoji) -> emoji_icon = $(".emoji-menu-content [data-emoji='#{emoji}']") @@ -128,7 +155,7 @@ class @AwardsHandler callback.call() findEmojiIcon: (emoji) -> - $(".award [data-emoji='#{emoji}']") + $(".awards > .js-emoji-btn [data-emoji='#{emoji}']") scrollToAwards: -> $('body, html').animate({ @@ -164,13 +191,13 @@ class @AwardsHandler term = $(ev.target).val() # Clean previous search results - $("ul.emoji-search,h5.emoji-search").remove() + $("ul.emoji-menu-search, h5.emoji-search").remove() if term # Generate a search result block h5 = $("<h5>").text("Search results").addClass("emoji-search") found_emojis = @searchEmojis(term).show() - ul = $("<ul>").addClass("emoji-search").append(found_emojis) + ul = $("<ul>").addClass("emoji-menu-list emoji-menu-search").append(found_emojis) $(".emoji-menu-content ul, .emoji-menu-content h5").hide() $(".emoji-menu-content").append(h5).append(ul) else diff --git a/app/assets/javascripts/breakpoints.coffee b/app/assets/javascripts/breakpoints.coffee new file mode 100644 index 00000000000..5457430f921 --- /dev/null +++ b/app/assets/javascripts/breakpoints.coffee @@ -0,0 +1,37 @@ +class @Breakpoints + instance = null; + + class BreakpointInstance + BREAKPOINTS = ["xs", "sm", "md", "lg"] + + constructor: -> + @setup() + + setup: -> + allDeviceSelector = BREAKPOINTS.map (breakpoint) -> + ".device-#{breakpoint}" + return if $(allDeviceSelector.join(",")).length + + # Create all the elements + els = $.map BREAKPOINTS, (breakpoint) -> + "<div class='device-#{breakpoint} visible-#{breakpoint}'></div>" + $("body").append els.join('') + + visibleDevice: -> + allDeviceSelector = BREAKPOINTS.map (breakpoint) -> + ".device-#{breakpoint}" + $(allDeviceSelector.join(",")).filter(":visible") + + getBreakpointSize: -> + $visibleDevice = @visibleDevice + # the page refreshed via turbolinks + if not $visibleDevice().length + @setup() + $visibleDevice = @visibleDevice() + return $visibleDevice.attr("class").split("visible-")[1] + + @get: -> + return instance ?= new BreakpointInstance + +$ => + @bp = Breakpoints.get() diff --git a/app/assets/javascripts/ci/build.coffee b/app/assets/javascripts/ci/build.coffee index 44d5ddb7d95..7afe8bf79e2 100644 --- a/app/assets/javascripts/ci/build.coffee +++ b/app/assets/javascripts/ci/build.coffee @@ -4,6 +4,8 @@ class CiBuild constructor: (build_url, build_status) -> clearInterval(CiBuild.interval) + @initScrollButtonAffix() + if build_status == "running" || build_status == "pending" # # Bind autoscroll button to follow build output @@ -38,4 +40,15 @@ class CiBuild checkAutoscroll: -> $("html,body").scrollTop $("#build-trace").height() if "enabled" is $("#autoscroll-button").data("state") + initScrollButtonAffix: -> + $buildScroll = $('#js-build-scroll') + $body = $('body') + $buildTrace = $('#build-trace') + + $buildScroll.affix( + offset: + bottom: -> + $body.outerHeight() - ($buildTrace.outerHeight() + $buildTrace.offset().top) + ) + @CiBuild = CiBuild diff --git a/app/assets/javascripts/dispatcher.js.coffee b/app/assets/javascripts/dispatcher.js.coffee index 54b28f2dd8d..1be86e3b820 100644 --- a/app/assets/javascripts/dispatcher.js.coffee +++ b/app/assets/javascripts/dispatcher.js.coffee @@ -74,8 +74,9 @@ class Dispatcher shortcut_handler = new ShortcutsNavigation() new TreeView() if $('#tree-slider').length - when 'groups:show' + when 'groups:activity' new Activities() + when 'groups:show' shortcut_handler = new ShortcutsNavigation() when 'groups:group_members:index' new GroupMembers() @@ -103,6 +104,8 @@ class Dispatcher new ProjectFork() when 'projects:artifacts:browse' new BuildArtifacts() + when 'projects:group_links:index' + new GroupsSelect() switch path.first() when 'admin' diff --git a/app/assets/javascripts/gl_dropdown.js.coffee b/app/assets/javascripts/gl_dropdown.js.coffee new file mode 100644 index 00000000000..4f038477755 --- /dev/null +++ b/app/assets/javascripts/gl_dropdown.js.coffee @@ -0,0 +1,272 @@ +class GitLabDropdownFilter + BLUR_KEYCODES = [27, 40] + + constructor: (@dropdown, @options) -> + @input = @dropdown.find(".dropdown-input .dropdown-input-field") + + # Key events + timeout = "" + @input.on "keyup", (e) => + if e.keyCode is 13 && @input.val() isnt "" + if @options.enterCallback + @options.enterCallback() + return + + clearTimeout timeout + timeout = setTimeout => + blur_field = @shouldBlur e.keyCode + search_text = @input.val() + + if blur_field + @input.blur() + + if @options.remote + @options.query search_text, (data) => + @options.callback(data) + else + @filter search_text + , 250 + + shouldBlur: (keyCode) -> + return BLUR_KEYCODES.indexOf(keyCode) >= 0 + + filter: (search_text) -> + data = @options.data() + results = data + + if search_text isnt "" + results = fuzzaldrinPlus.filter(data, search_text, + key: @options.keys + ) + + @options.callback results + +class GitLabDropdownRemote + constructor: (@dataEndpoint, @options) -> + + execute: -> + if typeof @dataEndpoint is "string" + @fetchData() + else if typeof @dataEndpoint is "function" + if @options.beforeSend + @options.beforeSend() + + # Fetch the data by calling the data funcfion + @dataEndpoint "", (data) => + if @options.success + @options.success(data) + + if @options.beforeSend + @options.beforeSend() + + # Fetch the data through ajax if the data is a string + fetchData: -> + $.ajax( + url: @dataEndpoint, + dataType: @options.dataType, + beforeSend: => + if @options.beforeSend + @options.beforeSend() + success: (data) => + if @options.success + @options.success(data) + ) + +class GitLabDropdown + LOADING_CLASS = "is-loading" + PAGE_TWO_CLASS = "is-page-two" + ACTIVE_CLASS = "is-active" + + constructor: (@el, @options) -> + self = @ + @dropdown = $(@el).parent() + search_fields = if @options.search then @options.search.fields else []; + + if @options.data + # Remote data + @remote = new GitLabDropdownRemote @options.data, { + dataType: @options.dataType, + beforeSend: @toggleLoading.bind(@) + success: (data) => + @fullData = data + + @parseData @fullData + } + + # Init filiterable + if @options.filterable + @filter = new GitLabDropdownFilter @dropdown, + remote: @options.filterRemote + query: @options.data + keys: @options.search.fields + data: => + return @fullData + callback: (data) => + @parseData data + enterCallback: => + @selectFirstRow() + + # Event listeners + @dropdown.on "shown.bs.dropdown", @opened + @dropdown.on "hidden.bs.dropdown", @hidden + + if @dropdown.find(".dropdown-toggle-page").length + @dropdown.find(".dropdown-toggle-page, .dropdown-menu-back").on "click", (e) => + e.preventDefault() + e.stopPropagation() + + @togglePage() + + if @options.selectable + selector = ".dropdown-content a" + + if @dropdown.find(".dropdown-toggle-page").length + selector = ".dropdown-page-one .dropdown-content a" + + @dropdown.on "click", selector, (e) -> + self.rowClicked $(@) + + if self.options.clicked + self.options.clicked() + + toggleLoading: -> + $('.dropdown-menu', @dropdown).toggleClass LOADING_CLASS + + togglePage: -> + menu = $('.dropdown-menu', @dropdown) + + if menu.hasClass(PAGE_TWO_CLASS) + if @remote + @remote.execute() + + menu.toggleClass PAGE_TWO_CLASS + + parseData: (data) -> + @renderedData = data + + # Render each row + html = $.map data, (obj) => + return @renderItem(obj) + + if @options.filterable and data.length is 0 + # render no matching results + html = [@noResults()] + + # Render the full menu + full_html = @renderMenu(html.join("")) + + @appendMenu(full_html) + + opened: => + contentHtml = $('.dropdown-content', @dropdown).html() + if @remote && contentHtml is "" + @remote.execute() + + if @options.filterable + @dropdown.find(".dropdown-input-field").focus() + + hidden: => + if @options.filterable + @dropdown.find(".dropdown-input-field").blur().val("") + + if @dropdown.find(".dropdown-toggle-page").length + $('.dropdown-menu', @dropdown).removeClass PAGE_TWO_CLASS + + + # Render the full menu + renderMenu: (html) -> + menu_html = "" + + if @options.renderMenu + menu_html = @options.renderMenu(html) + else + menu_html = "<ul>#{html}</ul>" + + return menu_html + + # Append the menu into the dropdown + appendMenu: (html) -> + selector = '.dropdown-content' + if @dropdown.find(".dropdown-toggle-page").length + selector = ".dropdown-page-one .dropdown-content" + + $(selector, @dropdown).html html + + # Render the row + renderItem: (data) -> + html = "" + + return "<li class='divider'></li>" if data is "divider" + + if @options.renderRow + # Call the render function + html = @options.renderRow(data) + else + selected = if @options.isSelected then @options.isSelected(data) else false + url = if @options.url then @options.url(data) else "#" + text = if @options.text then @options.text(data) else "" + cssClass = ""; + + if selected + cssClass = "is-active" + + html = "<li>" + html += "<a href='#{url}' class='#{cssClass}'>" + html += text + html += "</a>" + html += "</li>" + + return html + + noResults: -> + html = "<li>" + html += "<a href='#' class='is-focused'>" + html += "No matching results." + html += "</a>" + html += "</li>" + + rowClicked: (el) -> + fieldName = @options.fieldName + field = @dropdown.parent().find("input[name='#{fieldName}']") + + if el.hasClass(ACTIVE_CLASS) + field.remove() + else + fieldName = @options.fieldName + selectedIndex = el.parent().index() + if @renderedData + selectedObject = @renderedData[selectedIndex] + value = if @options.id then @options.id(selectedObject, el) else selectedObject.id + + if !value? + field.remove() + + if @options.multiSelect + oldValue = field.val() + if oldValue + value = "#{oldValue},#{value}" + else + @dropdown.find(ACTIVE_CLASS).removeClass ACTIVE_CLASS + + # Toggle active class for the tick mark + el.toggleClass "is-active" + + if value? + if !field.length + # Create hidden input for form + input = "<input type='hidden' name='#{fieldName}' />" + @dropdown.before input + + @dropdown.parent().find("input[name='#{fieldName}']").val value + + selectFirstRow: -> + selector = '.dropdown-content li:first-child a' + if @dropdown.find(".dropdown-toggle-page").length + selector = ".dropdown-page-one .dropdown-content li:first-child a" + + # similute a click on the first link + $(selector).trigger "click" + +$.fn.glDropdown = (opts) -> + return @.each -> + new GitLabDropdown @, opts diff --git a/app/assets/javascripts/issue_status_select.js.coffee b/app/assets/javascripts/issue_status_select.js.coffee new file mode 100644 index 00000000000..c5740f27ddd --- /dev/null +++ b/app/assets/javascripts/issue_status_select.js.coffee @@ -0,0 +1,11 @@ +class @IssueStatusSelect + constructor: -> + $('.js-issue-status').each (i, el) -> + fieldName = $(el).data("field-name") + + $(el).glDropdown( + selectable: true + fieldName: fieldName + id: (obj, el) -> + $(el).data("id") + ) diff --git a/app/assets/javascripts/labels_select.js.coffee b/app/assets/javascripts/labels_select.js.coffee new file mode 100644 index 00000000000..5ade2cb66cb --- /dev/null +++ b/app/assets/javascripts/labels_select.js.coffee @@ -0,0 +1,92 @@ +class @LabelsSelect + constructor: -> + $('.js-label-select').each (i, dropdown) -> + projectId = $(dropdown).data('project-id') + labelUrl = $(dropdown).data("labels") + selectedLabel = $(dropdown).data('selected') + if selectedLabel + selectedLabel = selectedLabel.split(",") + newLabelField = $('#new_label_name') + newColorField = $('#new_label_color') + showNo = $(dropdown).data('show-no') + showAny = $(dropdown).data('show-any') + + if newLabelField.length + $('.suggest-colors-dropdown a').on "click", (e) -> + e.preventDefault() + e.stopPropagation() + newColorField.val $(this).data("color") + $('.js-dropdown-label-color-preview') + .css 'background-color', $(this).data("color") + .addClass 'is-active' + + $('.js-new-label-btn').on "click", (e) -> + e.preventDefault() + e.stopPropagation() + + if newLabelField.val() isnt "" && newColorField.val() isnt "" + $('.js-new-label-btn').disable() + + # Create new label with API + Api.newLabel projectId, { + name: newLabelField.val() + color: newColorField.val() + }, (label) -> + $('.js-new-label-btn').enable() + $('.dropdown-menu-back', $(dropdown).parent()).trigger "click" + + $(dropdown).glDropdown( + data: (term, callback) -> + # We have to fetch the JS version of the labels list because there is no + # public facing JSON url for labels + $.ajax( + url: labelUrl + ).done (data) -> + html = $(data) + data = [] + html.find('.label-row a').each -> + data.push( + title: $(@).text().trim() + ) + + if showNo + data.unshift( + id: "0" + title: 'No label' + ) + + if showAny + data.unshift( + title: 'Any label' + ) + + if data.length > 2 + data.splice 2, 0, "divider" + + callback data + renderRow: (label) -> + if $.isArray(selectedLabel) + selected = "" + $.each selectedLabel, (i, selectedLbl) -> + selectedLbl = selectedLbl.trim() + if selected is "" && label.title is selectedLbl + selected = "is-active" + else + selected = if label.title is selectedLabel then "is-active" else "" + + "<li> + <a href='#' class='#{selected}'> + #{label.title} + </a> + </li>" + filterable: true + search: + fields: ['title'] + selectable: true + fieldName: $(dropdown).data('field-name') + id: (label) -> + label.title + clicked: -> + if $(dropdown).hasClass "js-filter-submit" + $(dropdown).parents('form').submit() + ) diff --git a/app/assets/javascripts/markdown_preview.js.coffee b/app/assets/javascripts/markdown_preview.js.coffee index 98fc8f17340..2a0b9479445 100644 --- a/app/assets/javascripts/markdown_preview.js.coffee +++ b/app/assets/javascripts/markdown_preview.js.coffee @@ -6,6 +6,7 @@ class @MarkdownPreview # Minimum number of users referenced before triggering a warning referenceThreshold: 10 + ajaxCache: {} showPreview: (form) -> preview = form.find('.js-md-preview') @@ -24,12 +25,16 @@ class @MarkdownPreview renderMarkdown: (text, success) -> return unless window.markdown_preview_path + return success(@ajaxCache.response) if text == @ajaxCache.text + $.ajax type: 'POST' url: window.markdown_preview_path data: { text: text } dataType: 'json' - success: success + success: (response) => + @ajaxCache = text: text, response: response + success(response) hideReferencedUsers: (form) -> referencedUsers = form.find('.referenced-users') @@ -49,6 +54,7 @@ markdownPreview = new MarkdownPreview() previewButtonSelector = '.js-md-preview-button' writeButtonSelector = '.js-md-write-button' +lastTextareaPreviewed = null $.fn.setupMarkdownPreview = -> $form = $(this) @@ -58,10 +64,10 @@ $.fn.setupMarkdownPreview = -> form_textarea.on 'input', -> markdownPreview.hideReferencedUsers($form) form_textarea.on 'blur', -> markdownPreview.showPreview($form) -$(document).on 'click', previewButtonSelector, (e) -> - e.preventDefault() +$(document).on 'markdown-preview:show', (e, $form) -> + return unless $form - $form = $(this).closest('form') + lastTextareaPreviewed = $form.find('textarea.markdown-area') # toggle tabs $form.find(writeButtonSelector).parent().removeClass('active') @@ -73,10 +79,10 @@ $(document).on 'click', previewButtonSelector, (e) -> markdownPreview.showPreview($form) -$(document).on 'click', writeButtonSelector, (e) -> - e.preventDefault() +$(document).on 'markdown-preview:hide', (e, $form) -> + return unless $form - $form = $(this).closest('form') + lastTextareaPreviewed = null # toggle tabs $form.find(writeButtonSelector).parent().addClass('active') @@ -84,4 +90,30 @@ $(document).on 'click', writeButtonSelector, (e) -> # toggle content $form.find('.md-write-holder').show() + $form.find('textarea.markdown-area').focus() $form.find('.md-preview-holder').hide() + +$(document).on 'markdown-preview:toggle', (e, keyboardEvent) -> + $target = $(keyboardEvent.target) + + if $target.is('textarea.markdown-area') + $(document).triggerHandler('markdown-preview:show', [$target.closest('form')]) + keyboardEvent.preventDefault() + else if lastTextareaPreviewed + $target = lastTextareaPreviewed + $(document).triggerHandler('markdown-preview:hide', [$target.closest('form')]) + keyboardEvent.preventDefault() + +$(document).on 'click', previewButtonSelector, (e) -> + e.preventDefault() + + $form = $(this).closest('form') + + $(document).triggerHandler('markdown-preview:show', [$form]) + +$(document).on 'click', writeButtonSelector, (e) -> + e.preventDefault() + + $form = $(this).closest('form') + + $(document).triggerHandler('markdown-preview:hide', [$form]) diff --git a/app/assets/javascripts/merge_request_tabs.js.coffee b/app/assets/javascripts/merge_request_tabs.js.coffee index 58373ba87a5..8322b4c46ad 100644 --- a/app/assets/javascripts/merge_request_tabs.js.coffee +++ b/app/assets/javascripts/merge_request_tabs.js.coffee @@ -189,7 +189,7 @@ class @MergeRequestTabs $('.container-fluid').removeClass('container-limited') shrinkView: -> - $gutterIcon = $('.gutter-toggle i') + $gutterIcon = $('.js-sidebar-toggle i') # Wait until listeners are set setTimeout( -> @@ -197,4 +197,3 @@ class @MergeRequestTabs if $gutterIcon.is('.fa-angle-double-right') $gutterIcon.closest('a').trigger('click',[true]) , 0) - diff --git a/app/assets/javascripts/milestone_select.js.coffee b/app/assets/javascripts/milestone_select.js.coffee new file mode 100644 index 00000000000..5e884454a65 --- /dev/null +++ b/app/assets/javascripts/milestone_select.js.coffee @@ -0,0 +1,60 @@ +class @MilestoneSelect + constructor: -> + $('.js-milestone-select').each (i, dropdown) -> + projectId = $(dropdown).data('project-id') + milestonesUrl = $(dropdown).data('milestones') + selectedMilestone = $(dropdown).data('selected') + showNo = $(dropdown).data('show-no') + showAny = $(dropdown).data('show-any') + useId = $(dropdown).data('use-id') + + $(dropdown).glDropdown( + data: (term, callback) -> + $.ajax( + url: milestonesUrl + ).done (data) -> + html = $(data) + data = [] + html.find('.milestone strong a').each -> + link = $(@).attr("href").split("/") + data.push( + id: link[link.length - 1] + title: $(@).text().trim() + ) + + if showNo + data.unshift( + id: "0" + title: 'No Milestone' + ) + + if showAny + data.unshift( + title: 'Any Milestone' + ) + + if data.length > 2 + data.splice 2, 0, "divider" + + callback(data) + filterable: true + search: + fields: ['title'] + selectable: true + fieldName: $(dropdown).data('field-name') + text: (milestone) -> + milestone.title + id: (milestone) -> + if !useId + if milestone.title isnt "Any milestone" + milestone.title + else + "" + else + milestone.id + isSelected: (milestone) -> + milestone.title is selectedMilestone + clicked: -> + if $(dropdown).hasClass "js-filter-submit" + $(dropdown).parents('form').submit() + ) diff --git a/app/assets/javascripts/notes.js.coffee b/app/assets/javascripts/notes.js.coffee index c95ead22e6c..75d7f52bbb6 100644 --- a/app/assets/javascripts/notes.js.coffee +++ b/app/assets/javascripts/notes.js.coffee @@ -30,8 +30,11 @@ class @Notes $(document).on "ajax:success", ".js-main-target-form", @addNote $(document).on "ajax:success", ".js-discussion-note-form", @addDiscussionNote + # catch note ajax errors + $(document).on "ajax:error", ".js-main-target-form", @addNoteError + # change note in UI after update - $(document).on "ajax:success", "form.edit_note", @updateNote + $(document).on "ajax:success", "form.edit-note", @updateNote # Edit note link $(document).on "click", ".js-note-edit", @showEditForm @@ -51,6 +54,9 @@ class @Notes $(document).on "ajax:complete", ".js-main-target-form", @reenableTargetFormSubmitButton $(document).on "ajax:success", ".js-main-target-form", @resetMainTargetForm + # reset main target form when clicking discard + $(document).on "click", ".js-note-discard", @resetMainTargetForm + # update the file name when an attachment is selected $(document).on "change", ".js-note-attachment-input", @updateFormAttachment @@ -72,7 +78,7 @@ class @Notes cleanBinding: -> $(document).off "ajax:success", ".js-main-target-form" $(document).off "ajax:success", ".js-discussion-note-form" - $(document).off "ajax:success", "form.edit_note" + $(document).off "ajax:success", "form.edit-note" $(document).off "click", ".js-note-edit" $(document).off "click", ".note-edit-cancel" $(document).off "click", ".js-note-delete" @@ -85,6 +91,7 @@ class @Notes $(document).off "keyup", ".js-note-text" $(document).off "click", ".js-note-target-reopen" $(document).off "click", ".js-note-target-close" + $(document).off "click", ".js-note-discard" $('.note .js-task-list-container').taskList('disable') $(document).off 'tasklist:changed', '.note .js-task-list-container' @@ -219,7 +226,7 @@ class @Notes Resets text and preview. Resets buttons. ### - resetMainTargetForm: -> + resetMainTargetForm: (e) => form = $(".js-main-target-form") # remove validation errors @@ -231,6 +238,8 @@ class @Notes form.find(".js-note-text").data("autosave").reset() + @updateTargetButtons(e) + reenableTargetFormSubmitButton: -> form = $(".js-main-target-form") @@ -274,8 +283,10 @@ class @Notes form.removeClass "js-new-note-form" form.find('.div-dropzone').remove() + # hide discard button + form.find('.js-note-discard').hide() + # setup preview buttons - form.find(".js-md-write-button, .js-md-preview-button").tooltip placement: "left" previewButton = form.find(".js-md-preview-button") textarea = form.find(".js-note-text") @@ -309,6 +320,10 @@ class @Notes addNote: (xhr, note, status) => @renderNote(note) + addNoteError: (xhr, note, status) => + flash = new Flash('Your comment could not be submitted! Please check your network connection and try again.', 'alert') + flash.pinTo('.md-area') + ### Called in response to the new note form being submitted @@ -347,22 +362,26 @@ class @Notes note = $(this).closest(".note") note.find(".note-body > .note-text").hide() note.find(".note-header").hide() - base_form = note.find(".note-edit-form") - form = base_form.clone().insertAfter(base_form) - form.addClass('current-note-edit-form gfm-form') - form.find('.div-dropzone').remove() + form = note.find(".note-edit-form") + isNewForm = form.is(':not(.gfm-form)') + if isNewForm + form.addClass('gfm-form') + form.addClass('current-note-edit-form') + form.show() # Show the attachment delete link note.find(".js-note-attachment-delete").show() # Setup markdown form - GitLab.GfmAutoComplete.setup() - new DropzoneInput(form) + if isNewForm + GitLab.GfmAutoComplete.setup() + new DropzoneInput(form) - form.show() textarea = form.find("textarea") textarea.focus() - autosize(textarea) + + if isNewForm + autosize(textarea) # HACK (rspeicher/DouweM): Work around a Chrome 43 bug(?). # The textarea has the correct value, Chrome just won't show it unless we @@ -371,7 +390,8 @@ class @Notes textarea.val "" textarea.val value - disableButtonIfEmptyField textarea, form.find(".js-comment-button") + if isNewForm + disableButtonIfEmptyField textarea, form.find(".js-comment-button") ### Called in response to clicking the edit note link @@ -383,7 +403,9 @@ class @Notes note = $(this).closest(".note") note.find(".note-body > .note-text").show() note.find(".note-header").show() - note.find(".current-note-edit-form").remove() + note.find(".current-note-edit-form") + .removeClass("current-note-edit-form") + .hide() ### Called in response to deleting a note of any kind. @@ -462,6 +484,11 @@ class @Notes form.find("#note_line_code").val dataHolder.data("lineCode") form.find("#note_noteable_type").val dataHolder.data("noteableType") form.find("#note_noteable_id").val dataHolder.data("noteableId") + form.find('.js-note-discard') + .show() + .removeClass('js-note-discard') + .addClass('js-close-discussion-note-form') + .text(form.find('.js-close-discussion-note-form').data('cancel-text')) @setupNoteForm form form.find(".js-note-text").focus() form.addClass "js-discussion-note-form" @@ -561,21 +588,52 @@ class @Notes updateCloseButton: (e) => textarea = $(e.target) form = textarea.parents('form') - form.find('.js-note-target-close').text('Close') + closebtn = form.find('.js-note-target-close') + closebtn.text(closebtn.data('original-text')) updateTargetButtons: (e) => textarea = $(e.target) form = textarea.parents('form') + reopenbtn = form.find('.js-note-target-reopen') + closebtn = form.find('.js-note-target-close') + discardbtn = form.find('.js-note-discard') + if textarea.val().trim().length > 0 - form.find('.js-note-target-reopen').text('Comment & reopen') - form.find('.js-note-target-close').text('Comment & close') - form.find('.js-note-target-reopen').addClass('btn-comment-and-reopen') - form.find('.js-note-target-close').addClass('btn-comment-and-close') + reopentext = reopenbtn.data('alternative-text') + closetext = closebtn.data('alternative-text') + + if reopenbtn.text() isnt reopentext + reopenbtn.text(reopentext) + + if closebtn.text() isnt closetext + closebtn.text(closetext) + + if reopenbtn.is(':not(.btn-comment-and-reopen)') + reopenbtn.addClass('btn-comment-and-reopen') + + if closebtn.is(':not(.btn-comment-and-close)') + closebtn.addClass('btn-comment-and-close') + + if discardbtn.is(':hidden') + discardbtn.show() else - form.find('.js-note-target-reopen').text('Reopen') - form.find('.js-note-target-close').text('Close') - form.find('.js-note-target-reopen').removeClass('btn-comment-and-reopen') - form.find('.js-note-target-close').removeClass('btn-comment-and-close') + reopentext = reopenbtn.data('original-text') + closetext = closebtn.data('original-text') + + if reopenbtn.text() isnt reopentext + reopenbtn.text(reopentext) + + if closebtn.text() isnt closetext + closebtn.text(closetext) + + if reopenbtn.is(':not(.btn-comment-and-reopen)') + reopenbtn.removeClass('btn-comment-and-reopen') + + if closebtn.is(':not(.btn-comment-and-close)') + closebtn.removeClass('btn-comment-and-close') + + if discardbtn.is(':visible') + discardbtn.hide() initTaskList: -> @enableTaskList() diff --git a/app/assets/javascripts/profile.js.coffee b/app/assets/javascripts/profile.js.coffee index 9110b732adc..59d44c30bee 100644 --- a/app/assets/javascripts/profile.js.coffee +++ b/app/assets/javascripts/profile.js.coffee @@ -4,12 +4,13 @@ class @Profile $('.js-preferences-form').on 'change.preference', 'input[type=radio]', -> $(this).parents('form').submit() - $('.update-username form').on 'ajax:before', -> - $('.loading-gif').show() + $('.update-username').on 'ajax:before', -> + $('.loading-username').show() $(this).find('.update-success').hide() $(this).find('.update-failed').hide() - $('.update-username form').on 'ajax:complete', -> + $('.update-username').on 'ajax:complete', -> + $('.loading-username').hide() $(this).find('.btn-save').enable() $(this).find('.loading-gif').hide() diff --git a/app/assets/javascripts/projects_list.js.coffee b/app/assets/javascripts/projects_list.js.coffee index ed5206368ce..e4c4bf3b273 100644 --- a/app/assets/javascripts/projects_list.js.coffee +++ b/app/assets/javascripts/projects_list.js.coffee @@ -2,6 +2,7 @@ init: -> $(".projects-list-filter").off('keyup') this.initSearch() + this.initPagination() initSearch: -> @timer = null @@ -29,3 +30,8 @@ # Change url so if user reload a page - search results are saved history.replaceState {page: project_filter_url}, document.title, project_filter_url dataType: "json" + + initPagination: -> + $('.projects-list-holder .pagination').on('ajax:success', (e, data) -> + $('.projects-list-holder').replaceWith(data.html) + ) diff --git a/app/assets/javascripts/shortcuts.js.coffee b/app/assets/javascripts/shortcuts.js.coffee index 9c7c2474aa4..100e3aac535 100644 --- a/app/assets/javascripts/shortcuts.js.coffee +++ b/app/assets/javascripts/shortcuts.js.coffee @@ -4,11 +4,15 @@ class @Shortcuts Mousetrap.reset() Mousetrap.bind('?', @selectiveHelp) Mousetrap.bind('s', Shortcuts.focusSearch) + Mousetrap.bind(['ctrl+shift+p', 'command+shift+p'], @toggleMarkdownPreview) Mousetrap.bind('t', -> Turbolinks.visit(findFileURL)) if findFileURL? selectiveHelp: (e) => Shortcuts.showHelp(e, @enabledHelp) + toggleMarkdownPreview: (e) => + $(document).triggerHandler('markdown-preview:toggle', [e]) + @showHelp: (e, location) -> if $('#modal-shortcuts').length > 0 $('#modal-shortcuts').modal('show') @@ -35,3 +39,14 @@ $(document).on 'click.more_help', '.js-more-help-button', (e) -> $(@).remove() $('.hidden-shortcut').show() e.preventDefault() + +Mousetrap.stopCallback = (-> + defaultStopCallback = Mousetrap.stopCallback + + return (e, element, combo) -> + # allowed shortcuts if textarea, input, contenteditable are focused + if ['ctrl+shift+p', 'command+shift+p'].indexOf(combo) != -1 + return false + else + return defaultStopCallback.apply(@, arguments) +)() diff --git a/app/assets/javascripts/sidebar.js.coffee b/app/assets/javascripts/sidebar.js.coffee index cff309c5972..eea3f5ee910 100644 --- a/app/assets/javascripts/sidebar.js.coffee +++ b/app/assets/javascripts/sidebar.js.coffee @@ -1,8 +1,7 @@ -$(document).on("click", '.toggle-nav-collapse', (e) -> - e.preventDefault() - collapsed = 'page-sidebar-collapsed' - expanded = 'page-sidebar-expanded' +collapsed = 'page-sidebar-collapsed' +expanded = 'page-sidebar-expanded' +toggleSidebar = -> $('.page-with-sidebar').toggleClass("#{collapsed} #{expanded}") $('header').toggleClass("header-collapsed header-expanded") $('.sidebar-wrapper').toggleClass("sidebar-collapsed sidebar-expanded") @@ -14,4 +13,15 @@ $(document).on("click", '.toggle-nav-collapse', (e) -> niceScrollBars.updateScrollBar(); ), 300 +$(document).on("click", '.toggle-nav-collapse', (e) -> + e.preventDefault() + + toggleSidebar() ) + +$ -> + size = bp.getBreakpointSize() + + if size is "xs" or size is "sm" + if $('.page-with-sidebar').hasClass(expanded) + toggleSidebar() diff --git a/app/assets/javascripts/users_select.js.coffee b/app/assets/javascripts/users_select.js.coffee index 9467011799f..987c6f4b8d2 100644 --- a/app/assets/javascripts/users_select.js.coffee +++ b/app/assets/javascripts/users_select.js.coffee @@ -3,6 +3,81 @@ class @UsersSelect @usersPath = "/autocomplete/users.json" @userPath = "/autocomplete/users/:id.json" + $('.js-user-search').each (i, dropdown) => + @projectId = $(dropdown).data('project-id') + @showCurrentUser = $(dropdown).data('current-user') + showNullUser = $(dropdown).data('null-user') + showAnyUser = $(dropdown).data('any-user') + firstUser = $(dropdown).data('first-user') + selectedId = $(dropdown).data('selected') + + $(dropdown).glDropdown( + data: (term, callback) => + @users term, (users) => + if term.length is 0 + showDivider = 0 + + if firstUser + # Move current user to the front of the list + for obj, index in users + if obj.username == firstUser + users.splice(index, 1) + users.unshift(obj) + break + + if showNullUser + showDivider += 1 + users.unshift( + name: 'Unassigned', + id: 0 + ) + + if showAnyUser + showDivider += 1 + name = showAnyUser + name = 'Any User' if name == true + anyUser = { + name: name, + id: null + } + users.unshift(anyUser) + + if showDivider + users.splice(showDivider, 0, "divider") + + # Send the data back + callback users + filterable: true + filterRemote: true + search: + fields: ['name', 'username'] + selectable: true + fieldName: $(dropdown).data('field-name') + clicked: -> + if $(dropdown).hasClass "js-filter-submit" + $(dropdown).parents('form').submit() + renderRow: (user) -> + username = if user.username then "@#{user.username}" else "" + avatar = if user.avatar_url then user.avatar_url else false + selected = if user.id is selectedId then "is-active" else "" + img = "" + + if avatar + img = "<img src='#{avatar}' class='avatar avatar-inline' width='30' />" + + "<li> + <a href='#' class='dropdown-menu-user-link #{selected}'> + #{img} + <strong class='dropdown-menu-user-full-name'> + #{user.name} + </strong> + <span class='dropdown-menu-user-username'> + #{username} + </span> + </a> + </li>" + ) + $('.ajax-users-select').each (i, select) => @projectId = $(select).data('project-id') @groupId = $(select).data('group-id') diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index d7e4153ddc0..d20b77ffae9 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -28,6 +28,10 @@ border-bottom: 1px solid $border-color; color: $gl-gray; + a { + color: $md-link-color; + } + &.oneline-block { line-height: 42px; } @@ -116,6 +120,10 @@ .cover-desc { padding: 0 $gl-padding 3px; color: $gl-text-color; + + &.username:last-child { + padding-bottom: $gl-padding; + } } .cover-controls { @@ -153,3 +161,7 @@ float: right; } } + +.content-block-small { + padding: 10px 0; +} diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index c98e43ad09f..ff551f151f1 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -12,11 +12,13 @@ .prepend-top-default { margin-top: $gl-padding !important; } .prepend-top-20 { margin-top:20px } .prepend-left-10 { margin-left:10px } -.prepend-left-default { margin-left:$gl-padding } +.prepend-left-default { margin-left: $gl-padding; } .prepend-left-20 { margin-left:20px } .append-right-5 { margin-right: 5px } .append-right-10 { margin-right:10px } +.append-right-default { margin-right: $gl-padding; } .append-right-20 { margin-right:20px } +.append-bottom-0 { margin-bottom:0 } .append-bottom-10 { margin-bottom:10px } .append-bottom-15 { margin-bottom:15px } .append-bottom-20 { margin-bottom:20px } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index d4878b333f9..5b647fc6176 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -17,6 +17,47 @@ .dropdown-menu { display: block; } + + .dropdown-menu-toggle { + border-color: $dropdown-toggle-hover-border-color; + + .fa { + color: $dropdown-toggle-hover-icon-color; + } + } +} + +.dropdown-menu-toggle { + position: relative; + width: 160px; + padding: 6px 20px 6px 10px; + background-color: $dropdown-toggle-bg; + color: $dropdown-toggle-color; + font-size: 15px; + text-align: left; + border: 1px solid $dropdown-toggle-border-color; + border-radius: 2px; + outline: 0; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + + .fa { + position: absolute; + top: 50%; + right: 6px; + margin-top: -4px; + color: $dropdown-toggle-icon-color; + font-size: 10px; + } + + &:hover, { + border-color: $dropdown-toggle-hover-border-color; + + .fa { + color: $dropdown-toggle-hover-icon-color; + } + } } .dropdown-menu { @@ -24,7 +65,7 @@ position: absolute; top: 100%; left: 0; - z-index: 9999; + z-index: 9; width: 240px; margin-top: 2px; margin-bottom: 0; @@ -36,6 +77,21 @@ border-radius: $border-radius-base; box-shadow: 0 2px 4px $dropdown-shadow-color; + &.is-loading { + .dropdown-content { + display: none; + } + + .dropdown-loading { + display: block; + } + } + + ul { + margin: 0; + padding: 0; + } + li { text-align: left; list-style: none; @@ -61,13 +117,70 @@ white-space: nowrap; overflow: hidden; - &:hover { + &:hover, + &:focus, + &.is-focused { background-color: $dropdown-link-hover-bg; text-decoration: none; + outline: 0; } } } +.dropdown-menu-paging { + .dropdown-page-two, + .dropdown-menu-back { + display: none; + } + + &.is-page-two { + .dropdown-page-one { + display: none; + } + + .dropdown-page-two, + .dropdown-menu-back { + display: block; + } + } +} + +.dropdown-menu-user { + .avatar { + float: left; + width: 30px; + height: 30px; + margin: 0 10px 0 0; + } +} + +.dropdown-menu-user-link { + padding-top: 7px; + padding-bottom: 7px; +} + +.dropdown-menu-user-full-name { + display: block; + margin-bottom: 2px; + font-weight: 600; + line-height: 1; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.dropdown-menu-user-username { + display: block; + line-height: 1; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.dropdown-select { + width: 280px; +} + .dropdown-menu-align-right { left: auto; right: 0; @@ -81,8 +194,9 @@ &::before { content: "\f00c"; position: absolute; - left: 4px; - top: 8px; + left: 5px; + top: 50%; + margin-top: -7px; font: normal normal normal 14px/1 FontAwesome; font-size: inherit; text-rendering: auto; @@ -94,9 +208,136 @@ } .dropdown-header { - padding-left: 10px; - padding-right: 10px; + padding-left: 5px; + padding-right: 5px; color: $dropdown-header-color; font-size: 13px; line-height: 22px; } + +.dropdown-title { + position: relative; + margin-bottom: 10px; + padding-left: 30px; + padding-right: 30px; + padding-bottom: 10px; + font-weight: 600; + line-height: 1; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + border-bottom: 1px solid $dropdown-divider-color; + overflow: hidden; +} + +.dropdown-title-button { + position: absolute; + top: -1px; + padding: 0; + color: $dropdown-title-btn-color; + font-size: 14px; + border: 0; + background: none; + outline: 0; + + &:hover { + color: darken($dropdown-title-btn-color, 15%); + } +} + +.dropdown-menu-close { + right: 0; +} + +.dropdown-menu-back { + left: 0; +} + +.dropdown-input { + position: relative; + margin-bottom: 10px; + + .fa { + position: absolute; + top: 10px; + right: 10px; + color: #C7C7C7; + font-size: 12px; + pointer-events: none; + } +} + +.dropdown-input-field { + width: 100%; + padding: 0 7px; + color: $dropdown-input-color; + line-height: 30px; + border: 1px solid $dropdown-divider-color; + border-radius: 2px; + outline: 0; + + &:focus { + color: $dropdown-link-color; + border-color: $dropdown-input-focus-border; + box-shadow: 0 0 4px $dropdown-input-focus-shadow; + + + .fa { + color: $dropdown-link-color; + } + } + + &:hover { + + .fa { + color: $dropdown-link-color; + } + } +} + +.dropdown-content { + max-height: 215px; + overflow-y: scroll; +} + +.dropdown-footer { + padding-top: 10px; + margin-top: 10px; + font-size: 13px; + border-top: 1px solid $dropdown-divider-color; +} + +.dropdown-footer-list { + font-size: 14px; + + a { + padding-left: 10px; + } +} + +.dropdown-loading { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + display: none; + z-index: 9; + background-color: $dropdown-loading-bg; + font-size: 28px; + + .fa { + position: absolute; + top: 50%; + left: 50%; + margin-top: -14px; + margin-left: -14px; + } +} + +.dropdown-menu-labels { + .label { + position: relative; + width: 30px; + margin-right: 5px; + text-indent: -99999px; + } +} diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 07907e6e5a6..b034a4882c1 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -169,6 +169,7 @@ */ &.code { padding: 0; + -webkit-overflow-scrolling: auto; // See https://gitlab.com/gitlab-org/gitlab-ce/issues/13987 } } } diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index eab41628677..c431e2b0df3 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -1,5 +1,6 @@ .filter-item { margin-right: 6px; + vertical-align: top; } @media (min-width: 800px) { diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index e624982c5c9..4c4033e3ae7 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -141,22 +141,18 @@ header { margin-left: $sidebar_collapsed_width; } -@media (max-width: $screen-md-max) { - .header-collapsed { - margin-left: $sidebar_collapsed_width; - } - - .header-expanded { - margin-left: $sidebar_width; - } -} +.header-collapsed { + margin-left: $sidebar_collapsed_width; -@media(min-width: $screen-md-max) { - .header-collapsed { + @media (min-width: $screen-md-min) { @include collapsed-header; } +} + +.header-expanded { + margin-left: $sidebar_collapsed_width; - .header-expanded { + @media (min-width: $screen-md-min) { margin-left: $sidebar_width; } } diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss index 7de874c8bcd..b2fbc95e043 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -63,7 +63,7 @@ border-bottom: none; /* Small devices (phones, tablets, 768px and lower) */ - @media (max-width: $screen-sm-min) { + @media (max-width: $screen-sm-max) { width: 100%; } } diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 6b382e4b1b2..26df9acd2ae 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -34,12 +34,12 @@ @media (min-width: $screen-sm-min) { padding-right: $gutter_width; } - + } } .sidebar-wrapper { - z-index: 99; + z-index: 999; background: $background-color; } @@ -203,7 +203,11 @@ } @mixin expanded-sidebar { - padding-left: $sidebar_width; + padding-left: $sidebar_collapsed_width; + + @media (min-width: $screen-md-min) { + padding-left: $sidebar_width; + } &.right-sidebar-collapsed { /* Extra small devices (phones, less than 768px) */ diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 48570abff49..9381cb3281c 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -149,13 +149,13 @@ } &:hover > a.anchor { - $size: 16px; + $size: 14px; position: absolute; right: 100%; top: 50%; - margin-top: -$size/2; - margin-right: 0px; - padding-right: 20px; + margin-top: -11px; + margin-right: 0; + padding-right: 15px; display: inline-block; width: $size; height: $size; diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index cc84a5ff932..d491d01a3cf 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -34,13 +34,15 @@ $error-exclamation-point: #E62958; $border-radius-default: 3px; $list-title-color: #333333; $list-text-color: #555555; -$profile-settings-link-color: $md-link-color; $btn-transparent-color: #8F8F8F; $ssh-key-icon-color: #8F8F8F; $ssh-key-icon-size: 18px; +$provider-btn-group-border: #E5E5E5; +$provider-btn-not-active-color: #4688F1; + /* * Color schema */ @@ -70,7 +72,7 @@ $orange-light: rgba(252, 109, 38, 0.80); $orange-normal: #E75E40; $orange-dark: #CE5237; -$red-light: #F43263; +$red-light: #F06559; $red-normal: #E52C5A; $red-dark: #D22852; @@ -94,13 +96,17 @@ $border-orange-light: #fc6d26; $border-orange-normal: #CE5237; $border-orange-dark: #C14E35; -$border-red-light: #E52C5A; +$border-red-light: #F24F41; $border-red-normal: #D22852; $border-red-dark: #CA264F; $help-well-bg: #FAFAFA; $help-well-border: #E5E5E5; +$warning-message-bg: #FBF2D9; +$warning-message-color: #9E8E60; +$warning-message-border: #F0E2BB; + /* header */ $light-grey-header: #faf9f9; @@ -138,3 +144,22 @@ $dropdown-shadow-color: rgba(#000, .1); $dropdown-divider-color: rgba(#000, .1); $dropdown-header-color: #959494; $dropdown-caret-color: #54565B; +$dropdown-title-btn-color: #BFBFBF; +$dropdown-input-color: #C7C7C7; +$dropdown-input-focus-border: rgb(58, 171, 240); +$dropdown-input-focus-shadow: rgba(#000, .2); +$dropdown-loading-bg: rgba(#fff, .6); + +$dropdown-toggle-bg: #fff; +$dropdown-toggle-color: #626262; +$dropdown-toggle-border-color: #EAEAEA; +$dropdown-toggle-hover-border-color: darken($dropdown-toggle-border-color, 15%); +$dropdown-toggle-icon-color: #C4C4C4; +$dropdown-toggle-hover-icon-color: $dropdown-toggle-hover-border-color; + +/* + * Award emoji + */ +$award-emoji-menu-bg: #FFF; +$award-emoji-menu-border: #F1F2F4; +$award-emoji-new-btn-icon-color: #DCDCDC; diff --git a/app/assets/stylesheets/pages/awards.scss b/app/assets/stylesheets/pages/awards.scss index 87dd30f4111..28994e60baa 100644 --- a/app/assets/stylesheets/pages/awards.scss +++ b/app/assets/stylesheets/pages/awards.scss @@ -1,125 +1,133 @@ .awards { - @include clearfix; line-height: 34px; .emoji-icon { width: 20px; height: 20px; - margin: 7px 0 0 5px; } +} - .award { - @include border-radius(5px); - - border: 1px solid; - padding: 0px 10px; - float: left; - margin-right: 5px; - border-color: $border-color; - cursor: pointer; +.emoji-menu { + position: absolute; + top: 100%; + left: 0; + margin-top: 3px; + z-index: 1000; + min-width: 160px; + font-size: 14px; + background-color: $award-emoji-menu-bg; + border: 1px solid $award-emoji-menu-border; + border-radius: $border-radius-base; + box-shadow: 0 6px 12px rgba(0,0,0,.175); + pointer-events: none; + opacity: 0; + transform: scale(.2); + transform-origin: 0 -45px; + transition: all .3s cubic-bezier(.87,-.41,.19,1.44); + + &.is-visible { + pointer-events: all; + opacity: 1; + transform: scale(1); + } - &:hover { - background-color: #dce0e5; + .emoji-menu-content { + padding: $gl-padding; + width: 300px; + height: 300px; + overflow-y: scroll; + + input.emoji-search{ + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAFu0lEQVRIia1WTahkVxH+quqce7vf6zdvJpHoIlkYJ2SiJiIokmQjgoGgIAaEIYuYXWICgojiwkmC4taFwhjcyIDusogEIwwiSSCKPwsdwzAg0SjJ9Izzk5n3+nXfe8+pqizOvd395scfsJqi6dPnnDr11Vc/NJ1OwUTosqJLCmYCHCAC2mSHs+ojZv6AO46Y+20AhIneJsafhPhXVZSXDk7qi+aOLhtQNuBmQtcarAKjTXpn2+l3u2yPunvZSABRucjcAV/eMZuM48/Go/g1d19kc4wq+e8MZjWkbI/P5t2P3RFFbv7SQdyBlBUx8N8OTuqjMcof+N94yMPrY2DMm/ytnb32J0QrY+6AqsHM4Q64O9SKDmerKDD3Oy/tNL9vk342CC8RuU6n0ymCMHb22scu7zQngtASOjUHE1BX4UUAv4b7Ow6qiXCXuz/UdvogAAweDY943/b4cAz0ZlYHXeMsnT07RVb7wMUr8ykI4H5HVkMd5Rcb4/jNURVOL5qErAaAUUdCCIJ5kx5q2nw8m39ImEAAsjpE6PStB0YfMcd1wqqG3Xn7A3PfZyyKnNjaqD4fmE/fCNKshirIyY1xvI+Av6g5QIAIIWX7cJPssboSiBBEeKmsZne0Sb8kzAUWNYyq8NvbDo0fZ6beqxuLmqOOMr/lwOh+YXpXtbjERGja9JyZ9+HxpXKb9Gj5oywRESbj+Cj1ENG1QViTGBl1FbC1We1tbVRfHWIoQkhqH9xbpE92XUbb6VJZ1R4crjRz1JWcDMJvLdoMcyAEhjuwHo8Bfndg3mbszhOY+adVlMtD3po51OwzIQiEaams7oeJhxRw1FFOVpFRRUYIhMBAFRnjOsC8IFHHUA4TQQhgAqpAiIFfGbxkIqj54ayGbL7UoOqHCniAEKHLNr26l+D9wQJzeUwMAnfHvEnLECzZRwRV++d60ptjW9VLZeolEJG6GwCCE0CFVNB+Ay0NEqoQYG4YYFu7B8IEVRt3uRzy/osIoLV9QZimWXGHUMFdmI6M64DUF2Je88R9VZqCSP+QlcF5k+4tCzSsXaqjINuK6UyE0+s/mk6/qFq8oAIL9pqMLhkGsNrOyoOIlszust3aJv0U9+kFdwjTGwWl1YdF+KWlQSZ0Se/psj8yGVdg5tJyfH96EBWmLtoEMwMzMFt031NzGWLLzKhC+KV7H5ZeeaMOPxemma2x68puc0LN3+/u6LJiePS6MKHvn4wu6cPzJj0hsioeMfDrEvjv5r6W9gBvjKJujuKzQ0URIZj75NylvT+mbHfXQa4rwAMaVRTMm/SFyzvNy0yF6+4AM+1ubcSnqkAIUjQKl1RKSbE5jt+vovx1MBqF0WW7/d1Z80ab9BtmuJ3Xk5cJKds9TZt/uLPXvtiTrQ+dIwqfAejUvM1os6FNikXKUHfQ+ekUsXT5u85enJ0CaBSkkGEo1syUQ+DfMdE/4GA1uzupf9zdbzhOmLsF4efHVXjaHHAzmDtGdQRd/Nc5wAEJjNki3XfhyvwVNz80xANrht3LsENY9cBBdN1L9GUyyvFRFZ42t75sBvCQRykbRlU4tT2pPxoCvzx09d4GmPs200M6wKdWSDGK8mppYSWdhAlt0qeaLv+IadXU9/Evq4FAZ8ej+LmtcTxaRX4NWI0Uag5Vg1p5MYg8BnlhXIdPHDow+vTWZvVMVttXDLqkTzZdPj6Qii6cP1cSvIdl3iQkNYyi9HH0I22y+93tY3DcQkTZgQtM+POoCr8x97eylkmtrgKuztrvXJ21x/aNKuqIkZ/fntRfCdcTfhUTAIhRzoDojJD0aSNLLwMzmpT7+JaLtyf1MwDo6qz9djFaUq3t9MlFmy/c1OCSceY9fMsVaL9mvH9ocXdkdWxv1scAePG0THAhMOaLdOw/Gvxfxb1w4eCapyIENUcV5M3/u8FitAxZ25P6GAHT3UX39Srw+QOb1ZffA98Dl2Wy1BYkAAAAAElFTkSuQmCC"); + background-repeat: no-repeat; + background-position: right 5px center; + background-size: 16px; } + } +} - &.active { - border-color: $border-gray-light; - background-color: $gray-light; - - &:hover { - background-color: #dce0e5; - } +.emoji-menu-list { + list-style: none; + padding-left: 0; + margin-bottom: 0; +} - .counter { - font-weight: bold; - } - } +.emoji-menu-list-item { + padding: 3px; + margin-left: 1px; + margin-right: 1px; +} - .icon { - float: left; - margin-right: 10px; - } +.emoji-menu-btn { + display: block; + cursor: pointer; + width: 30px; + height: 30px; + padding: 0; + background: none; + border: 0; + border-radius: $border-radius-base; + transition: transform .15s cubic-bezier(.3, 0, .2, 2); + + &:hover { + background-color: transparent; + outline: 0; + transform: scale(1.3); + } - .counter { - float: left; - } + &:focus, + &:active { + outline: 0; } - .awards-controls { + .emoji-icon { + display: inline-block; position: relative; - margin-left: 10px; - float: left; + top: 3px; + } +} - .add-award { - font-size: 24px; - color: $gl-gray; - position: relative; - top: 2px; +.award-menu-holder { + display: inline-block; + position: relative; +} - &:hover, - &:link { - text-decoration: none; - } - } +.award-control { + margin-right: 5px; + padding-left: 5px; + padding-right: 5px; + line-height: 20px; + outline: 0; + + &.active, + &:active { + background-color: $white-dark; + box-shadow: none; + outline: 0; + } - .emoji-menu{ - position: absolute; - top: 100%; - left: 0; - z-index: 1000; + &.is-loading { + .award-control-icon { display: none; - float: left; - min-width: 160px; - padding: 5px 0; - margin: 2px 0 0; - font-size: 14px; - text-align: left; - list-style: none; - background-color: #fff; - -webkit-background-clip: padding-box; - background-clip: padding-box; - border: 1px solid #ccc; - border: 1px solid rgba(0,0,0,.15); - border-radius: 4px; - -webkit-box-shadow: 0 6px 12px rgba(0,0,0,.175); - box-shadow: 0 6px 12px rgba(0,0,0,.175); - - .emoji-menu-content { - padding: $gl-padding; - width: 300px; - height: 300px; - overflow-y: scroll; - - h5 { - clear: left; - } - - ul { - list-style-type: none; - margin-left: -20px; - margin-bottom: 20px; - overflow: auto; - } - - input.emoji-search{ - background: image-url("icon-search.png") 240px no-repeat; - } - - li { - cursor: pointer; - width: 30px; - height: 30px; - text-align: center; - float: left; - margin: 3px; - list-decorate: none; - @include border-radius(5px); - - &:hover { - background-color: #ccc; - } - } - } } + + .award-control-icon-loading { + display: block; + } + } + + .icon, + .award-control-icon { + float: left; + margin-right: 5px; + font-size: 20px; + } + + .award-control-icon-loading { + display: none; + } + + .award-control-icon { + color: $award-emoji-new-btn-icon-color; } } diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 3c2997c1d5a..75f298019e3 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -27,10 +27,25 @@ } .scroll-controls { - position: fixed; - bottom: 10px; - left: 250px; - z-index: 100; + &.affix-top { + position: absolute; + top: 10px; + right: 25px; + } + + &.affix-bottom { + position: absolute; + right: 25px; + } + + &.affix { + right: 30px; + bottom: 15px; + + @media (min-width: $screen-md-min) { + right: 26%; + } + } a { display: block; diff --git a/app/assets/stylesheets/pages/commit.scss b/app/assets/stylesheets/pages/commit.scss index e53d6fc6bdc..c0cc30d33a6 100644 --- a/app/assets/stylesheets/pages/commit.scss +++ b/app/assets/stylesheets/pages/commit.scss @@ -90,6 +90,7 @@ position: relative; font-family: $monospace_font; $left: 12px; + overflow: hidden; // See https://gitlab.com/gitlab-org/gitlab-ce/issues/13987 .max-width-marker { width: 72ch; color: rgba(0, 0, 0, 0.0); diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 1c78aafdb87..5ec0966194c 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -7,6 +7,28 @@ display: inline-block; margin-right: 10px; } + + &.suggest-colors-dropdown { + margin-bottom: 5px; + + a { + @include border-radius(0); + width: 36.7px; + margin-right: 0; + margin-bottom: -5px; + } + } +} + +.dropdown-label-color-preview { + display: none; + margin-top: 5px; + width: 100%; + height: 25px; + + &.is-active { + display: block; + } } .label-row { diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 4826b994e37..90094189b55 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -1,7 +1,6 @@ -.account-page { - fieldset { - margin-bottom: 15px; - padding-bottom: 15px; +.profile-avatar-form-option { + hr { + margin: 10px 0; } } @@ -20,7 +19,7 @@ .account-btn-link, .profile-settings-sidebar a { - color: $profile-settings-link-color; + color: $md-link-color; } .oauth-buttons { @@ -172,6 +171,65 @@ .profile-settings-content { a { - color: $profile-settings-link-color; + color: $md-link-color; + } +} + +.change-username-title { + color: $gl-warning; +} + +.remove-account-title { + color: $gl-danger; +} + +.provider-btn-group { + display: inline-block; + margin-right: 10px; + border: 1px solid $provider-btn-group-border; + border-radius: 3px; + + &:last-child { + margin-right: 0; + } +} + +.provider-btn-image { + display: inline-block; + padding: 5px 10px; + border-right: 1px solid $provider-btn-group-border; + + > img { + width: 20px; + } +} + +.provider-btn { + display: inline-block; + padding: 5px 10px; + margin-left: -3px; + line-height: 22px; + background-color: $gray-light; + + &.not-active { + color: $provider-btn-not-active-color; + } +} + +.profile-settings-message { + line-height: 32px; + color: $warning-message-color; + background-color: $warning-message-bg; + border: 1px solid $warning-message-border; + border-radius: $border-radius-base; +} + +.oauth-applications { + form { + display: inline-block; + } + + .last-heading { + width: 105px; } } diff --git a/app/assets/stylesheets/pages/snippets.scss b/app/assets/stylesheets/pages/snippets.scss index 0161642d871..639d639d5b0 100644 --- a/app/assets/stylesheets/pages/snippets.scss +++ b/app/assets/stylesheets/pages/snippets.scss @@ -26,5 +26,13 @@ margin-right: 10px; font-size: $gl-font-size; border: 1px solid; - line-height: 40px; + line-height: 32px; +} + +.markdown-snippet-copy { + position: fixed; + top: -10px; + left: -10px; + max-height: 0; + max-width: 0; } diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index 4d3e48f7f81..668396a0f20 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -55,7 +55,7 @@ class Admin::GroupsController < Admin::ApplicationController private def group - @group = Group.find_by(path: params[:id]) + @group ||= Group.find_by(path: params[:id]) end def group_params diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index fb74919ea23..1f55b18e0b1 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -246,6 +246,8 @@ class ApplicationController < ActionController::Base def ldap_security_check if current_user && current_user.requires_ldap_check? + return unless current_user.try_obtain_ldap_lease + unless Gitlab::LDAP::Access.allowed?(current_user) sign_out current_user flash[:alert] = "Access denied for your LDAP account." diff --git a/app/controllers/concerns/continue_params.rb b/app/controllers/concerns/continue_params.rb new file mode 100644 index 00000000000..0a995c45bdf --- /dev/null +++ b/app/controllers/concerns/continue_params.rb @@ -0,0 +1,13 @@ +module ContinueParams + extend ActiveSupport::Concern + + def continue_params + continue_params = params[:continue] + return nil unless continue_params + + continue_params = continue_params.permit(:to, :notice, :notice_now) + return unless continue_params[:to] && continue_params[:to].start_with?('/') + + continue_params + end +end diff --git a/app/controllers/concerns/filter_projects.rb b/app/controllers/concerns/filter_projects.rb new file mode 100644 index 00000000000..f63b703d101 --- /dev/null +++ b/app/controllers/concerns/filter_projects.rb @@ -0,0 +1,15 @@ +# == FilterProjects +# +# Controller concern to handle projects filtering +# * by name +# * by archived state +# +module FilterProjects + extend ActiveSupport::Concern + + def filter_projects(projects) + projects = projects.search(params[:filter_projects]) if params[:filter_projects].present? + projects = projects.non_archived if params[:archived].blank? + projects + end +end diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index dc880b634e5..0e8b63872ca 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -1,18 +1,15 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController + include FilterProjects + before_action :event_filter def index - @projects = current_user.authorized_projects.sorted_by_activity.non_archived - @projects = @projects.sort(@sort = params[:sort]) + @projects = current_user.authorized_projects.sorted_by_activity + @projects = filter_projects(@projects) @projects = @projects.includes(:namespace) + @projects = @projects.sort(@sort = params[:sort]) + @projects = @projects.page(params[:page]).per(PER_PAGE) - terms = params[:filter_projects] - - if terms.present? - @projects = @projects.search(terms) - end - - @projects = @projects.page(params[:page]).per(PER_PAGE) if terms.blank? @last_push = current_user.recent_push respond_to do |format| @@ -32,16 +29,11 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController def starred @projects = current_user.starred_projects.sorted_by_activity + @projects = filter_projects(@projects) @projects = @projects.includes(:namespace, :forked_from_project, :tags) @projects = @projects.sort(@sort = params[:sort]) + @projects = @projects.page(params[:page]).per(PER_PAGE) - terms = params[:filter_projects] - - if terms.present? - @projects = @projects.search(terms) - end - - @projects = @projects.page(params[:page]).per(PER_PAGE) if terms.blank? @last_push = current_user.recent_push @groups = [] diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb index a384f3004db..8271ca87436 100644 --- a/app/controllers/explore/projects_controller.rb +++ b/app/controllers/explore/projects_controller.rb @@ -1,14 +1,14 @@ class Explore::ProjectsController < Explore::ApplicationController + include FilterProjects + def index @projects = ProjectsFinder.new.execute(current_user) @tags = @projects.tags_on(:tags) @projects = @projects.tagged_with(params[:tag]) if params[:tag].present? @projects = @projects.where(visibility_level: params[:visibility_level]) if params[:visibility_level].present? - @projects = @projects.non_archived - @projects = @projects.search(params[:search]) if params[:search].present? - @projects = @projects.search(params[:filter_projects]) if params[:filter_projects].present? + @projects = filter_projects(@projects) @projects = @projects.sort(@sort = params[:sort]) - @projects = @projects.includes(:namespace).page(params[:page]).per(PER_PAGE) if params[:filter_projects].blank? + @projects = @projects.includes(:namespace).page(params[:page]).per(PER_PAGE) respond_to do |format| format.html @@ -22,9 +22,8 @@ class Explore::ProjectsController < Explore::ApplicationController def trending @projects = TrendingProjectsFinder.new.execute(current_user) - @projects = @projects.non_archived - @projects = @projects.search(params[:filter_projects]) if params[:filter_projects].present? - @projects = @projects.page(params[:page]).per(PER_PAGE) if params[:filter_projects].blank? + @projects = filter_projects(@projects) + @projects = @projects.page(params[:page]).per(PER_PAGE) respond_to do |format| format.html @@ -38,9 +37,9 @@ class Explore::ProjectsController < Explore::ApplicationController def starred @projects = ProjectsFinder.new.execute(current_user) - @projects = @projects.search(params[:filter_projects]) if params[:filter_projects].present? + @projects = filter_projects(@projects) @projects = @projects.reorder('star_count DESC') - @projects = @projects.page(params[:page]).per(PER_PAGE) if params[:filter_projects].blank? + @projects = @projects.page(params[:page]).per(PER_PAGE) respond_to do |format| format.html diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index ca5ce1e2046..06c5c8be9a5 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -1,4 +1,5 @@ class GroupsController < Groups::ApplicationController + include FilterProjects include IssuesAction include MergeRequestsAction @@ -14,7 +15,7 @@ class GroupsController < Groups::ApplicationController # Load group projects before_action :load_projects, except: [:index, :new, :create, :projects, :edit, :update, :autocomplete] - before_action :event_filter, only: [:show, :events] + before_action :event_filter, only: [:activity] layout :determine_layout @@ -41,9 +42,12 @@ class GroupsController < Groups::ApplicationController def show @last_push = current_user.recent_push if current_user @projects = @projects.includes(:namespace) - @projects = @projects.search(params[:filter_projects]) if params[:filter_projects].present? + @projects = filter_projects(@projects) + @projects = @projects.sort(@sort = params[:sort]) @projects = @projects.page(params[:page]).per(PER_PAGE) if params[:filter_projects].blank? + @shared_projects = @group.shared_projects + respond_to do |format| format.html @@ -60,8 +64,10 @@ class GroupsController < Groups::ApplicationController end end - def events + def activity respond_to do |format| + format.html + format.json do load_events pager_json("events/_events", @events.count) @@ -98,7 +104,7 @@ class GroupsController < Groups::ApplicationController end def load_projects - @projects ||= ProjectsFinder.new.execute(current_user, group: group).sorted_by_activity.non_archived + @projects ||= ProjectsFinder.new.execute(current_user, group: group).sorted_by_activity end # Dont allow unauthorized access to group @@ -129,7 +135,7 @@ class GroupsController < Groups::ApplicationController end def group_params - params.require(:group).permit(:name, :description, :path, :avatar, :public) + params.require(:group).permit(:name, :description, :path, :avatar, :public, :share_with_group_lock) end def load_events diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb index dc22101cd5e..d1e4ac10f6c 100644 --- a/app/controllers/oauth/applications_controller.rb +++ b/app/controllers/oauth/applications_controller.rb @@ -8,7 +8,7 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController layout 'profile' def index - head :forbidden and return + set_index_vars end def create @@ -20,18 +20,11 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create]) redirect_to oauth_application_url(@application) else - render :new + set_index_vars + render :index end end - def destroy - if @application.destroy - flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :destroy]) - end - - redirect_to applications_profile_url - end - private def verify_user_oauth_applications_enabled @@ -40,6 +33,17 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController redirect_to applications_profile_url end + def set_index_vars + @applications = current_user.oauth_applications + @authorized_tokens = current_user.oauth_authorized_tokens + @authorized_anonymous_tokens = @authorized_tokens.reject(&:application) + @authorized_apps = @authorized_tokens.map(&:application).uniq.reject(&:nil?) + + # Don't overwrite a value possibly set by `create` + @application ||= Doorkeeper::Application.new + end + + # Override Doorkeeper to scope to the current user def set_application @application = current_user.oauth_applications.find(params[:id]) end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index fa7a1148961..50b8f38eecb 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -8,13 +8,6 @@ class ProfilesController < Profiles::ApplicationController def show end - def applications - @applications = current_user.oauth_applications - @authorized_tokens = current_user.oauth_authorized_tokens - @authorized_anonymous_tokens = @authorized_tokens.reject(&:application) - @authorized_apps = @authorized_tokens.map(&:application).uniq - [nil] - end - def update user_params.except!(:email) if @user.ldap_user? diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb index 7b202f3862f..a1b8632df98 100644 --- a/app/controllers/projects/forks_controller.rb +++ b/app/controllers/projects/forks_controller.rb @@ -1,4 +1,6 @@ class Projects::ForksController < Projects::ApplicationController + include ContinueParams + # Authorize before_action :require_non_empty_project before_action :authorize_download_code! @@ -53,15 +55,4 @@ class Projects::ForksController < Projects::ApplicationController render :error end end - - private - - def continue_params - continue_params = params[:continue] - if continue_params - continue_params.permit(:to, :notice, :notice_now) - else - nil - end - end end diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb new file mode 100644 index 00000000000..4159e53bfa9 --- /dev/null +++ b/app/controllers/projects/group_links_controller.rb @@ -0,0 +1,23 @@ +class Projects::GroupLinksController < Projects::ApplicationController + layout 'project_settings' + before_action :authorize_admin_project! + + def index + @group_links = project.project_group_links.all + end + + def create + link = project.project_group_links.new + link.group_id = params[:link_group_id] + link.group_access = params[:link_group_access] + link.save + + redirect_to namespace_project_group_links_path(project.namespace, project) + end + + def destroy + project.project_group_links.find(params[:id]).destroy + + redirect_to namespace_project_group_links_path(project.namespace, project) + end +end diff --git a/app/controllers/projects/imports_controller.rb b/app/controllers/projects/imports_controller.rb index 196996f1752..7756f0f0ed3 100644 --- a/app/controllers/projects/imports_controller.rb +++ b/app/controllers/projects/imports_controller.rb @@ -1,4 +1,6 @@ class Projects::ImportsController < Projects::ApplicationController + include ContinueParams + # Authorize before_action :authorize_admin_project! before_action :require_no_repo, only: [:new, :create] @@ -44,16 +46,6 @@ class Projects::ImportsController < Projects::ApplicationController private - def continue_params - continue_params = params[:continue] - - if continue_params - continue_params.permit(:to, :notice, :notice_now) - else - nil - end - end - def finished_notice if @project.forked? 'The project was successfully forked.' diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index 8364fc293b7..e7bddc4a6f1 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -27,6 +27,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController end @project_member = @project.project_members.new + @project_group_links = @project.project_group_links end def create diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index aea08ecce3e..36f37221c58 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -1,7 +1,6 @@ class ProjectsController < ApplicationController include ExtractsPath - prepend_before_action :render_go_import, only: [:show] skip_before_action :authenticate_user!, only: [:show, :activity] before_action :project, except: [:new, :create] before_action :repository, except: [:new, :create] @@ -173,10 +172,15 @@ class ProjectsController < ApplicationController def housekeeping ::Projects::HousekeepingService.new(@project).execute - respond_to do |format| - flash[:notice] = "Housekeeping successfully started." - format.html { redirect_to project_path(@project) } - end + redirect_to( + project_path(@project), + notice: "Housekeeping successfully started" + ) + rescue ::Projects::HousekeepingService::LeaseTaken => ex + redirect_to( + edit_project_path(@project), + alert: ex.to_s + ) end def toggle_star @@ -242,16 +246,6 @@ class ProjectsController < ApplicationController end end - def render_go_import - return unless params["go-get"] == "1" - - @namespace = params[:namespace_id] - @id = params[:project_id] || params[:id] - @id = @id.gsub(/\.git\Z/, "") - - render "go_import", layout: false - end - def repo_exists? project.repository_exists? && !project.empty_repo? end diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index c88a420b412..19e8c7a92be 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -244,10 +244,17 @@ class IssuableFinder items end + def filter_by_upcoming_milestone? + params[:milestone_title] == '#upcoming' + end + def by_milestone(items) if milestones? if filter_by_no_milestone? items = items.where(milestone_id: [-1, nil]) + elsif filter_by_upcoming_milestone? + upcoming = Milestone.where(project_id: projects).upcoming + items = items.joins(:milestone).where(milestones: { title: upcoming.title }) else items = items.joins(:milestone).where(milestones: { title: params[:milestone_title] }) diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 3b4e0362e04..2c55f088594 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -43,7 +43,8 @@ class ProjectsFinder if current_user [ group_projects_for_user(current_user, group), - group.projects.public_and_internal_only + group.projects.public_and_internal_only, + group.shared_projects.visible_to_user(current_user) ] else [group.projects.public_only] @@ -52,7 +53,10 @@ class ProjectsFinder def all_projects(current_user) if current_user - [current_user.authorized_projects, public_and_internal_projects] + [ + current_user.authorized_projects, + public_and_internal_projects + ] else [Project.public_only] end diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb index 07b5759443b..a41172816b8 100644 --- a/app/finders/snippets_finder.rb +++ b/app/finders/snippets_finder.rb @@ -4,7 +4,7 @@ class SnippetsFinder case filter when :all then - snippets(current_user).fresh.non_expired + snippets(current_user).fresh when :by_user then by_user(current_user, params[:user], params[:scope]) when :by_project @@ -27,7 +27,7 @@ class SnippetsFinder end def by_user(current_user, user, scope) - snippets = user.snippets.fresh.non_expired + snippets = user.snippets.fresh return snippets.are_public unless current_user @@ -48,7 +48,7 @@ class SnippetsFinder end def by_project(current_user, project) - snippets = project.snippets.fresh.non_expired + snippets = project.snippets.fresh if current_user if project.team.member?(current_user.id) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index f0aa2b57121..d1b1c61b710 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -72,7 +72,7 @@ module ApplicationHelper if user_or_email.is_a?(User) user = user_or_email else - user = User.find_by(email: user_or_email.downcase) + user = User.find_by_any_email(user_or_email.try(:downcase)) end if user diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb index d8bee21c82e..f20779f2fbb 100644 --- a/app/helpers/ci_status_helper.rb +++ b/app/helpers/ci_status_helper.rb @@ -42,12 +42,12 @@ module CiStatusHelper icon(icon_name + ' fw') end - def render_ci_status(ci_commit) + def render_ci_status(ci_commit, tooltip_placement: 'auto left') link_to ci_status_icon(ci_commit), ci_status_path(ci_commit), class: "ci-status-link ci-status-icon-#{ci_commit.status.dasherize}", title: "Build #{ci_status_label(ci_commit)}", - data: { toggle: 'tooltip', placement: 'left' } + data: { toggle: 'tooltip', placement: tooltip_placement } end def no_runners_for_project?(project) diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index a09e91578b6..f994c9e6170 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -211,4 +211,15 @@ module CommitsHelper def clean(string) Sanitize.clean(string, remove_contents: true) end + + def limited_commits(commits) + if commits.size > MergeRequestDiff::COMMITS_SAFE_SIZE + [ + commits.first(MergeRequestDiff::COMMITS_SAFE_SIZE), + commits.size - MergeRequestDiff::COMMITS_SAFE_SIZE + ] + else + [commits, 0] + end + end end diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb new file mode 100644 index 00000000000..74f326e0b83 --- /dev/null +++ b/app/helpers/dropdowns_helper.rb @@ -0,0 +1,100 @@ +module DropdownsHelper + def dropdown_tag(toggle_text, options: {}, &block) + content_tag :div, class: "dropdown" do + data_attr = { toggle: "dropdown" } + + if options.has_key?(:data) + data_attr = options[:data].merge(data_attr) + end + + dropdown_output = dropdown_toggle(toggle_text, data_attr, options) + + dropdown_output << content_tag(:div, class: "dropdown-menu dropdown-select #{options[:dropdown_class] if options.has_key?(:dropdown_class)}") do + output = "" + + if options.has_key?(:title) + output << dropdown_title(options[:title]) + end + + if options.has_key?(:filter) + output << dropdown_filter(options[:placeholder]) + end + + output << content_tag(:div, class: "dropdown-content") do + capture(&block) if block && !options.has_key?(:footer_content) + end + + if block && options.has_key?(:footer_content) + output << content_tag(:div, class: "dropdown-footer") do + capture(&block) + end + end + + output << dropdown_loading + + output.html_safe + end + + dropdown_output.html_safe + end + end + + def dropdown_toggle(toggle_text, data_attr, options) + content_tag(:button, class: "dropdown-menu-toggle #{options[:toggle_class] if options.has_key?(:toggle_class)}", id: (options[:id] if options.has_key?(:id)), type: "button", data: data_attr) do + output = content_tag(:span, toggle_text, class: "dropdown-toggle-text") + output << icon('chevron-down') + output.html_safe + end + end + + def dropdown_title(title, back: false) + content_tag :div, class: "dropdown-title" do + title_output = "" + + if back + title_output << content_tag(:button, class: "dropdown-title-button dropdown-menu-back", aria: { label: "Go back" }, type: "button") do + icon('arrow-left') + end + end + + title_output << content_tag(:span, title) + + title_output << content_tag(:button, class: "dropdown-title-button dropdown-menu-close", aria: { label: "Close" }, type: "button") do + icon('times') + end + + title_output.html_safe + end + end + + def dropdown_filter(placeholder) + content_tag :div, class: "dropdown-input" do + filter_output = search_field_tag nil, nil, class: "dropdown-input-field", placeholder: placeholder + filter_output << icon('search') + + filter_output.html_safe + end + end + + def dropdown_content(&block) + content_tag(:div, class: "dropdown-content") do + if block + capture(&block) + end + end + end + + def dropdown_footer(&block) + content_tag(:div, class: "dropdown-footer") do + if block + capture(&block) + end + end + end + + def dropdown_loading + content_tag :div, class: "dropdown-loading" do + icon('spinner spin') + end + end +end diff --git a/app/helpers/explore_helper.rb b/app/helpers/explore_helper.rb index 3648757428b..337b0aacbb5 100644 --- a/app/helpers/explore_helper.rb +++ b/app/helpers/explore_helper.rb @@ -1,5 +1,5 @@ module ExploreHelper - def explore_projects_filter_path(options={}) + def filter_projects_path(options={}) exist_opts = { sort: params[:sort], scope: params[:scope], @@ -9,15 +9,7 @@ module ExploreHelper } options = exist_opts.merge(options) - - path = if explore_controller? - explore_projects_path - elsif current_action?(:starred) - starred_dashboard_projects_path - else - dashboard_projects_path - end - + path = request.path path << "?#{options.to_param}" path end diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb index e3e7daa49c5..e8ac8788d9d 100644 --- a/app/helpers/milestones_helper.rb +++ b/app/helpers/milestones_helper.rb @@ -59,6 +59,7 @@ module MilestonesHelper grouped_milestones = grouped_milestones.sort_by { |x| x.due_date.nil? ? epoch : x.due_date } grouped_milestones.unshift(Milestone::None) grouped_milestones.unshift(Milestone::Any) + grouped_milestones.unshift(Milestone::Upcoming) options_from_collection_for_select(grouped_milestones, 'name', 'title', params[:milestone_title]) end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 1eb790b1796..494dad0b41e 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -40,7 +40,7 @@ module SearchHelper { label: "help: Rake Tasks Help", url: help_page_path("raketasks", "README") }, { label: "help: SSH Keys Help", url: help_page_path("ssh", "README") }, { label: "help: System Hooks Help", url: help_page_path("system_hooks", "system_hooks") }, - { label: "help: Web Hooks Help", url: help_page_path("web_hooks", "web_hooks") }, + { label: "help: Webhooks Help", url: help_page_path("web_hooks", "web_hooks") }, { label: "help: Workflow Help", url: help_page_path("workflow", "README") }, ] end diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb index 41ae4048992..0a5a8eb5aee 100644 --- a/app/helpers/snippets_helper.rb +++ b/app/helpers/snippets_helper.rb @@ -1,14 +1,4 @@ module SnippetsHelper - def lifetime_select_options - options = [ - ['forever', nil], - ['1 day', "#{Date.current + 1.day}"], - ['1 week', "#{Date.current + 1.week}"], - ['1 month', "#{Date.current + 1.month}"] - ] - options_for_select(options) - end - def reliable_snippet_path(snippet) if snippet.project_id? namespace_project_snippet_path(snippet.project.namespace, diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index f9026b887da..2f2d2721d6d 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -16,6 +16,16 @@ module SortingHelper } end + def projects_sort_options_hash + { + sort_value_name => sort_title_name, + sort_value_recently_updated => sort_title_recently_updated, + sort_value_oldest_updated => sort_title_oldest_updated, + sort_value_recently_created => sort_title_recently_created, + sort_value_oldest_created => sort_title_oldest_created, + } + end + def sort_title_oldest_updated 'Oldest updated' end diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb index 3a83b083109..256cbcd73a1 100644 --- a/app/mailers/emails/profile.rb +++ b/app/mailers/emails/profile.rb @@ -14,7 +14,10 @@ module Emails end def new_ssh_key_email(key_id) - @key = Key.find(key_id) + @key = Key.find_by_id(key_id) + + return unless @key + @current_user = @user = @key.user @target_url = user_url(@user) mail(to: @user.notification_email, subject: subject("SSH key was added to your account")) diff --git a/app/models/ability.rb b/app/models/ability.rb index f34554d557c..fe9e0aab717 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -9,6 +9,7 @@ class Ability when CommitStatus then commit_status_abilities(user, subject) when Project then project_abilities(user, subject) when Issue then issue_abilities(user, subject) + when ExternalIssue then external_issue_abilities(user, subject) when Note then note_abilities(user, subject) when ProjectSnippet then project_snippet_abilities(user, subject) when PersonalSnippet then personal_snippet_abilities(user, subject) @@ -424,6 +425,10 @@ class Ability end end + def external_issue_abilities(user, subject) + project_abilities(user, subject.project) + end + private def named_abilities(name) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 1227458e525..7d33838044b 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -37,8 +37,6 @@ module Ci class Build < CommitStatus - include Gitlab::Application.routes.url_helpers - LAZY_ATTRIBUTES = ['trace'] belongs_to :runner, class_name: 'Ci::Runner' @@ -128,7 +126,7 @@ module Ci end def retried? - !self.commit.latest_builds_for_ref(self.ref).include?(self) + !self.commit.latest_statuses_for_ref(self.ref).include?(self) end def depends_on_builds @@ -309,22 +307,6 @@ module Ci project.valid_runners_token? token end - def target_url - namespace_project_build_url(project.namespace, project, self) - end - - def cancel_url - if active? - cancel_namespace_project_build_path(project.namespace, project, self) - end - end - - def retry_url - if retryable? - retry_namespace_project_build_path(project.namespace, project, self) - end - end - def can_be_served?(runner) (tag_list - runner.tag_list).empty? end @@ -333,7 +315,7 @@ module Ci project.any_runners? { |runner| runner.active? && runner.online? && can_be_served?(runner) } end - def show_warning? + def stuck? pending? && !any_runners_online? end @@ -348,18 +330,6 @@ module Ci artifacts_file.exists? end - def artifacts_download_url - if artifacts? - download_namespace_project_build_artifacts_path(project.namespace, project, self) - end - end - - def artifacts_browse_url - if artifacts_metadata? - browse_namespace_project_build_artifacts_path(project.namespace, project, self) - end - end - def artifacts_metadata? artifacts? && artifacts_metadata.exists? end diff --git a/app/models/ci/commit.rb b/app/models/ci/commit.rb index ecbd2078b1d..f4cf7034b14 100644 --- a/app/models/ci/commit.rb +++ b/app/models/ci/commit.rb @@ -25,8 +25,6 @@ module Ci has_many :builds, class_name: 'Ci::Build' has_many :trigger_requests, dependent: :destroy, class_name: 'Ci::TriggerRequest' - scope :ordered, -> { order('CASE WHEN ci_commits.committed_at IS NULL THEN 0 ELSE 1 END', :committed_at, :id) } - validates_presence_of :sha validate :valid_commit_sha @@ -42,16 +40,6 @@ module Ci project.id end - def last_build - builds.order(:id).last - end - - def retry - latest_builds.each do |build| - Ci::Build.retry(build) - end - end - def valid_commit_sha if self.sha == Gitlab::Git::BLANK_SHA self.errors.add(:sha, " cant be 00000000 (branch removal)") @@ -121,12 +109,14 @@ module Ci @latest_statuses ||= statuses.latest.to_a end - def latest_builds - @latest_builds ||= builds.latest.to_a + def latest_statuses_for_ref(ref) + latest_statuses.select { |status| status.ref == ref } end - def latest_builds_for_ref(ref) - latest_builds.select { |build| build.ref == ref } + def matrix_builds(build = nil) + matrix_builds = builds.latest.ordered + matrix_builds = matrix_builds.similar(build) if build + matrix_builds.to_a end def retried @@ -170,7 +160,7 @@ module Ci end def duration - duration_array = latest_statuses.map(&:duration).compact + duration_array = statuses.map(&:duration).compact duration_array.reduce(:+).to_i end @@ -183,16 +173,12 @@ module Ci end def coverage - coverage_array = latest_builds.map(&:coverage).compact + coverage_array = latest_statuses.map(&:coverage).compact if coverage_array.size >= 1 '%.2f' % (coverage_array.reduce(:+) / coverage_array.size) end end - def matrix_for_ref?(ref) - latest_builds_for_ref(ref).size > 1 - end - def config_processor return nil unless ci_yaml_file @config_processor ||= Ci::GitlabCiYamlProcessor.new(ci_yaml_file, project.path_with_namespace) @@ -218,10 +204,6 @@ module Ci git_commit_message =~ /(\[ci skip\])/ if git_commit_message end - def update_committed! - update!(committed_at: DateTime.now) - end - private def save_yaml_error(error) diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index e725a6d468c..90349a07594 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -23,7 +23,7 @@ module Ci LAST_CONTACT_TIME = 5.minutes.ago AVAILABLE_SCOPES = ['specific', 'shared', 'active', 'paused', 'online'] - + has_many :builds, class_name: 'Ci::Build' has_many :runner_projects, dependent: :destroy, class_name: 'Ci::RunnerProject' has_many :projects, through: :runner_projects, class_name: '::Project', foreign_key: :gl_project_id @@ -46,9 +46,23 @@ module Ci acts_as_taggable + # Searches for runners matching the given query. + # + # This method uses ILIKE on PostgreSQL and LIKE on MySQL. + # + # This method performs a *partial* match on tokens, thus a query for "a" + # will match any runner where the token contains the letter "a". As a result + # you should *not* use this method for non-admin purposes as otherwise users + # might be able to query a list of all runners. + # + # query - The search query as a String + # + # Returns an ActiveRecord::Relation. def self.search(query) - where('LOWER(ci_runners.token) LIKE :query OR LOWER(ci_runners.description) like :query', - query: "%#{query.try(:downcase)}%") + t = arel_table + pattern = "%#{query}%" + + where(t[:token].matches(pattern).or(t[:description].matches(pattern))) end def set_default_values diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 7ef50836322..3b1aa0f5c80 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -125,23 +125,7 @@ class CommitStatus < ActiveRecord::Base end end - def cancel_url - nil - end - - def retry_url - nil - end - - def show_warning? + def stuck? false end - - def artifacts_download_url - nil - end - - def artifacts_browse_url - nil - end end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 27b97944e38..3c42f582937 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -61,12 +61,29 @@ module Issuable end module ClassMethods + # Searches for records with a matching title. + # + # This method uses ILIKE on PostgreSQL and LIKE on MySQL. + # + # query - The search query as a String + # + # Returns an ActiveRecord::Relation. def search(query) - where("LOWER(title) like :query", query: "%#{query.downcase}%") + where(arel_table[:title].matches("%#{query}%")) end + # Searches for records with a matching title or description. + # + # This method uses ILIKE on PostgreSQL and LIKE on MySQL. + # + # query - The search query as a String + # + # Returns an ActiveRecord::Relation. def full_search(query) - where("LOWER(title) like :query OR LOWER(description) like :query", query: "%#{query.downcase}%") + t = arel_table + pattern = "%#{query}%" + + where(t[:title].matches(pattern).or(t[:description].matches(pattern))) end def sort(method) diff --git a/app/models/group.rb b/app/models/group.rb index 76042b3e3fd..9919ca112dc 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -23,6 +23,8 @@ class Group < Namespace has_many :group_members, dependent: :destroy, as: :source, class_name: 'GroupMember' alias_method :members, :group_members has_many :users, through: :group_members + has_many :project_group_links, dependent: :destroy + has_many :shared_projects, through: :project_group_links, source: :project validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_changed? } validates :avatar, file_size: { maximum: 200.kilobytes.to_i } @@ -33,8 +35,18 @@ class Group < Namespace after_destroy :post_destroy_hook class << self + # Searches for groups matching the given query. + # + # This method uses ILIKE on PostgreSQL and LIKE on MySQL. + # + # query - The search query as a String + # + # Returns an ActiveRecord::Relation. def search(query) - where("LOWER(namespaces.name) LIKE :query or LOWER(namespaces.path) LIKE :query", query: "%#{query.downcase}%") + table = Namespace.arel_table + pattern = "%#{query}%" + + where(table[:name].matches(pattern).or(table[:path].matches(pattern))) end def sort(method) diff --git a/app/models/key.rb b/app/models/key.rb index 406a1257b5d..0282ad18139 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -16,6 +16,7 @@ require 'digest/md5' class Key < ActiveRecord::Base + include AfterCommitQueue include Sortable belongs_to :user @@ -62,7 +63,7 @@ class Key < ActiveRecord::Base end def notify_user - NotificationService.new.new_key(self) + run_after_commit { NotificationService.new.new_key(self) } end def post_create_hook diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index c1e18bb3cc5..188325045e2 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -135,7 +135,6 @@ class MergeRequest < ActiveRecord::Base scope :by_branch, ->(branch_name) { where("(source_branch LIKE :branch) OR (target_branch LIKE :branch)", branch: branch_name) } scope :cared, ->(user) { where('assignee_id = :user OR author_id = :user', user: user.id) } scope :by_milestone, ->(milestone) { where(milestone_id: milestone) } - scope :in_projects, ->(project_ids) { where("source_project_id in (:project_ids) OR target_project_id in (:project_ids)", project_ids: project_ids) } scope :of_projects, ->(ids) { where(target_project_id: ids) } scope :merged, -> { with_state(:merged) } scope :closed_and_merged, -> { with_states(:closed, :merged) } @@ -161,6 +160,24 @@ class MergeRequest < ActiveRecord::Base super("merge_requests", /(?<merge_request>\d+)/) end + # Returns all the merge requests from an ActiveRecord:Relation. + # + # This method uses a UNION as it usually operates on the result of + # ProjectsFinder#execute. PostgreSQL in particular doesn't always like queries + # using multiple sub-queries especially when combined with an OR statement. + # UNIONs on the other hand perform much better in these cases. + # + # relation - An ActiveRecord::Relation that returns a list of Projects. + # + # Returns an ActiveRecord::Relation. + def self.in_projects(relation) + source = where(source_project_id: relation).select(:id) + target = where(target_project_id: relation).select(:id) + union = Gitlab::SQL::Union.new([source, target]) + + where("merge_requests.id IN (#{union.to_sql})") + end + def to_reference(from_project = nil) reference = "#{self.class.reference_prefix}#{iid}" diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index df08d3a6dfb..33884118595 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -17,7 +17,7 @@ class MergeRequestDiff < ActiveRecord::Base include Sortable # Prevent store of diff if commits amount more then 500 - COMMITS_SAFE_SIZE = 500 + COMMITS_SAFE_SIZE = 100 belongs_to :merge_request diff --git a/app/models/milestone.rb b/app/models/milestone.rb index e3969f32dd6..374590ba0c5 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -19,6 +19,7 @@ class Milestone < ActiveRecord::Base MilestoneStruct = Struct.new(:title, :name, :id) None = MilestoneStruct.new('No Milestone', 'No Milestone', 0) Any = MilestoneStruct.new('Any Milestone', '', -1) + Upcoming = MilestoneStruct.new('Upcoming', '#upcoming', -2) include InternalId include Sortable @@ -58,9 +59,18 @@ class Milestone < ActiveRecord::Base alias_attribute :name, :title class << self + # Searches for milestones matching the given query. + # + # This method uses ILIKE on PostgreSQL and LIKE on MySQL. + # + # query - The search query as a String + # + # Returns an ActiveRecord::Relation. def search(query) - query = "%#{query}%" - where("title like ? or description like ?", query, query) + t = arel_table + pattern = "%#{query}%" + + where(t[:title].matches(pattern).or(t[:description].matches(pattern))) end end @@ -72,6 +82,10 @@ class Milestone < ActiveRecord::Base super("milestones", /(?<milestone>\d+)/) end + def self.upcoming + self.where('due_date > ?', Time.now).order(due_date: :asc).first + end + def to_reference(from_project = nil) escaped_title = self.title.gsub("]", "\\]") diff --git a/app/models/namespace.rb b/app/models/namespace.rb index bdb33f37495..55842df1e2d 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -52,8 +52,18 @@ class Namespace < ActiveRecord::Base find_by("lower(path) = :path OR lower(name) = :path", path: path.downcase) end + # Searches for namespaces matching the given query. + # + # This method uses ILIKE on PostgreSQL and LIKE on MySQL. + # + # query - The search query as a String + # + # Returns an ActiveRecord::Relation def search(query) - where("name LIKE :query OR path LIKE :query", query: "%#{query}%") + t = arel_table + pattern = "%#{query}%" + + where(t[:name].matches(pattern).or(t[:path].matches(pattern))) end def clean_path(path) diff --git a/app/models/note.rb b/app/models/note.rb index 3b20d5d22b6..b0c33f2eec5 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -44,6 +44,7 @@ class Note < ActiveRecord::Base delegate :name, :email, to: :author, prefix: true before_validation :set_award! + before_validation :clear_blank_line_code! validates :note, :project, presence: true validates :note, uniqueness: { scope: [:author, :noteable_type, :noteable_id] }, if: ->(n) { n.is_award } @@ -63,7 +64,7 @@ class Note < ActiveRecord::Base scope :nonawards, ->{ where(is_award: false) } scope :for_commit_id, ->(commit_id) { where(noteable_type: "Commit", commit_id: commit_id) } scope :inline, ->{ where("line_code IS NOT NULL") } - scope :not_inline, ->{ where(line_code: [nil, '']) } + scope :not_inline, ->{ where(line_code: nil) } scope :system, ->{ where(system: true) } scope :user, ->{ where(system: false) } scope :common, ->{ where(noteable_type: ["", nil]) } @@ -105,8 +106,18 @@ class Note < ActiveRecord::Base [:discussion, type.try(:underscore), id, line_code].join("-").to_sym end + # Searches for notes matching the given query. + # + # This method uses ILIKE on PostgreSQL and LIKE on MySQL. + # + # query - The search query as a String. + # + # Returns an ActiveRecord::Relation. def search(query) - where("LOWER(note) like :query", query: "%#{query.downcase}%") + table = arel_table + pattern = "%#{query}%" + + where(table[:note].matches(pattern)) end def grouped_awards @@ -162,26 +173,29 @@ class Note < ActiveRecord::Base Note.where(noteable_id: noteable_id, noteable_type: noteable_type, line_code: line_code).last.try(:diff) end - # Check if such line of code exists in merge request diff - # If exists - its active discussion - # If not - its outdated diff + # Check if this note is part of an "active" discussion + # + # This will always return true for anything except MergeRequest noteables, + # which have special logic. + # + # If the note's current diff cannot be matched in the MergeRequest's current + # diff, it's considered inactive. def active? return true unless self.diff return false unless noteable return @active if defined?(@active) - diffs = noteable.diffs(Commit.max_diff_options) - notable_diff = diffs.find { |d| d.new_path == self.diff.new_path } + noteable_diff = find_noteable_diff - return @active = false if notable_diff.nil? + if noteable_diff + parsed_lines = Gitlab::Diff::Parser.new.parse(noteable_diff.diff.each_line) - parsed_lines = Gitlab::Diff::Parser.new.parse(notable_diff.diff.each_line) - # We cannot use ||= because @active may be false - @active = parsed_lines.any? { |line_obj| line_obj.text == diff_line } - end + @active = parsed_lines.any? { |line_obj| line_obj.text == diff_line } + else + @active = false + end - def outdated? - !active? + @active end def diff_file_index @@ -365,6 +379,16 @@ class Note < ActiveRecord::Base private + def clear_blank_line_code! + self.line_code = nil if self.line_code.blank? + end + + # Find the diff on noteable that matches our own + def find_noteable_diff + diffs = noteable.diffs(Commit.max_diff_options) + diffs.find { |d| d.new_path == self.diff.new_path } + end + def awards_supported? (for_issue? || for_merge_request?) && !for_diff_line? end diff --git a/app/models/personal_snippet.rb b/app/models/personal_snippet.rb index 9cee3b70cb3..452f3913eef 100644 --- a/app/models/personal_snippet.rb +++ b/app/models/personal_snippet.rb @@ -10,7 +10,6 @@ # created_at :datetime # updated_at :datetime # file_name :string(255) -# expires_at :datetime # type :string(255) # visibility_level :integer default(0), not null # diff --git a/app/models/project.rb b/app/models/project.rb index 3235a1cee50..79e0cc7b23d 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -151,6 +151,9 @@ class Project < ActiveRecord::Base has_many :releases, dependent: :destroy has_many :lfs_objects_projects, dependent: :destroy has_many :lfs_objects, through: :lfs_objects_projects + has_many :project_group_links, dependent: :destroy + has_many :invited_groups, through: :project_group_links, source: :group + has_many :todos, dependent: :destroy has_one :import_data, dependent: :destroy, class_name: "ProjectImportData" @@ -265,13 +268,31 @@ class Project < ActiveRecord::Base joins(:issues, :notes, :merge_requests).order('issues.created_at, notes.created_at, merge_requests.created_at DESC') end + # Searches for a list of projects based on the query given in `query`. + # + # On PostgreSQL this method uses "ILIKE" to perform a case-insensitive + # search. On MySQL a regular "LIKE" is used as it's already + # case-insensitive. + # + # query - The search query as a String. def search(query) - joins(:namespace). - where('LOWER(projects.name) LIKE :query OR - LOWER(projects.path) LIKE :query OR - LOWER(namespaces.name) LIKE :query OR - LOWER(projects.description) LIKE :query', - query: "%#{query.try(:downcase)}%") + ptable = arel_table + ntable = Namespace.arel_table + pattern = "%#{query}%" + + projects = select(:id).where( + ptable[:path].matches(pattern). + or(ptable[:name].matches(pattern)). + or(ptable[:description].matches(pattern)) + ) + + namespaces = select(:id). + joins(:namespace). + where(ntable[:name].matches(pattern)) + + union = Gitlab::SQL::Union.new([projects, namespaces]) + + where("projects.id IN (#{union.to_sql})") end def search_by_visibility(level) @@ -279,7 +300,10 @@ class Project < ActiveRecord::Base end def search_by_title(query) - non_archived.where('LOWER(projects.name) LIKE :query', query: "%#{query.downcase}%") + pattern = "%#{query}%" + table = Project.arel_table + + non_archived.where(table[:name].matches(pattern)) end def find_with_namespace(id) @@ -527,11 +551,11 @@ class Project < ActiveRecord::Base end def ci_services - services.select { |service| service.category == :ci } + services.where(category: :ci) end def ci_service - @ci_service ||= ci_services.find(&:activated?) + @ci_service ||= ci_services.reorder(nil).find_by(active: true) end def jira_tracker? @@ -877,6 +901,10 @@ class Project < ActiveRecord::Base jira_tracker? && jira_service.active end + def allowed_to_share_with_group? + !namespace.share_with_group_lock + end + def ci_commit(sha) ci_commits.find_by(sha: sha) end @@ -908,13 +936,13 @@ class Project < ActiveRecord::Base end def valid_runners_token? token - self.runners_token && self.runners_token == token + self.runners_token && ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.runners_token) end # TODO (ayufan): For now we use runners_token (backward compatibility) # In 8.4 every build will have its own individual token valid for time of build def valid_build_token? token - self.builds_enabled? && self.runners_token && self.runners_token == token + self.builds_enabled? && self.runners_token && ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.runners_token) end def build_coverage_enabled? diff --git a/app/models/project_group_link.rb b/app/models/project_group_link.rb new file mode 100644 index 00000000000..e52a6bd7c84 --- /dev/null +++ b/app/models/project_group_link.rb @@ -0,0 +1,36 @@ +class ProjectGroupLink < ActiveRecord::Base + GUEST = 10 + REPORTER = 20 + DEVELOPER = 30 + MASTER = 40 + + belongs_to :project + belongs_to :group + + validates :project_id, presence: true + validates :group_id, presence: true + validates :group_id, uniqueness: { scope: [:project_id], message: "already shared with this group" } + validates :group_access, presence: true + validates :group_access, inclusion: { in: Gitlab::Access.values }, presence: true + validate :different_group + + def self.access_options + Gitlab::Access.options + end + + def self.default_access + DEVELOPER + end + + def human_access + self.class.access_options.key(self.group_access) + end + + private + + def different_group + if self.group && self.project && self.project.group == self.group + errors.add(:base, "Project cannot be shared with the project it is in.") + end + end +end diff --git a/app/models/project_services/ci_service.rb b/app/models/project_services/ci_service.rb index e10b5529b42..d9f0849d147 100644 --- a/app/models/project_services/ci_service.rb +++ b/app/models/project_services/ci_service.rb @@ -26,7 +26,7 @@ class CiService < Service default_value_for :category, 'ci' def valid_token?(token) - self.respond_to?(:token) && self.token.present? && self.token == token + self.respond_to?(:token) && self.token.present? && ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.token) end def supported_events diff --git a/app/models/project_snippet.rb b/app/models/project_snippet.rb index 9e2c1b0e18e..1f7d85a5f3d 100644 --- a/app/models/project_snippet.rb +++ b/app/models/project_snippet.rb @@ -10,7 +10,6 @@ # created_at :datetime # updated_at :datetime # file_name :string(255) -# expires_at :datetime # type :string(255) # visibility_level :integer default(0), not null # @@ -23,6 +22,4 @@ class ProjectSnippet < Snippet # Scopes scope :fresh, -> { order("created_at DESC") } - scope :non_expired, -> { where(["expires_at IS NULL OR expires_at > ?", Time.current]) } - scope :expired, -> { where(["expires_at IS NOT NULL AND expires_at < ?", Time.current]) } end diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 9629c7e1bb9..70a8bbaba65 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -160,7 +160,27 @@ class ProjectTeam end end - access.max + if project.invited_groups.any? && project.allowed_to_share_with_group? + access << max_invited_level(user_id) + end + + access.compact.max + end + + + def max_invited_level(user_id) + project.project_group_links.map do |group_link| + invited_group = group_link.group + access = invited_group.group_members.find_by(user_id: user_id).try(:access_field) + + # If group member has higher access level we should restrict it + # to max allowed access level + if access && access > group_link.group_access + access = group_link.group_access + end + + access + end.compact.max end private @@ -168,6 +188,35 @@ class ProjectTeam def fetch_members(level = nil) project_members = project.project_members group_members = group ? group.group_members : [] + invited_members = [] + + if project.invited_groups.any? && project.allowed_to_share_with_group? + project.project_group_links.each do |group_link| + invited_group = group_link.group + im = invited_group.group_members + + if level + int_level = GroupMember.access_level_roles[level.to_s.singularize.titleize] + + # Skip group members if we ask for masters + # but max group access is developers + next if int_level > group_link.group_access + + # If we ask for developers and max + # group access is developers we need to provide + # both group master, developers as devs + if int_level == group_link.group_access + im.where("access_level >= ?)", group_link.group_access) + else + im.send(level) + end + end + + invited_members << im + end + + invited_members = invited_members.flatten.compact + end if level project_members = project_members.send(level) @@ -175,6 +224,7 @@ class ProjectTeam end user_ids = project_members.pluck(:user_id) + user_ids.push(*invited_members.map(&:user_id)) if invited_members.any? user_ids.push(*group_members.pluck(:user_id)) if group User.where(id: user_ids) diff --git a/app/models/repository.rb b/app/models/repository.rb index ff48f993d42..6441cd87e87 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -133,18 +133,18 @@ class Repository rugged.branches.create(branch_name, target) end - expire_branches_cache + after_create_branch find_branch(branch_name) end def add_tag(tag_name, ref, message = nil) - expire_tags_cache + before_push_tag gitlab_shell.add_tag(path_with_namespace, tag_name, ref, message) end def rm_branch(user, branch_name) - expire_branches_cache + before_remove_branch branch = find_branch(branch_name) oldrev = branch.try(:target) @@ -155,12 +155,12 @@ class Repository rugged.branches.delete(branch_name) end - expire_branches_cache + after_remove_branch true end def rm_tag(tag_name) - expire_tags_cache + before_remove_tag gitlab_shell.rm_tag(path_with_namespace, tag_name) end @@ -183,6 +183,14 @@ class Repository end end + def branch_count + @branch_count ||= cache.fetch(:branch_count) { raw_repository.branch_count } + end + + def tag_count + @tag_count ||= cache.fetch(:tag_count) { raw_repository.rugged.tags.count } + end + # Return repo size in megabytes # Cached in redis def size @@ -278,6 +286,16 @@ class Repository @has_visible_content = nil end + def expire_branch_count_cache + cache.expire(:branch_count) + @branch_count = nil + end + + def expire_tag_count_cache + cache.expire(:tag_count) + @tag_count = nil + end + def rebuild_cache cache_keys.each do |key| cache.expire(key) @@ -313,9 +331,17 @@ class Repository expire_root_ref_cache end - # Runs code before creating a new tag. - def before_create_tag + # Runs code before pushing (= creating or removing) a tag. + def before_push_tag expire_cache + expire_tags_cache + expire_tag_count_cache + end + + # Runs code before removing a tag. + def before_remove_tag + expire_tags_cache + expire_tag_count_cache end # Runs code after a repository has been forked/imported. @@ -330,12 +356,21 @@ class Repository # Runs code after a new branch has been created. def after_create_branch + expire_branches_cache expire_has_visible_content_cache + expire_branch_count_cache + end + + # Runs code before removing an existing branch. + def before_remove_branch + expire_branches_cache end # Runs code after an existing branch has been removed. def after_remove_branch expire_has_visible_content_cache + expire_branch_count_cache + expire_branches_cache end def method_missing(m, *args, &block) diff --git a/app/models/snippet.rb b/app/models/snippet.rb index f876be7a4c8..b9e835a4486 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -10,7 +10,6 @@ # created_at :datetime # updated_at :datetime # file_name :string(255) -# expires_at :datetime # type :string(255) # visibility_level :integer default(0), not null # @@ -46,8 +45,6 @@ class Snippet < ActiveRecord::Base scope :are_public, -> { where(visibility_level: Snippet::PUBLIC) } scope :public_and_internal, -> { where(visibility_level: [Snippet::PUBLIC, Snippet::INTERNAL]) } scope :fresh, -> { order("created_at DESC") } - scope :expired, -> { where(["expires_at IS NOT NULL AND expires_at < ?", Time.current]) } - scope :non_expired, -> { where(["expires_at IS NULL OR expires_at > ?", Time.current]) } participant :author, :notes @@ -111,21 +108,37 @@ class Snippet < ActiveRecord::Base nil end - def expired? - expires_at && expires_at < Time.current - end - def visibility_level_field visibility_level end class << self + # Searches for snippets with a matching title or file name. + # + # This method uses ILIKE on PostgreSQL and LIKE on MySQL. + # + # query - The search query as a String. + # + # Returns an ActiveRecord::Relation. def search(query) - where('(title LIKE :query OR file_name LIKE :query)', query: "%#{query}%") + t = arel_table + pattern = "%#{query}%" + + where(t[:title].matches(pattern).or(t[:file_name].matches(pattern))) end + # Searches for snippets with matching content. + # + # This method uses ILIKE on PostgreSQL and LIKE on MySQL. + # + # query - The search query as a String. + # + # Returns an ActiveRecord::Relation. def search_code(query) - where('(content LIKE :query)', query: "%#{query}%") + table = Snippet.arel_table + pattern = "%#{query}%" + + where(table[:content].matches(pattern)) end def accessible_to(user) diff --git a/app/models/user.rb b/app/models/user.rb index 3098d49d58a..8871b0ab9fa 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -286,8 +286,22 @@ class User < ActiveRecord::Base end end + # Searches users matching the given query. + # + # This method uses ILIKE on PostgreSQL and LIKE on MySQL. + # + # query - The search query as a String + # + # Returns an ActiveRecord::Relation. def search(query) - where("lower(name) LIKE :query OR lower(email) LIKE :query OR lower(username) LIKE :query", query: "%#{query.downcase}%") + table = arel_table + pattern = "%#{query}%" + + where( + table[:name].matches(pattern). + or(table[:email].matches(pattern)). + or(table[:username].matches(pattern)) + ) end def by_login(login) @@ -612,6 +626,13 @@ class User < ActiveRecord::Base end end + def try_obtain_ldap_lease + # After obtaining this lease LDAP checks will be blocked for 600 seconds + # (10 minutes) for this user. + lease = Gitlab::ExclusiveLease.new("user_ldap_check:#{id}", timeout: 600) + lease.try_obtain + end + def solo_owned_groups @solo_owned_groups ||= owned_groups.select do |group| group.owners == [self] @@ -811,7 +832,8 @@ class User < ActiveRecord::Base def projects_union Gitlab::SQL::Union.new([personal_projects.select(:id), groups_projects.select(:id), - projects.select(:id)]) + projects.select(:id), + groups.joins(:shared_projects).select(:project_id)]) end def ci_projects_union diff --git a/app/services/ci/image_for_build_service.rb b/app/services/ci/image_for_build_service.rb index 005a5c4661c..50c95ced8a7 100644 --- a/app/services/ci/image_for_build_service.rb +++ b/app/services/ci/image_for_build_service.rb @@ -3,7 +3,7 @@ module Ci def execute(project, opts) sha = opts[:sha] || ref_sha(project, opts[:ref]) - commit = project.ci_commits.ordered.find_by(sha: sha) + commit = project.ci_commits.find_by(sha: sha) image_name = image_for_commit(commit) image_path = Rails.root.join('public/ci', image_name) diff --git a/app/services/create_commit_builds_service.rb b/app/services/create_commit_builds_service.rb index 31b407efeb1..69d5c42a877 100644 --- a/app/services/create_commit_builds_service.rb +++ b/app/services/create_commit_builds_service.rb @@ -33,7 +33,6 @@ class CreateCommitBuildsService unless commit.skip_ci? # Create builds for commit tag = Gitlab::Git.tag_ref?(origin_ref) - commit.update_committed! commit.create_builds(ref, tag, user) end diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index 736b82e3571..4313de0ccab 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -12,7 +12,7 @@ class GitPushService < BaseService # 1. Creates the push event # 2. Updates merge requests # 3. Recognizes cross-references from commit messages - # 4. Executes the project's web hooks + # 4. Executes the project's webhooks # 5. Executes the project's services # 6. Checks if the project's main language has changed # @@ -49,6 +49,8 @@ class GitPushService < BaseService # Update merge requests that may be affected by this push. A new branch # could cause the last commit of a merge request to change. update_merge_requests + + perform_housekeeping end def update_main_language @@ -73,6 +75,13 @@ class GitPushService < BaseService ProjectCacheWorker.perform_async(@project.id) end + def perform_housekeeping + housekeeping = Projects::HousekeepingService.new(@project) + housekeeping.increment! + housekeeping.execute if housekeeping.needed? + rescue Projects::HousekeepingService::LeaseTaken + end + def process_default_branch @push_commits = project.repository.commits(params[:newrev]) @@ -110,7 +119,9 @@ class GitPushService < BaseService # a different branch. closed_issues = commit.closes_issues(current_user) closed_issues.each do |issue| - Issues::CloseService.new(project, authors[commit], {}).execute(issue, commit) + if can?(current_user, :update_issue, issue) + Issues::CloseService.new(project, authors[commit], {}).execute(issue, commit) + end end end diff --git a/app/services/git_tag_push_service.rb b/app/services/git_tag_push_service.rb index a62c5fc4fc4..c88c7672805 100644 --- a/app/services/git_tag_push_service.rb +++ b/app/services/git_tag_push_service.rb @@ -2,7 +2,7 @@ class GitTagPushService attr_accessor :project, :user, :push_data def execute(project, user, oldrev, newrev, ref) - project.repository.before_create_tag + project.repository.before_push_tag @project, @user = project, user @push_data = build_push_data(oldrev, newrev, ref) diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb index 8f25c5e2496..ebb67c7db65 100644 --- a/app/services/merge_requests/post_merge_service.rb +++ b/app/services/merge_requests/post_merge_service.rb @@ -21,7 +21,9 @@ module MergeRequests closed_issues = merge_request.closes_issues(current_user) closed_issues.each do |issue| - Issues::CloseService.new(project, current_user, {}).execute(issue, merge_request) + if can?(current_user, :update_issue, issue) + Issues::CloseService.new(project, current_user, {}).execute(issue, merge_request) + end end end diff --git a/app/services/projects/housekeeping_service.rb b/app/services/projects/housekeeping_service.rb index 0db85ac2142..bccd67d3dbf 100644 --- a/app/services/projects/housekeeping_service.rb +++ b/app/services/projects/housekeeping_service.rb @@ -9,12 +9,39 @@ module Projects class HousekeepingService < BaseService include Gitlab::ShellAdapter + LEASE_TIMEOUT = 3600 + + class LeaseTaken < StandardError + def to_s + "Somebody already triggered housekeeping for this project in the past #{LEASE_TIMEOUT / 60} minutes" + end + end + def initialize(project) @project = project end def execute + raise LeaseTaken if !try_obtain_lease + GitlabShellWorker.perform_async(:gc, @project.path_with_namespace) + ensure + @project.update_column(:pushes_since_gc, 0) + end + + def needed? + @project.pushes_since_gc >= 10 + end + + def increment! + @project.increment!(:pushes_since_gc) + end + + private + + def try_obtain_lease + lease = ::Gitlab::ExclusiveLease.new("project_housekeeping:#{@project.id}", timeout: LEASE_TIMEOUT) + lease.try_obtain end end end diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb index e904cb6c6fc..e1e94c5cc38 100644 --- a/app/services/search/global_service.rb +++ b/app/services/search/global_service.rb @@ -10,9 +10,8 @@ module Search group = Group.find_by(id: params[:group_id]) if params[:group_id].present? projects = ProjectsFinder.new.execute(current_user) projects = projects.in_namespace(group.id) if group - project_ids = projects.pluck(:id) - Gitlab::SearchResults.new(project_ids, params[:search]) + Gitlab::SearchResults.new(projects, params[:search]) end end end diff --git a/app/services/search/project_service.rb b/app/services/search/project_service.rb index f630c0a3790..c08881dce4b 100644 --- a/app/services/search/project_service.rb +++ b/app/services/search/project_service.rb @@ -7,7 +7,7 @@ module Search end def execute - Gitlab::ProjectSearchResults.new(project.id, + Gitlab::ProjectSearchResults.new(project, params[:search], params[:repository_ref]) end diff --git a/app/services/search/snippet_service.rb b/app/services/search/snippet_service.rb index 8ca0877321d..0b3e713e220 100644 --- a/app/services/search/snippet_service.rb +++ b/app/services/search/snippet_service.rb @@ -7,8 +7,9 @@ module Search end def execute - snippet_ids = Snippet.accessible_to(current_user).pluck(:id) - Gitlab::SnippetSearchResults.new(snippet_ids, params[:search]) + snippets = Snippet.accessible_to(current_user) + + Gitlab::SnippetSearchResults.new(snippets, params[:search]) end end end diff --git a/app/views/admin/builds/_build.html.haml b/app/views/admin/builds/_build.html.haml index 34d955568f2..588ad767426 100644 --- a/app/views/admin/builds/_build.html.haml +++ b/app/views/admin/builds/_build.html.haml @@ -4,13 +4,13 @@ = ci_status_with_icon(build.status) %td.build-link - - if can?(current_user, :read_build, project) && build.target_url - = link_to build.target_url do + - if can?(current_user, :read_build, build.project) + = link_to namespace_project_build_url(build.project.namespace, build.project, build) do %strong Build ##{build.id} - else %strong Build ##{build.id} - - if build.show_warning? + - if build.stuck? %i.fa.fa-warning.text-warning %td @@ -18,11 +18,11 @@ = link_to project.name_with_namespace, admin_namespace_project_path(project.namespace, project), class: "monospace" %td - = link_to build.short_sha, namespace_project_commit_path(project.namespace, project, build.sha), class: "monospace" + = link_to build.short_sha, namespace_project_commit_path(build.project.namespace, build.project, build.sha), class: "monospace" %td - if build.ref - = link_to build.ref, namespace_project_commits_path(project.namespace, project, build.ref) + = link_to build.ref, namespace_project_commits_path(build.project.namespace, build.project, build.ref) - else .light none @@ -61,13 +61,12 @@ %td .pull-right - if can?(current_user, :read_build, project) && build.artifacts? - = link_to build.artifacts_download_url, title: 'Download artifacts' do + = link_to download_namespace_project_build_artifacts_path(build.project.namespace, build.project, build), title: 'Download artifacts' do %i.fa.fa-download - if can?(current_user, :update_build, build.project) - if build.active? - - if build.cancel_url - = link_to build.cancel_url, method: :post, title: 'Cancel' do - %i.fa.fa-remove.cred - - elsif defined?(allow_retry) && allow_retry && build.retry_url - = link_to build.retry_url, method: :post, title: 'Retry' do + = link_to cancel_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Cancel' do + %i.fa.fa-remove.cred + - elsif defined?(allow_retry) && allow_retry && build.retryable? + = link_to retry_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Retry' do %i.fa.fa-repeat diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml index f7fd156b84a..264fa1bf0cd 100644 --- a/app/views/admin/groups/show.html.haml +++ b/app/views/admin/groups/show.html.haml @@ -50,6 +50,22 @@ .panel-footer = paginate @projects, param_name: 'projects_page', theme: 'gitlab' + - if @group.shared_projects.any? + .panel.panel-default + .panel-heading + Projects shared with #{@group.name} + %span.badge + #{@group.shared_projects.count} + %ul.well-list + - @group.shared_projects.sort_by(&:name).each do |project| + %li + %strong + = link_to project.name_with_namespace, [:admin, project.namespace.becomes(Namespace), project] + %span.label.label-gray + = repository_size(project) + %span.pull-right.light + %span.monospace= project.path_with_namespace + ".git" + .col-md-6 - if can?(current_user, :admin_group_member, @group) .panel.panel-default diff --git a/app/views/ci/commits/_commit.html.haml b/app/views/ci/commits/_commit.html.haml deleted file mode 100644 index 11163813f3e..00000000000 --- a/app/views/ci/commits/_commit.html.haml +++ /dev/null @@ -1,32 +0,0 @@ -%tr.build - %td.status - = ci_status_with_icon(commit.status) - - if commit.running? - · - = commit.stage - - - %td.build-link - = link_to ci_status_path(commit) do - %strong #{commit.short_sha} - - %td.build-message - %span= truncate_first_line(commit.git_commit_message) - - %td.build-branch - - unless @ref - %span - - commit.refs.each do |ref| - = link_to truncate(ref, length: 25), ci_project_path(@project, ref: ref) - - %td.duration - - if commit.duration > 0 - #{time_interval_in_words commit.duration} - - %td.timestamp - - if commit.finished_at - %span #{time_ago_in_words commit.finished_at} ago - - - if commit.coverage - %td.coverage - #{commit.coverage}% diff --git a/app/views/dashboard/_projects_head.html.haml b/app/views/dashboard/_projects_head.html.haml index 40f88261c10..9da3fcbd986 100644 --- a/app/views/dashboard/_projects_head.html.haml +++ b/app/views/dashboard/_projects_head.html.haml @@ -15,7 +15,7 @@ .nav-controls = form_tag request.original_url, method: :get, class: 'project-filter-form', id: 'project-filter-form' do |f| = search_field_tag :filter_projects, params[:filter_projects], placeholder: 'Filter by name...', class: 'project-filter-form-field form-control input-short projects-list-filter', spellcheck: false, id: 'project-filter-form-field', tabindex: "2" - = render 'explore/projects/dropdown' + = render 'shared/projects/dropdown' - if current_user.can_create_project? = link_to new_project_path, class: 'btn btn-new' do = icon('plus') diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml index f878d36e739..45cfe3da188 100644 --- a/app/views/dashboard/todos/_todo.html.haml +++ b/app/views/dashboard/todos/_todo.html.haml @@ -4,7 +4,10 @@ .todo-title %span.author-name - = link_to_author todo + - if todo.author + = link_to_author(todo) + - else + (removed) %span.todo-label = todo_action_name(todo) = todo_target_link(todo) diff --git a/app/views/doorkeeper/applications/_delete_form.html.haml b/app/views/doorkeeper/applications/_delete_form.html.haml index 6a5c917049d..001a711b1dd 100644 --- a/app/views/doorkeeper/applications/_delete_form.html.haml +++ b/app/views/doorkeeper/applications/_delete_form.html.haml @@ -1,4 +1,10 @@ - submit_btn_css ||= 'btn btn-link btn-remove btn-sm' = form_tag oauth_application_path(application) do %input{:name => "_method", :type => "hidden", :value => "delete"}/ - = submit_tag 'Destroy', onclick: "return confirm('Are you sure?')", class: submit_btn_css
\ No newline at end of file + - if defined? small + = button_tag type: "submit", class: "btn btn-transparent", data: { confirm: "Are you sure?" } do + %span.sr-only + Destroy + = icon('trash') + - else + = submit_tag 'Destroy', data: { confirm: "Are you sure?" }, class: submit_btn_css diff --git a/app/views/doorkeeper/applications/_form.html.haml b/app/views/doorkeeper/applications/_form.html.haml index 98a61ab211b..906b0676150 100644 --- a/app/views/doorkeeper/applications/_form.html.haml +++ b/app/views/doorkeeper/applications/_form.html.haml @@ -1,4 +1,4 @@ -= form_for application, url: doorkeeper_submit_path(application), html: {class: 'form-horizontal', role: 'form'} do |f| += form_for application, url: doorkeeper_submit_path(application), html: {role: 'form'} do |f| - if application.errors.any? .alert.alert-danger %ul @@ -6,25 +6,20 @@ %li= msg .form-group - = f.label :name, class: 'control-label' - - .col-sm-10 - = f.text_field :name, class: 'form-control', required: true + = f.label :name, class: 'label-light' + = f.text_field :name, class: 'form-control', required: true .form-group - = f.label :redirect_uri, class: 'control-label' - - .col-sm-10 - = f.text_area :redirect_uri, class: 'form-control', required: true + = f.label :redirect_uri, class: 'label-light' + = f.text_area :redirect_uri, class: 'form-control', required: true + %span.help-block + Use one line per URI + - if Doorkeeper.configuration.native_redirect_uri %span.help-block - Use one line per URI - - if Doorkeeper.configuration.native_redirect_uri - %span.help-block - Use - %code= Doorkeeper.configuration.native_redirect_uri - for local tests + Use + %code= Doorkeeper.configuration.native_redirect_uri + for local tests - .form-actions - = f.submit 'Submit', class: "btn btn-create" - = link_to "Cancel", applications_profile_path, class: "btn btn-cancel" + .prepend-top-default + = f.submit 'Save application', class: "btn btn-create" diff --git a/app/views/doorkeeper/applications/index.html.haml b/app/views/doorkeeper/applications/index.html.haml index ba4c5b86efb..ea0b66c932b 100644 --- a/app/views/doorkeeper/applications/index.html.haml +++ b/app/views/doorkeeper/applications/index.html.haml @@ -1,19 +1,83 @@ - page_title "Applications" -%h3.page-title Your applications -%p= link_to 'New Application', new_oauth_application_path, class: 'btn btn-success' +- header_title page_title, applications_profile_path -.table-holder - %table.table.table-striped - %thead - %tr - %th Name - %th Callback URL - %th - %th - %tbody - - @applications.each do |application| - %tr{:id => "application_#{application.id}"} - %td= link_to application.name, oauth_application_path(application) - %td= application.redirect_uri - %td= link_to 'Edit', edit_oauth_application_path(application), class: 'btn btn-link' - %td= render 'delete_form', application: application +.row.prepend-top-default + .col-lg-3.profile-settings-sidebar + %h4.prepend-top-0 + = page_title + %p + - if user_oauth_applications? + Manage applications that can use GitLab as an OAuth provider, + and applications that you've authorized to use your account. + - else + Manage applications that you've authorized to use your account. + .col-lg-9 + - if user_oauth_applications? + %h5.prepend-top-0 + Add new application + = render 'form', application: @application + %hr + - if user_oauth_applications? + .oauth-applications + %h5 + Your applications (#{@applications.size}) + - if @applications.any? + .table-responsive + %table.table + %thead + %tr + %th Name + %th Callback URL + %th Clients + %th.last-heading + %tbody + - @applications.each do |application| + %tr{id: "application_#{application.id}"} + %td= link_to application.name, oauth_application_path(application) + %td + - application.redirect_uri.split.each do |uri| + %div= uri + %td= application.access_tokens.count + %td + = link_to edit_oauth_application_path(application), class: "btn btn-transparent append-right-5" do + %span.sr-only + Edit + = icon('pencil') + = render 'delete_form', application: application, small: true + - else + .profile-settings-message.text-center + You don't have any applications + .oauth-authorized-applications.prepend-top-20.append-bottom-default + - if user_oauth_applications? + %h5 + Authorized applications (#{@authorized_tokens.size}) + + - if @authorized_tokens.any? + .table-responsive + %table.table.table-striped + %thead + %tr + %th Name + %th Authorized At + %th Scope + %th + %tbody + - @authorized_apps.each do |app| + - token = app.authorized_tokens.order('created_at desc').first + %tr{id: "application_#{app.id}"} + %td= app.name + %td= token.created_at + %td= token.scopes + %td= render 'delete_form', application: app + - @authorized_anonymous_tokens.each do |token| + %tr + %td + Anonymous + %div.help-block + %em Authorization was granted by entering your username and password in the application. + %td= token.created_at + %td= token.scopes + %td= render 'delete_form', token: token + - else + .profile-settings-message.text-center + You don't have any authorized applications diff --git a/app/views/emojis/index.html.haml b/app/views/emojis/index.html.haml index b66e513e4d2..3443a8e2307 100644 --- a/app/views/emojis/index.html.haml +++ b/app/views/emojis/index.html.haml @@ -2,8 +2,10 @@ .emoji-menu-content = text_field_tag :emoji_search, "", class: "emoji-search search-input form-control" - AwardEmoji.emoji_by_category.each do |category, emojis| - %h5= AwardEmoji::CATEGORIES[category] - %ul + %h5.emoji-menu-title + = AwardEmoji::CATEGORIES[category] + %ul.clearfix.emoji-menu-list - emojis.each do |emoji| - %li - = emoji_icon(emoji["name"], emoji["unicode"], emoji["aliases"])
\ No newline at end of file + %li.pull-left.text-center.emoji-menu-list-item + %button.emoji-menu-btn.text-center.js-emoji-btn{type: "button"} + = emoji_icon(emoji["name"], emoji["unicode"], emoji["aliases"]) diff --git a/app/views/explore/projects/_dropdown.html.haml b/app/views/explore/projects/_dropdown.html.haml deleted file mode 100644 index a4b4cd8d6c7..00000000000 --- a/app/views/explore/projects/_dropdown.html.haml +++ /dev/null @@ -1,20 +0,0 @@ -.dropdown.inline - %button.dropdown-toggle.btn{type: 'button', 'data-toggle' => 'dropdown'} - %span.light - - if @sort.present? - = sort_options_hash[@sort] - - else - = sort_title_recently_updated - %b.caret - %ul.dropdown-menu.dropdown-menu-align-right - %li - = link_to explore_projects_filter_path(sort: sort_value_name) do - = sort_title_name - = link_to explore_projects_filter_path(sort: sort_value_recently_created) do - = sort_title_recently_created - = link_to explore_projects_filter_path(sort: sort_value_oldest_created) do - = sort_title_oldest_created - = link_to explore_projects_filter_path(sort: sort_value_recently_updated) do - = sort_title_recently_updated - = link_to explore_projects_filter_path(sort: sort_value_oldest_updated) do - = sort_title_oldest_updated diff --git a/app/views/explore/projects/_filter.html.haml b/app/views/explore/projects/_filter.html.haml index 39e3e8e2738..cd485da5104 100644 --- a/app/views/explore/projects/_filter.html.haml +++ b/app/views/explore/projects/_filter.html.haml @@ -10,11 +10,11 @@ %b.caret %ul.dropdown-menu %li - = link_to explore_projects_filter_path(visibility_level: nil) do + = link_to filter_projects_path(visibility_level: nil) do Any - Gitlab::VisibilityLevel.values.each do |level| %li{ class: (level.to_s == params[:visibility_level]) ? 'active' : 'light' } - = link_to explore_projects_filter_path(visibility_level: level) do + = link_to filter_projects_path(visibility_level: level) do = visibility_level_icon(level) = visibility_level_label(level) @@ -30,11 +30,11 @@ %b.caret %ul.dropdown-menu %li - = link_to explore_projects_filter_path(tag: nil) do + = link_to filter_projects_path(tag: nil) do Any - @tags.each do |tag| %li{ class: (tag.name == params[:tag]) ? 'active' : 'light' } - = link_to explore_projects_filter_path(tag: tag.name) do - %i.fa.fa-tag + = link_to filter_projects_path(tag: tag.name) do + = icon('tag') = tag.name diff --git a/app/views/groups/_activities.html.haml b/app/views/groups/_activities.html.haml new file mode 100644 index 00000000000..dc76599b776 --- /dev/null +++ b/app/views/groups/_activities.html.haml @@ -0,0 +1,12 @@ +.hidden-xs + = render "events/event_last_push", event: @last_push + +.nav-block + - if current_user + .controls + = link_to dashboard_projects_path(:atom, { private_token: current_user.private_token }), class: 'btn rss-btn' do + %i.fa.fa-rss + = render 'shared/event_filter' + +.content_list += spinner diff --git a/app/views/groups/_projects.html.haml b/app/views/groups/_projects.html.haml index 794aa57b55a..cca7dc27b1c 100644 --- a/app/views/groups/_projects.html.haml +++ b/app/views/groups/_projects.html.haml @@ -1,11 +1 @@ -.top-area - .nav-controls - = form_tag request.original_url, method: :get, class: 'project-filter-form', id: 'project-filter-form' do |f| - - if @projects.present? - = search_field_tag :filter_projects, nil, placeholder: 'Filter by name', class: 'projects-list-filter form-control', spellcheck: false - - if can? current_user, :create_projects, @group - = link_to new_project_path(namespace_id: @group.id), class: 'btn btn-new pull-right' do - = icon('plus') - New Project - -= render 'shared/projects/list', projects: @projects, stars: false, skip_namespace: true += render 'shared/projects/list', projects: projects, stars: false, skip_namespace: true diff --git a/app/views/groups/_shared_projects.html.haml b/app/views/groups/_shared_projects.html.haml new file mode 100644 index 00000000000..b1694c919d0 --- /dev/null +++ b/app/views/groups/_shared_projects.html.haml @@ -0,0 +1 @@ += render 'shared/projects/list', projects: projects, stars: false, skip_namespace: false diff --git a/app/views/groups/activity.html.haml b/app/views/groups/activity.html.haml new file mode 100644 index 00000000000..f73e1d9e865 --- /dev/null +++ b/app/views/groups/activity.html.haml @@ -0,0 +1,9 @@ += content_for :meta_tags do + - if current_user + = auto_discovery_link_tag(:atom, group_url(@group, format: :atom, private_token: current_user.private_token), title: "#{@group.name} activity") + +- page_title "Activity" +- header_title group_title(@group, "Activity", activity_group_path(@group)) + +%section.activities + = render 'activities' diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index 3430f56a9c9..83936d39b16 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -23,6 +23,15 @@ %hr = link_to 'Remove avatar', group_avatar_path(@group.to_param), data: { confirm: "Group avatar will be removed. Are you sure?"}, method: :delete, class: "btn btn-remove btn-sm remove-avatar" + .form-group + %hr + = f.label :share_with_group_lock, class: 'control-label' do + Share with group lock + .col-sm-10 + .checkbox + = f.check_box :share_with_group_lock + %span.descr Prevent sharing a project with another group within this group + .form-actions = f.submit 'Save group', class: "btn btn-save" diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 6148d8cb3d2..23a34ac36dd 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -27,31 +27,34 @@ .cover-desc.description = markdown(@group.description, pipeline: :description) - - %ul.nav-links - %li.active - = link_to "#activity", 'data-toggle' => 'tab' do - Activity - %li - = link_to "#projects", 'data-toggle' => 'tab' do - Projects - - if can?(current_user, :read_group, @group) %div{ class: container_class } - .tab-content - .tab-pane.active#activity - .activity-filter-block - - if current_user - = render "events/event_last_push", event: @last_push - - = render 'shared/event_filter' + .top-area + %ul.nav-links + %li.active + = link_to "#projects", 'data-toggle' => 'tab' do + All Projects + - if @shared_projects.present? + %li + = link_to "#shared", 'data-toggle' => 'tab' do + Shared Projects + .nav-controls + = form_tag request.original_url, method: :get, class: 'project-filter-form', id: 'project-filter-form' do |f| + = search_field_tag :filter_projects, nil, placeholder: 'Filter by name', class: 'projects-list-filter form-control', spellcheck: false + = render 'shared/projects/dropdown' + - if can? current_user, :create_projects, @group + = link_to new_project_path(namespace_id: @group.id), class: 'btn btn-new pull-right' do + = icon('plus') + New Project - .content_list{data: {href: events_group_path}} - = spinner - - .tab-pane#projects + .tab-content + .tab-pane.active#projects = render "projects", projects: @projects + - if @shared_projects.present? + .tab-pane#shared + = render "shared_projects", projects: @shared_projects + - else %p.nav-links.no-top No projects to show diff --git a/app/views/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml index 82d2d4aabed..da3c3711cdd 100644 --- a/app/views/help/_shortcuts.html.haml +++ b/app/views/help/_shortcuts.html.haml @@ -22,6 +22,14 @@ %td.shortcut .key ? %td Show this dialog + %tr + %td.shortcut + - if browser.mac? + .key ⌘ shift p + - else + .key ctrl shift p + + %td Toggle Markdown preview %tbody %tr %th diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml index a2c0a858930..d084559abc3 100644 --- a/app/views/help/ui.html.haml +++ b/app/views/help/ui.html.haml @@ -19,6 +19,8 @@ %li = link_to 'Buttons', '#buttons' %li + = link_to 'Dropdowns', '#dropdowns' + %li = link_to 'Panels', '#panels' %li = link_to 'Alerts', '#alerts' @@ -180,9 +182,9 @@ .nav-controls = text_field_tag 'sample', nil, class: 'form-control' .dropdown - %button.dropdown-toggle.btn{type: 'button', 'data-toggle' => 'dropdown'} + %button.dropdown-menu-toggle{type: 'button', 'data-toggle' => 'dropdown'} %span Sort by name - %b.caret + = icon('chevron-down') %ul.dropdown-menu %li %a Sort by date @@ -212,6 +214,227 @@ %button.btn.btn-danger{:type => "button"} Danger %button.btn.btn-link{:type => "button"} Link + %h2#dropdowns Dropdowns + + .example + .clearfix + .dropdown.inline.pull-left + %button.dropdown-menu-toggle{type: 'button', data: {toggle: 'dropdown'}} + Dropdown + = icon('chevron-down') + %ul.dropdown-menu + %li + %a{href: "#"} + Dropdown Option + .dropdown.inline.pull-right + %button.dropdown-menu-toggle{type: 'button', data: {toggle: 'dropdown'}} + Dropdown + = icon('chevron-down') + %ul.dropdown-menu.dropdown-menu-align-right + %li + %a{href: "#"} + Dropdown Option + .example + %div + .dropdown.inline + %button.dropdown-menu-toggle{type: 'button', data: {toggle: 'dropdown'}} + Dropdown + = icon('chevron-down') + %ul.dropdown-menu.dropdown-menu-selectable + %li + %a.is-active{href: "#"} + Dropdown Option + .example + %div + .dropdown.inline + %button.dropdown-menu-toggle{type: 'button', data: {toggle: 'dropdown'}} + Dropdown + = icon('chevron-down') + .dropdown-menu.dropdown-select.dropdown-menu-selectable + .dropdown-title + %span Dropdown Title + %button.dropdown-title-button.dropdown-menu-close{aria: {label: "Close"}} + = icon('times') + .dropdown-input + %input.dropdown-input-field{type: "search", placeholder: "Filter results"} + = icon('search') + .dropdown-content + %ul + %li + %a.is-active{href: "#"} + Dropdown Option + %li + %a{href: "#"} + Dropdown Option + %li.divider + %li + %a{href: "#"} + Dropdown Option + %li + %a{href: "#"} + Dropdown Option + %li + %a{href: "#"} + Dropdown Option + %li + %a{href: "#"} + Dropdown Option + %li + %a{href: "#"} + Dropdown Option + .dropdown-footer + %strong Tip: + If an author is not a member of this project, you can still filter by his name while using the search field. + .dropdown.inline + %button.dropdown-menu-toggle{type: 'button', data: {toggle: 'dropdown'}} + Dropdown loading + = icon('chevron-down') + .dropdown-menu.dropdown-select.dropdown-menu-selectable.is-loading + .dropdown-title + %span Dropdown Title + %button.dropdown-title-button.dropdown-menu-close{aria: {label: "Close"}} + = icon('times') + .dropdown-input + %input.dropdown-input-field{type: "search", placeholder: "Filter results"} + = icon('search') + .dropdown-content + %ul + %li + %a.is-active{href: "#"} + Dropdown Option + %li + %a{href: "#"} + Dropdown Option + %li.divider + %li + %a{href: "#"} + Dropdown Option + %li + %a{href: "#"} + Dropdown Option + %li + %a{href: "#"} + Dropdown Option + %li + %a{href: "#"} + Dropdown Option + %li + %a{href: "#"} + Dropdown Option + .dropdown-footer + %strong Tip: + If an author is not a member of this project, you can still filter by his name while using the search field. + .dropdown-loading + = icon('spinner spin') + + .example + %div + .dropdown.inline + %button.dropdown-menu-toggle{type: 'button', data: {toggle: 'dropdown'}} + Dropdown user + = icon('chevron-down') + .dropdown-menu.dropdown-select.dropdown-menu-selectable.dropdown-menu-user + .dropdown-title + %span Dropdown Title + %button.dropdown-title-button.dropdown-menu-close{aria: {label: "Close"}} + = icon('times') + .dropdown-input + %input.dropdown-input-field{type: "search", placeholder: "Filter results"} + = icon('search') + .dropdown-content + %ul + %li + %a.dropdown-menu-user-link.is-active{href: "#"} + = link_to_member_avatar(current_user, size: 30) + %strong.dropdown-menu-user-full-name + = current_user.name + .dropdown-menu-user-username + = current_user.to_reference + + .example + %div + .dropdown.inline + %button.dropdown-menu-toggle{type: 'button', data: {toggle: 'dropdown'}} + Dropdown page 2 + = icon('chevron-down') + .dropdown-menu.dropdown-select.dropdown-menu-selectable.dropdown-menu-user.dropdown-menu-paging.is-page-two + .dropdown-page-one + .dropdown-title + %button.dropdown-title-button.dropdown-menu-back{aria: {label: "Go back"}} + = icon('arrow-left') + %span Dropdown Title + %button.dropdown-title-button.dropdown-menu-close{aria: {label: "Close"}} + = icon('times') + .dropdown-input + %input.dropdown-input-field{type: "search", placeholder: "Filter results"} + = icon('search') + .dropdown-content + %ul + %li + %a.dropdown-menu-user-link.is-active{href: "#"} + = link_to_member_avatar(current_user, size: 30) + %strong.dropdown-menu-user-full-name + = current_user.name + .dropdown-menu-user-username + = current_user.to_reference + .dropdown-page-two + .dropdown-title + %button.dropdown-title-button.dropdown-menu-back{aria: {label: "Go back"}} + = icon('arrow-left') + %span Create label + %button.dropdown-title-button.dropdown-menu-close{aria: {label: "Close"}} + = icon('times') + .dropdown-input + %input.dropdown-input-field{type: "search", placeholder: "Name new label"} + .dropdown-content + %button.btn.btn-primary + Create + + .example + %div + .dropdown.inline + %button#js-project-dropdown.dropdown-menu-toggle{type: 'button', data: {toggle: 'dropdown'}} + Projects + = icon('chevron-down') + .dropdown-menu.dropdown-select.dropdown-menu-selectable + .dropdown-title + %span Go to project + %button.dropdown-title-button.dropdown-menu-close{aria: {label: "Close"}} + = icon('times') + .dropdown-input + %input.dropdown-input-field{type: "search", placeholder: "Filter results"} + = icon('search') + .dropdown-content + .dropdown-loading + = icon('spinner spin') + :javascript + $('#js-project-dropdown').glDropdown({ + data: function (term, callback) { + Api.projects(term, "last_activity_at", function (data) { + callback(data); + }); + }, + text: function (project) { + return project.name_with_namespace || project.name; + }, + selectable: true, + fieldName: "author_id", + filterable: true, + search: { + fields: ['name_with_namespace'] + }, + id: function (data) { + return data.id; + }, + isSelected: function (data) { + return data.id === 2; + } + }) + + .example + %div + = dropdown_tag("Projects", options: { title: "Go to project", filter: true, placeholder: "Filter projects" }) + %h2#panels Panels .row diff --git a/app/views/layouts/nav/_group.html.haml b/app/views/layouts/nav/_group.html.haml index e5e2a59eaed..59411ae1da1 100644 --- a/app/views/layouts/nav/_group.html.haml +++ b/app/views/layouts/nav/_group.html.haml @@ -9,10 +9,15 @@ = nav_link(path: 'groups#show', html_options: {class: 'home'}) do = link_to group_path(@group), title: 'Home' do - = icon('dashboard fw') + = icon('group fw') %span Group - if can?(current_user, :read_group, @group) + = nav_link(path: 'groups#activity') do + = link_to activity_group_path(@group), title: 'Activity' do + = icon('dashboard fw') + %span + Activity - if current_user = nav_link(controller: [:group, :milestones]) do = link_to group_milestones_path(@group), title: 'Milestones' do diff --git a/app/views/layouts/nav/_profile.html.haml b/app/views/layouts/nav/_profile.html.haml index f3ded04419b..3b9d31a6fc5 100644 --- a/app/views/layouts/nav/_profile.html.haml +++ b/app/views/layouts/nav/_profile.html.haml @@ -17,7 +17,7 @@ = icon('gear fw') %span Account - = nav_link(path: ['profiles#applications', 'applications#edit', 'applications#show', 'applications#new', 'applications#create']) do + = nav_link(controller: 'oauth/applications') do = link_to applications_profile_path, title: 'Applications' do = icon('cloud fw') %span diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index 319974e12c5..0ae83ee01eb 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -16,7 +16,7 @@ = nav_link(path: 'projects#show', html_options: {class: 'home'}) do = link_to project_path(@project), title: 'Project', class: 'shortcuts-project' do - = icon('home fw') + = icon('bookmark fw') %span Project = nav_link(path: 'projects#activity') do diff --git a/app/views/layouts/nav/_project_settings.html.haml b/app/views/layouts/nav/_project_settings.html.haml index 970da78a5c9..dc3050f02e5 100644 --- a/app/views/layouts/nav/_project_settings.html.haml +++ b/app/views/layouts/nav/_project_settings.html.haml @@ -13,16 +13,22 @@ = icon('pencil-square-o fw') %span Project Settings + - if @project.allowed_to_share_with_group? + = nav_link(controller: :group_links) do + = link_to namespace_project_group_links_path(@project.namespace, @project), title: "Groups" do + = icon('share-square-o fw') + %span + Groups = nav_link(controller: :deploy_keys) do = link_to namespace_project_deploy_keys_path(@project.namespace, @project), title: 'Deploy Keys' do = icon('key fw') %span Deploy Keys = nav_link(controller: :hooks) do - = link_to namespace_project_hooks_path(@project.namespace, @project), title: 'Web Hooks' do + = link_to namespace_project_hooks_path(@project.namespace, @project), title: 'Webhooks' do = icon('link fw') %span - Web Hooks + Webhooks = nav_link(controller: :services) do = link_to namespace_project_services_path(@project.namespace, @project), title: 'Services' do = icon('cogs fw') diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index 9fa96084f94..6efd119f260 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -5,114 +5,113 @@ .alert.alert-info Some options are unavailable for LDAP accounts -.account-page.prepend-top-default - .panel.panel-default.update-token - .panel-heading - Reset Private token - .panel-body - = form_for @user, url: reset_private_token_profile_path, method: :put do |f| - .data - %p - Your private token is used to access application resources without authentication. - %br - It can be used for atom feeds or the API. - %span.cred - Keep it secret! - - %p.cgray - - if current_user.private_token - = text_field_tag "token", current_user.private_token, class: "form-control" - - else - %span You don`t have one yet. Click generate to fix it. - - .form-actions - - if current_user.private_token - = f.submit 'Reset private token', data: { confirm: "Are you sure?" }, class: "btn btn-default" - - else - = f.submit 'Generate', class: "btn btn-default" - - .panel.panel-default - .panel-heading +.row.prepend-top-default + .col-lg-3.profile-settings-sidebar + %h4.prepend-top-0 + Private Token + %p + Your private token is used to access application resources without authentication. + .col-lg-9 + = form_for @user, url: reset_private_token_profile_path, method: :put, html: {class: "private-token"} do |f| + %p.cgray + - if current_user.private_token + = label_tag "token", "Private token", class: "label-light" + = text_field_tag "token", current_user.private_token, class: "form-control" + - else + %span You don`t have one yet. Click generate to fix it. + %p.help-block + It can be used for atom feeds or the API. Keep it secret! + .prepend-top-default + - if current_user.private_token + = f.submit 'Reset private token', data: { confirm: "Are you sure?" }, class: "btn btn-default" + - else + = f.submit 'Generate', class: "btn btn-default" +%hr +.row.prepend-top-default + .col-lg-3.profile-settings-sidebar + %h4.prepend-top-0 Two-factor Authentication - .panel-body - - if current_user.two_factor_enabled? - .pull-right - = link_to 'Disable Two-factor Authentication', profile_two_factor_auth_path, method: :delete, class: 'btn btn-close btn-sm', + %p + Increase your account's security by enabling two-factor authentication (2FA). + .col-lg-9 + %p + Status: #{current_user.two_factor_enabled? ? 'enabled' : 'disabled'} + - if !current_user.two_factor_enabled? + %p + Download the Google Authenticator application from App Store for iOS or Google Play for Android and scan this code. + More information is available in the #{link_to('documentation', help_page_path('profile', 'two_factor_authentication'))}. + .append-bottom-10 + = link_to 'Enable two-factor authentication', new_profile_two_factor_auth_path, class: 'btn btn-success' + - else + = link_to 'Disable Two-factor Authentication', profile_two_factor_auth_path, method: :delete, class: 'btn btn-danger', data: { confirm: 'Are you sure?' } - %p.text-success - %strong - Two-factor Authentication is enabled - %p - If you lose your recovery codes you can - %strong - = succeed ',' do - = link_to 'generate new ones', codes_profile_two_factor_auth_path, method: :post, data: { confirm: 'Are you sure?' } - invalidating all previous codes. - - - else - %p - Increase your account's security by enabling two-factor authentication (2FA). - %p - Each time you log in you’ll be required to provide your username and - password as usual, plus a randomly-generated code from your phone. - - .form-actions - = link_to 'Enable Two-factor Authentication', new_profile_two_factor_auth_path, class: 'btn btn-success' - - - if button_based_providers.any? - .panel.panel-default - .panel-heading +%hr +- if button_based_providers.any? + .row.prepend-top-default + .col-lg-3.profile-settings-sidebar + %h4.prepend-top-0 + Social sign-in + %p + Activate signin with one of the following services + .col-lg-9 + %label.label-light Connected Accounts - .panel-body - .oauth-buttons.append-bottom-10 - %p Click on icon to activate signin with one of the following services - - button_based_providers.each do |provider| - .btn-group - = link_to provider_image_tag(provider), user_omniauth_authorize_path(provider), method: :post, class: "btn btn-lg #{'active' if auth_active?(provider)}", "data-no-turbolink" => "true" - - - if auth_active?(provider) - = link_to unlink_profile_account_path(provider: provider), method: :delete, class: 'btn btn-lg' do - = icon('close') - - - if current_user.can_change_username? - .panel.panel-warning.update-username - .panel-heading - Change Username - .panel-body - = form_for @user, url: update_username_profile_path, method: :put, remote: true do |f| - %p - Changing your username will change path to all personal projects! - %div - .input-group - .input-group-addon - = "#{root_url}u/" - = f.text_field :username, required: true, class: 'form-control' - - .loading-gif.hide - %p - = icon('spinner spin') - Saving new username - .form-actions - = f.submit 'Save username', class: "btn btn-warning" + %p Click on icon to activate signin with one of the following services + - button_based_providers.each do |provider| + .provider-btn-group + .provider-btn-image + = provider_image_tag(provider) + - if auth_active?(provider) + = link_to unlink_profile_account_path(provider: provider), method: :delete, class: 'provider-btn' do + Disconnect + - else + = link_to user_omniauth_authorize_path(provider), method: :post, class: "provider-btn #{'not-active' if !auth_active?(provider)}", "data-no-turbolink" => "true" do + Connect + %hr +- if current_user.can_change_username? + .row.prepend-top-default + .col-lg-3.profile-settings-sidebar + %h4.prepend-top-0.change-username-title + Change username + %p + Changing your username will change path to all personal projects! + .col-lg-9 + = form_for @user, url: update_username_profile_path, method: :put, remote: true, html: {class: "update-username"} do |f| + .form-group + = f.label :username, "Path", class: "label-light" + .input-group + .input-group-addon + = "#{root_url}u/" + = f.text_field :username, required: true, class: 'form-control' + .help-block + Current path: + = "#{root_url}u/#{current_user.username}" + .prepend-top-default + = f.button class: "btn btn-warning", type: "submit" do + = icon "spinner spin", class: "hidden loading-username" + Update username + %hr - - if signup_enabled? - .panel.panel-danger.remove-account - .panel-heading +- if signup_enabled? + .row.prepend-top-default + .col-lg-3.profile-settings-sidebar + %h4.prepend-top-0.remove-account-title Remove account - .panel-body - - if @user.can_be_removed? - %p Deleting an account has the following effects: - %ul - %li All user content like authored issues, snippets, comments will be removed - - rp = current_user.personal_projects.count - - unless rp.zero? - %li #{pluralize rp, 'personal project'} will be removed and cannot be restored - .form-actions - = link_to 'Delete account', user_registration_path, data: { confirm: "REMOVE #{current_user.name}? Are you sure?" }, method: :delete, class: "btn btn-remove" - - else - - if @user.solo_owned_groups.present? - %p - Your account is currently an owner in these groups: - %strong #{@user.solo_owned_groups.map(&:name).join(', ')} - %p - You must transfer ownership or delete these groups before you can delete your account. + .col-lg-9 + - if @user.can_be_removed? + %p + Deleting an account has the following effects: + %ul + %li All user content like authored issues, snippets, comments will be removed + - rp = current_user.personal_projects.count + - unless rp.zero? + %li #{pluralize rp, 'personal project'} will be removed and cannot be restored + = link_to 'Delete account', user_registration_path, data: { confirm: "REMOVE #{current_user.name}? Are you sure?" }, method: :delete, class: "btn btn-remove" + - else + - if @user.solo_owned_groups.present? + %p + Your account is currently an owner in these groups: + %strong #{@user.solo_owned_groups.map(&:name).join(', ')} + %p + You must transfer ownership or delete these groups before you can delete your account. +.append-bottom-default diff --git a/app/views/profiles/applications.html.haml b/app/views/profiles/applications.html.haml deleted file mode 100644 index 86f35823406..00000000000 --- a/app/views/profiles/applications.html.haml +++ /dev/null @@ -1,70 +0,0 @@ -- page_title "Applications" -- header_title page_title, applications_profile_path - -.alert.alert-help.prepend-top-default - - if user_oauth_applications? - Manage applications that can use GitLab as an OAuth provider, - and applications that you've authorized to use your account. - - else - Manage applications that you've authorized to use your account. - -- if user_oauth_applications? - .oauth-applications - %h3 - Your applications - .pull-right - = link_to 'New Application', new_oauth_application_path, class: 'btn btn-success' - - if @applications.any? - .table-holder - %table.table.table-striped - %thead - %tr - %th Name - %th Callback URL - %th Clients - %th - %th - %tbody - - @applications.each do |application| - %tr{:id => "application_#{application.id}"} - %td= link_to application.name, oauth_application_path(application) - %td - - application.redirect_uri.split.each do |uri| - %div= uri - %td= application.access_tokens.count - %td= link_to 'Edit', edit_oauth_application_path(application), class: 'btn btn-link btn-sm' - %td= render 'doorkeeper/applications/delete_form', application: application - -.oauth-authorized-applications.prepend-top-20 - - if user_oauth_applications? - %h3 - Authorized applications - - - if @authorized_tokens.any? - .table-holder - %table.table.table-striped - %thead - %tr - %th Name - %th Authorized At - %th Scope - %th - %tbody - - @authorized_apps.each do |app| - - token = app.authorized_tokens.order('created_at desc').first - %tr{:id => "application_#{app.id}"} - %td= app.name - %td= token.created_at - %td= token.scopes - %td= render 'doorkeeper/authorized_applications/delete_form', application: app - - @authorized_anonymous_tokens.each do |token| - %tr - %td - Anonymous - %div.help-block - %em Authorization was granted by entering your username and password in the application. - %td= token.created_at - %td= token.scopes - %td= render 'doorkeeper/authorized_applications/delete_form', token: token - - else - %p.light You don't have any authorized applications diff --git a/app/views/profiles/two_factor_auths/new.html.haml b/app/views/profiles/two_factor_auths/new.html.haml index b2830aa0834..5d342ef58e5 100644 --- a/app/views/profiles/two_factor_auths/new.html.haml +++ b/app/views/profiles/two_factor_auths/new.html.haml @@ -1,41 +1,41 @@ - page_title 'Two-factor Authentication', 'Account' -%h2.page-title Two-factor Authentication (2FA) -%p - Download the Google Authenticator application from App Store for iOS or Google - Play for Android and scan this code. - - More information is available in the #{link_to('documentation', help_page_path('profile', 'two_factor_authentication'))}. - -%hr - -= form_tag profile_two_factor_auth_path, method: :post, class: 'form-horizontal two-factor-new' do |f| - - if @error - .alert.alert-danger - = @error - .form-group - .col-lg-2.col-lg-offset-2 - = raw @qr_code - .col-lg-7.col-lg-offset-1.manual-instructions - %h3 Can't scan the code? - - %p - To add the entry manually, provide the following details to the - application on your phone. - - %dl - %dt Account - %dd= current_user.email - %dl - %dt Key - %dd= current_user.otp_secret.scan(/.{4}/).join(' ') - %dl - %dt Time based - %dd Yes - .form-group - = label_tag :pin_code, nil, class: "control-label" - .col-lg-10 - = text_field_tag :pin_code, nil, class: "form-control", required: true, autofocus: true - .form-actions - = submit_tag 'Submit', class: 'btn btn-success' - = link_to 'Configure it later', skip_profile_two_factor_auth_path, :method => :patch, class: 'btn btn-cancel' if two_factor_skippable? +.row.prepend-top-default + .col-lg-3 + %h4.prepend-top-0 + Two-factor Authentication (2FA) + %p + Increase your account's security by enabling two-factor authentication (2FA). + .col-lg-9 + %p + Status: #{current_user.two_factor_enabled? ? 'enabled' : 'disabled'} + %p + Download the Google Authenticator application from App Store for iOS or Google Play for Android and scan this code. + More information is available in the #{link_to('documentation', help_page_path('profile', 'two_factor_authentication'))}. + .row.append-bottom-10 + .col-md-3 + = raw @qr_code + .col-md-9 + .account-well + %p.prepend-top-0.append-bottom-0 + Can't scan the code? + %p.prepend-top-0.append-bottom-0 + To add the entry manually, provide the following details to the application on your phone. + %p.prepend-top-0.append-bottom-0 + Account: + = current_user.email + %p.prepend-top-0.append-bottom-0 + Key: + = current_user.otp_secret.scan(/.{4}/).join(' ') + %p.two-factor-new-manual-content + Time based: Yes + = form_tag profile_two_factor_auth_path, method: :post do |f| + - if @error + .alert.alert-danger + = @error + .form-group + = label_tag :pin_code, nil, class: "label-light" + = text_field_tag :pin_code, nil, class: "form-control", required: true + .prepend-top-default + = submit_tag 'Enable two-factor authentication', class: 'btn btn-success' + = link_to 'Configure it later', skip_profile_two_factor_auth_path, :method => :patch, class: 'btn btn-cancel' if two_factor_skippable? diff --git a/app/views/projects/branches/destroy.js.haml b/app/views/projects/branches/destroy.js.haml index 882a4d0c5e2..a21ddaf4930 100644 --- a/app/views/projects/branches/destroy.js.haml +++ b/app/views/projects/branches/destroy.js.haml @@ -1 +1 @@ -$('.js-totalbranch-count').html("#{@repository.branches.size}") +$('.js-totalbranch-count').html("#{@repository.branch_count}") diff --git a/app/views/projects/builds/index.html.haml b/app/views/projects/builds/index.html.haml index 14f1d3226bb..811d304ea75 100644 --- a/app/views/projects/builds/index.html.haml +++ b/app/views/projects/builds/index.html.haml @@ -55,7 +55,6 @@ %th Coverage %th - - @builds.each do |build| - = render 'projects/commit_statuses/commit_status', commit_status: build, commit_sha: true, stage: true, coverage: @project.build_coverage_enabled?, allow_retry: true + = render @builds, commit_sha: true, stage: true, allow_retry: true, coverage: @project.build_coverage_enabled? = paginate @builds, theme: 'gitlab' diff --git a/app/views/projects/builds/show.html.haml b/app/views/projects/builds/show.html.haml index 8eec78a557c..b02aee3db21 100644 --- a/app/views/projects/builds/show.html.haml +++ b/app/views/projects/builds/show.html.haml @@ -13,9 +13,10 @@ = link_to "merge request ##{merge_request.iid}", merge_request_path(merge_request) #up-build-trace - - if @commit.matrix_for_ref?(@build.ref) + - builds = @build.commit.matrix_builds(@build) + - if builds.size > 1 %ul.nav-links.no-top.no-bottom - - @commit.latest_builds_for_ref(@build.ref).each do |build| + - builds.each do |build| %li{class: ('active' if build == @build) } = link_to namespace_project_build_path(@project.namespace, @project, build) do = ci_icon_for_status(build.status) @@ -44,7 +45,7 @@ .pull-right #{time_ago_with_tooltip(@build.finished_at) if @build.finished_at} - - if @build.show_warning? + - if @build.stuck? - unless @build.any_runners_online? .bs-callout.bs-callout-warning %p @@ -70,7 +71,7 @@ .autoscroll-container %button.btn.btn-success.btn-sm#autoscroll-button{:type => "button", :data => {:state => 'disabled'}} enable autoscroll .clearfix - .scroll-controls + #js-build-scroll.scroll-controls = link_to '#up-build-trace', class: 'btn' do %i.fa.fa-angle-up = link_to '#down-build-trace', class: 'btn' do @@ -100,12 +101,12 @@ %h4.title Build artifacts .center .btn-group{ role: :group } - = link_to @build.artifacts_download_url, class: 'btn btn-sm btn-primary' do + = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-primary' do = icon('download') Download - if @build.artifacts_metadata? - = link_to @build.artifacts_browse_url, class: 'btn btn-sm btn-primary' do + = link_to browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-primary' do = icon('folder-open') Browse @@ -115,10 +116,10 @@ - if can?(current_user, :update_build, @project) .center .btn-group{ role: :group } - - if @build.cancel_url - = link_to "Cancel", @build.cancel_url, class: 'btn btn-sm btn-danger', method: :post - - elsif @build.retry_url - = link_to "Retry", @build.retry_url, class: 'btn btn-sm btn-primary', method: :post + - if @build.active? + = link_to "Cancel", cancel_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-danger', method: :post + - elsif @build.retryable? + = link_to "Retry", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-primary', method: :post - if @build.erasable? = link_to erase_namespace_project_build_path(@project.namespace, @project, @build), diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml index 14ee2263b7d..6a60cfeff76 100644 --- a/app/views/projects/buttons/_download.html.haml +++ b/app/views/projects/buttons/_download.html.haml @@ -1,4 +1,4 @@ - unless @project.empty_repo? - if can? current_user, :download_code, @project - = link_to archive_namespace_project_repository_path(@project.namespace, @project, ref: @ref, format: 'zip'), class: 'btn has_tooltip', rel: 'nofollow', title: "Download ZIP" do + = link_to archive_namespace_project_repository_path(@project.namespace, @project, ref: @ref, format: 'zip'), class: 'btn has_tooltip', data: {container: "body"}, rel: 'nofollow', title: "Download ZIP" do = icon('download') diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml new file mode 100644 index 00000000000..7123efffd5b --- /dev/null +++ b/app/views/projects/ci/builds/_build.html.haml @@ -0,0 +1,77 @@ +%tr.build + %td.status + - if can?(current_user, :read_build, build) + = link_to namespace_project_build_url(build.project.namespace, build.project, build), class: "ci-status ci-#{build.status}" do + = ci_status_with_icon(build.status) + - else + = ci_status_with_icon(build.status) + + %td.build-link + - if can?(current_user, :read_build, build) + = link_to namespace_project_build_url(build.project.namespace, build.project, build) do + %strong ##{build.id} + - else + %strong ##{build.id} + + - if build.stuck? + %i.fa.fa-warning.text-warning + + - if defined?(commit_sha) && commit_sha + %td + = link_to build.short_sha, namespace_project_commit_path(build.project.namespace, build.project, build.sha), class: "monospace" + + %td + - if build.ref + = link_to build.ref, namespace_project_commits_path(build.project.namespace, build.project, build.ref) + - else + .light none + + - if defined?(runner) && runner + %td + - if build.try(:runner) + = runner_link(build.runner) + - else + .light none + + - if defined?(stage) && stage + %td + = build.stage + + %td + = build.name + + .pull-right + - if build.tags.any? + - build.tags.each do |tag| + %span.label.label-primary + = tag + - if build.try(:trigger_request) + %span.label.label-info triggered + - if build.try(:allow_failure) + %span.label.label-danger allowed to fail + + %td.duration + - if build.duration + #{duration_in_words(build.finished_at, build.started_at)} + + %td.timestamp + - if build.finished_at + %span #{time_ago_with_tooltip(build.finished_at)} + + - if defined?(coverage) && coverage + %td.coverage + - if build.try(:coverage) + #{build.coverage}% + + %td + .pull-right + - if can?(current_user, :read_build, build) && build.artifacts? + = link_to download_namespace_project_build_artifacts_path(build.project.namespace, build.project, build), title: 'Download artifacts' do + %i.fa.fa-download + - if can?(current_user, :update_build, build) + - if build.active? + = link_to cancel_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Cancel' do + %i.fa.fa-remove.cred + - elsif defined?(allow_retry) && allow_retry && build.retryable? + = link_to retry_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Retry' do + %i.fa.fa-repeat diff --git a/app/views/projects/commit/_builds.html.haml b/app/views/projects/commit/_builds.html.haml index befad27666c..003b7c18d0e 100644 --- a/app/views/projects/commit/_builds.html.haml +++ b/app/views/projects/commit/_builds.html.haml @@ -43,8 +43,8 @@ %th Coverage %th - @ci_commit.refs.each do |ref| - = render partial: "projects/commit_statuses/commit_status", collection: @ci_commit.statuses.for_ref(ref).latest.ordered, - locals: { coverage: @ci_commit.project.build_coverage_enabled?, stage: true, allow_retry: true } + - builds = @ci_commit.statuses.for_ref(ref).latest.ordered + = render builds, coverage: @ci_commit.project.build_coverage_enabled?, stage: true, allow_retry: true - if @ci_commit.retried.any? .gray-content-block.second-block @@ -64,5 +64,4 @@ - if @ci_commit.project.build_coverage_enabled? %th Coverage %th - = render partial: "projects/commit_statuses/commit_status", collection: @ci_commit.retried, - locals: { coverage: @ci_commit.project.build_coverage_enabled?, stage: true } + = render @ci_commit.retried, coverage: @ci_commit.project.build_coverage_enabled?, stage: true diff --git a/app/views/projects/commit_statuses/_commit_status.html.haml b/app/views/projects/commit_statuses/_commit_status.html.haml deleted file mode 100644 index a3449d1ae05..00000000000 --- a/app/views/projects/commit_statuses/_commit_status.html.haml +++ /dev/null @@ -1,79 +0,0 @@ -%tr.commit_status - %td.status - - if can?(current_user, :read_commit_status, commit_status) && commit_status.target_url - = link_to commit_status.target_url, class: "ci-status ci-#{commit_status.status}" do - = ci_icon_for_status(commit_status.status) - = commit_status.status - - else - = ci_status_with_icon(commit_status.status) - - %td.commit_status-link - - if can?(current_user, :read_commit_status, commit_status) && commit_status.target_url - = link_to commit_status.target_url do - %strong ##{commit_status.id} - - else - %strong ##{commit_status.id} - - - if commit_status.show_warning? - %i.fa.fa-warning.text-warning{data: { toggle: "tooltip" }, title: "This build is stuck, open it to know more"} - - - if defined?(commit_sha) && commit_sha - %td - = link_to commit_status.short_sha, namespace_project_commit_path(commit_status.project.namespace, commit_status.project, commit_status.sha), class: "monospace" - - %td - - if commit_status.ref - = link_to commit_status.ref, namespace_project_commits_path(commit_status.project.namespace, commit_status.project, commit_status.ref) - - else - .light none - - - if defined?(runner) && runner - %td - - if commit_status.try(:runner) - = runner_link(commit_status.runner) - - else - .light none - - - if defined?(stage) && stage - %td - = commit_status.stage - - %td - = commit_status.name - - .pull-right - - if commit_status.tags.any? - - commit_status.tags.each do |tag| - %span.label.label-primary - = tag - - if commit_status.try(:trigger_request) - %span.label.label-info triggered - - if commit_status.try(:allow_failure) - %span.label.label-danger allowed to fail - - %td.duration - - if commit_status.duration - #{duration_in_words(commit_status.finished_at, commit_status.started_at)} - - %td.timestamp - - if commit_status.finished_at - %span #{time_ago_with_tooltip(commit_status.finished_at)} - - - if defined?(coverage) && coverage - %td.coverage - - if commit_status.try(:coverage) - #{commit_status.coverage}% - - %td - .pull-right - - if can?(current_user, :read_commit_status, commit_status) && commit_status.artifacts_download_url - = link_to commit_status.artifacts_download_url, title: 'Download artifacts' do - %i.fa.fa-download - - if can?(current_user, :update_commit_status, commit_status) - - if commit_status.active? - - if commit_status.cancel_url - = link_to commit_status.cancel_url, method: :post, title: 'Cancel' do - %i.fa.fa-remove.cred - - elsif defined?(allow_retry) && allow_retry && commit_status.retry_url - = link_to commit_status.retry_url, method: :post, title: 'Retry' do - %i.fa.fa-repeat diff --git a/app/views/projects/commits/_commit_list.html.haml b/app/views/projects/commits/_commit_list.html.haml index ce60fbdf032..bac9e244d36 100644 --- a/app/views/projects/commits/_commit_list.html.haml +++ b/app/views/projects/commits/_commit_list.html.haml @@ -1,11 +1,14 @@ +- commits, hidden = limited_commits(@commits) +- commits = Commit.decorate(commits, @project) + %div.panel.panel-default .panel-heading Commits (#{@commits.count}) - - if @commits.size > MergeRequestDiff::COMMITS_SAFE_SIZE + - if hidden > 0 %ul.well-list - - Commit.decorate(@commits.first(MergeRequestDiff::COMMITS_SAFE_SIZE), @project).each do |commit| + - commits.each do |commit| = render "projects/commits/inline_commit", commit: commit, project: @project %li.warning-row.unstyled - other #{@commits.size - MergeRequestDiff::COMMITS_SAFE_SIZE} commits hidden to prevent performance issues. + #{number_with_delimiter(hidden)} additional commits have been omitted to prevent performance issues. - else - %ul.well-list= render Commit.decorate(@commits, @project), project: @project + %ul.well-list= render commits, project: @project diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml index 6c631228002..a7e3c2478c2 100644 --- a/app/views/projects/commits/_commits.html.haml +++ b/app/views/projects/commits/_commits.html.haml @@ -1,7 +1,9 @@ - unless defined?(project) - project = @project -- @commits.group_by { |c| c.committed_date.to_date }.sort.reverse.each do |day, commits| +- commits, hidden = limited_commits(@commits) + +- commits.group_by { |c| c.committed_date.to_date }.sort.reverse.each do |day, commits| .row.commits-row .col-md-2.hidden-xs.hidden-sm %h5.commits-row-date @@ -13,3 +15,7 @@ %ul.bordered-list = render commits, project: project %hr.lists-separator + +- if hidden > 0 + .alert.alert-warning + #{number_with_delimiter(hidden)} additional commits have been omitted to prevent performance issues. diff --git a/app/views/projects/commits/_head.html.haml b/app/views/projects/commits/_head.html.haml index 498c5e05b32..7a5b0d993db 100644 --- a/app/views/projects/commits/_head.html.haml +++ b/app/views/projects/commits/_head.html.haml @@ -15,9 +15,9 @@ = nav_link(html_options: {class: branches_tab_class}) do = link_to namespace_project_branches_path(@project.namespace, @project) do Branches - %span.badge.js-totalbranch-count= @repository.branches.size + %span.badge.js-totalbranch-count= @repository.branch_count = nav_link(controller: [:tags, :releases]) do = link_to namespace_project_tags_path(@project.namespace, @project) do Tags - %span.badge.js-totaltags-count= @repository.tags.length + %span.badge.js-totaltags-count= @repository.tag_count diff --git a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml new file mode 100644 index 00000000000..4143ea13063 --- /dev/null +++ b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml @@ -0,0 +1,59 @@ +%tr.generic_commit_status + %td.status + - if can?(current_user, :read_commit_status, generic_commit_status) && generic_commit_status.target_url + = link_to generic_commit_status.target_url, class: "ci-status ci-#{generic_commit_status.status}" do + = ci_status_with_icon(generic_commit_status.status) + - else + = ci_status_with_icon(generic_commit_status.status) + + %td.generic_commit_status-link + - if can?(current_user, :read_commit_status, generic_commit_status) && generic_commit_status.target_url + = link_to generic_commit_status.target_url do + %strong ##{generic_commit_status.id} + - else + %strong ##{generic_commit_status.id} + + - if defined?(commit_sha) && commit_sha + %td + = link_to generic_commit_status.short_sha, namespace_project_commit_path(generic_commit_status.project.namespace, generic_commit_status.project, generic_commit_status.sha), class: "monospace" + + %td + - if generic_commit_status.ref + = link_to generic_commit_status.ref, namespace_project_commits_path(generic_commit_status.project.namespace, generic_commit_status.project, generic_commit_status.ref) + - else + .light none + + - if defined?(runner) && runner + %td + - if generic_commit_status.try(:runner) + = runner_link(generic_commit_status.runner) + - else + .light none + + - if defined?(stage) && stage + %td + = generic_commit_status.stage + + %td + = generic_commit_status.name + + .pull-right + - if generic_commit_status.tags.any? + - generic_commit_status.tags.each do |tag| + %span.label.label-primary + = tag + + %td.duration + - if generic_commit_status.duration + #{duration_in_words(generic_commit_status.finished_at, generic_commit_status.started_at)} + + %td.timestamp + - if generic_commit_status.finished_at + %span #{time_ago_with_tooltip(generic_commit_status.finished_at)} + + - if defined?(coverage) && coverage + %td.coverage + - if generic_commit_status.try(:coverage) + #{generic_commit_status.coverage}% + + %td diff --git a/app/views/projects/go_import.html.haml b/app/views/projects/go_import.html.haml deleted file mode 100644 index 87ac75a350f..00000000000 --- a/app/views/projects/go_import.html.haml +++ /dev/null @@ -1,5 +0,0 @@ -!!! 5 -%html - %head - - web_url = [Gitlab.config.gitlab.url, @namespace, @id].join('/') - %meta{name: "go-import", content: "#{web_url.split('://')[1]} git #{web_url}.git"} diff --git a/app/views/projects/group_links/index.html.haml b/app/views/projects/group_links/index.html.haml new file mode 100644 index 00000000000..13f5fc141fa --- /dev/null +++ b/app/views/projects/group_links/index.html.haml @@ -0,0 +1,41 @@ +- page_title "Groups" +%h3.page_title Share project with other groups +%p.light + Projects can be stored in only one group at once. However you can share a project with other groups here. +%hr +- if @group_links.present? + .enabled-groups.panel.panel-default + .panel-heading + Already shared with + %ul.well-list + - @group_links.each do |group_link| + - group = group_link.group + %li + .pull-right + = link_to namespace_project_group_link_path(@project.namespace, @project, group_link), method: :delete, class: 'btn btn-sm' do + %i.icon-remove + disable sharing + = link_to group do + %strong + %i.icon-folder-open + = group.name + %br + .light up to #{group_link.human_access} + + +.available-groups + %h4 + Can be shared with + %div + = form_tag namespace_project_group_links_path(@project.namespace, @project), method: :post, class: 'form-horizontal' do + .form-group + = label_tag :link_group_id, 'Group', class: 'control-label' + .col-sm-10 + = groups_select_tag(:link_group_id, skip_group: @project.group.try(:path)) + .form-group + = label_tag :link_group_access, 'Max access level', class: 'control-label' + .col-sm-10 + = select_tag :link_group_access, options_for_select(ProjectGroupLink.access_options, ProjectGroupLink.default_access), class: "form-control" + .form-actions + = submit_tag "Share", class: "btn btn-create" + diff --git a/app/views/projects/hooks/index.html.haml b/app/views/projects/hooks/index.html.haml index a0511819c9f..67d016bd871 100644 --- a/app/views/projects/hooks/index.html.haml +++ b/app/views/projects/hooks/index.html.haml @@ -1,9 +1,9 @@ -- page_title "Web Hooks" +- page_title "Webhooks" %h3.page-title - Web hooks + Webhooks %p.light - #{link_to "Web hooks ", help_page_path("web_hooks", "web_hooks"), class: "vlink"} can be + #{link_to "Webhooks ", help_page_path("web_hooks", "web_hooks"), class: "vlink"} can be used for binding events when something is happening within the project. %hr.clearfix @@ -70,12 +70,12 @@ = f.check_box :enable_ssl_verification %strong Enable SSL verification .form-actions - = f.submit "Add Web Hook", class: "btn btn-create" + = f.submit "Add Webhook", class: "btn btn-create" -if @hooks.any? .panel.panel-default .panel-heading - Web hooks (#{@hooks.count}) + Webhooks (#{@hooks.count}) %ul.well-list - @hooks.each do |hook| %li diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index eb9c225df2f..b151393abab 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -1,7 +1,7 @@ - content_for :note_actions do - if can?(current_user, :update_issue, @issue) - = link_to 'Reopen issue', issue_path(@issue, issue: {state_event: :reopen}, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "btn btn-nr btn-grouped btn-reopen btn-comment js-note-target-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' - = link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "btn btn-nr btn-grouped btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' + = link_to 'Reopen issue', issue_path(@issue, issue: {state_event: :reopen}, status_only: true, format: 'json'), data: {no_turbolink: true, original_text: "Reopen issue", alternative_text: "Comment & reopen issue"}, class: "btn btn-nr btn-grouped btn-reopen btn-comment js-note-target-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' + = link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, status_only: true, format: 'json'), data: {no_turbolink: true, original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "btn btn-nr btn-grouped btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' #notes = render 'projects/notes/notes_with_form' diff --git a/app/views/projects/issues/_merge_requests.html.haml b/app/views/projects/issues/_merge_requests.html.haml index 640a1962ffc..d9868ad1f0a 100644 --- a/app/views/projects/issues/_merge_requests.html.haml +++ b/app/views/projects/issues/_merge_requests.html.haml @@ -11,7 +11,7 @@ - elsif has_any_ci = icon('blank fw') %span.merge-request-id - \!#{merge_request.iid} + = merge_request.to_reference %span.merge-request-info %strong = link_to_gfm merge_request.title, merge_request_path(merge_request), class: "row_title" diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 617b0437807..0242276cd84 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -18,7 +18,7 @@ %span.hidden-sm.hidden-md.hidden-lg = icon('circle-o') - %a.btn.btn-default.pull-right.hidden-sm.hidden-md.hidden-lg.gutter-toggle{ href: "#" } + %a.btn.btn-default.pull-right.visible-xs-block.gutter-toggle.js-sidebar-toggle{ href: "#" } = icon('angle-double-left') .issue-meta @@ -71,7 +71,7 @@ .merge-requests = render 'merge_requests' - .content-block + .content-block.content-block-small = render 'votes/votes_block', votable: @issue .row diff --git a/app/views/projects/merge_requests/_discussion.html.haml b/app/views/projects/merge_requests/_discussion.html.haml index 1c7de94acfd..393998f15b9 100644 --- a/app/views/projects/merge_requests/_discussion.html.haml +++ b/app/views/projects/merge_requests/_discussion.html.haml @@ -1,8 +1,8 @@ - content_for :note_actions do - if can?(current_user, :update_merge_request, @merge_request) - if @merge_request.open? - = link_to 'Close', merge_request_path(@merge_request, merge_request: {state_event: :close }), method: :put, class: "btn btn-nr btn-comment btn-grouped btn-close close-mr-link js-note-target-close", title: "Close merge request" + = link_to 'Close merge request', merge_request_path(@merge_request, merge_request: {state_event: :close }), method: :put, class: "btn btn-nr btn-comment btn-grouped btn-close close-mr-link js-note-target-close", title: "Close merge request", data: {original_text: "Close merge request", alternative_text: "Comment & close merge request"} - if @merge_request.closed? - = link_to 'Reopen', merge_request_path(@merge_request, merge_request: {state_event: :reopen }), method: :put, class: "btn btn-nr btn-comment btn-grouped btn-reopen reopen-mr-link js-note-target-reopen", title: "Reopen merge request" + = link_to 'Reopen merge request', merge_request_path(@merge_request, merge_request: {state_event: :reopen }), method: :put, class: "btn btn-nr btn-comment btn-grouped btn-reopen reopen-mr-link js-note-target-reopen", title: "Reopen merge request", data: {original_text: "Reopen merge request", alternative_text: "Comment & reopen merge request"} #notes= render "projects/notes/notes_with_form" diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index b262892ac65..ee5b9fd95a8 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -68,7 +68,7 @@ .tab-content #notes.notes.tab-pane.voting_notes - .content-block.oneline-block + .content-block.content-block-small.oneline-block = render 'votes/votes_block', votable: @merge_request .row diff --git a/app/views/projects/merge_requests/show/_mr_title.html.haml b/app/views/projects/merge_requests/show/_mr_title.html.haml index d24c12251f3..a75c0d96c57 100644 --- a/app/views/projects/merge_requests/show/_mr_title.html.haml +++ b/app/views/projects/merge_requests/show/_mr_title.html.haml @@ -4,7 +4,7 @@ = @merge_request.state_human_name %span.hidden-sm.hidden-md.hidden-lg = icon(@merge_request.state_icon_name) - %a.btn.btn-default.pull-right.hidden-sm.hidden-md.hidden-lg.gutter-toggle{ href: "#" } + %a.btn.btn-default.pull-right.visible-xs-block.gutter-toggle.js-sidebar-toggle{ href: "#" } = icon('angle-double-left') .issue-meta %strong.identifier diff --git a/app/views/projects/notes/_edit_form.html.haml b/app/views/projects/notes/_edit_form.html.haml index b5f076088c7..13e624764d9 100644 --- a/app/views/projects/notes/_edit_form.html.haml +++ b/app/views/projects/notes/_edit_form.html.haml @@ -1,5 +1,5 @@ .note-edit-form - = form_for note, url: namespace_project_note_path(@project.namespace, @project, note), method: :put, remote: true, authenticity_token: true, class: 'js-quick-submit' do |f| + = form_for note, url: namespace_project_note_path(@project.namespace, @project, note), method: :put, remote: true, authenticity_token: true, html: { class: 'edit-note js-quick-submit' } do |f| = note_target_fields(note) = render layout: 'projects/md_preview', locals: { preview_class: 'md-preview' } do = render 'projects/zen', f: f, attr: :note, classes: 'note_text js-note-text js-task-list-field' diff --git a/app/views/projects/notes/_form.html.haml b/app/views/projects/notes/_form.html.haml index 09740d8ea12..f675f092da1 100644 --- a/app/views/projects/notes/_form.html.haml +++ b/app/views/projects/notes/_form.html.haml @@ -13,6 +13,7 @@ .error-alert .note-form-actions.clearfix - = f.submit 'Add Comment', class: "btn btn-nr btn-create comment-btn btn-grouped js-comment-button" + = f.submit 'Comment', class: "btn btn-nr btn-create comment-btn btn-grouped js-comment-button" = yield(:note_actions) - %a.btn.btn-nr.btn-cancel.js-close-discussion-note-form Cancel + %a.btn.btn-cancel.js-note-discard{role: "button", data: {cancel_text: "Cancel"}} + Discard draft diff --git a/app/views/projects/project_members/_shared_group_members.html.haml b/app/views/projects/project_members/_shared_group_members.html.haml new file mode 100644 index 00000000000..62888e41935 --- /dev/null +++ b/app/views/projects/project_members/_shared_group_members.html.haml @@ -0,0 +1,21 @@ +- @project_group_links.each do |group_links| + - shared_group = group_links.group + - shared_group_users_count = group_links.group.group_members.count + .panel.panel-default + .panel-heading + Shared with + %strong #{shared_group.name} + group, members with + %strong #{group_links.human_access} + role (#{shared_group_users_count}) + - if current_user.can?(:admin_group, shared_group) + .panel-head-actions + = link_to group_group_members_path(shared_group), class: 'btn btn-sm' do + %i.fa.fa-pencil-square-o + Edit group members + %ul.content-list + - shared_group.group_members.order('access_level DESC').limit(20).each do |member| + = render 'groups/group_members/group_member', member: member, show_controls: false, show_roles: false + - if shared_group_users_count > 20 + %li + and #{shared_group_users_count - 20} more. For full list visit #{link_to 'group members page', group_group_members_path(shared_group)} diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index 0f8848a5cbe..ebcfc907ebb 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -18,3 +18,6 @@ - if @group = render "group_members", members: @group_members + + - if @project_group_links.any? && @project.allowed_to_share_with_group? + = render "shared_group_members" diff --git a/app/views/search/_filter.html.haml b/app/views/search/_filter.html.haml index ec478a5963d..4ef544136a8 100644 --- a/app/views/search/_filter.html.haml +++ b/app/views/search/_filter.html.haml @@ -6,14 +6,21 @@ - else Any %b.caret - %ul.dropdown-menu - %li - = link_to search_filter_path(group_id: nil) do - Any - - current_user.authorized_groups.sort_by(&:name).each do |group| - %li - = link_to search_filter_path(group_id: group.id, project_id: nil) do - = group.name + .dropdown-menu.dropdown-select.dropdown-menu-selectable + .dropdown-title + %span Filter results by group + %button.dropdown-title-button.dropdown-menu-close{aria: {label: "Close"}} + = icon('times') + .dropdown-content + %ul + %li + = link_to search_filter_path(group_id: nil), class: ("is-active" if !params[:group_id].present?) do + Any + %li.divider + - current_user.authorized_groups.sort_by(&:name).each do |group| + %li + = link_to search_filter_path(group_id: group.id, project_id: nil), class: ("is-active" if params[:group_id] == group.id.to_s) do + = group.name .dropdown.inline.prepend-left-10.project-filter %button.dropdown-toggle.btn.btn-sm{type: 'button', 'data-toggle' => 'dropdown'} @@ -23,11 +30,18 @@ - else Any %b.caret - %ul.dropdown-menu - %li - = link_to search_filter_path(project_id: nil) do - Any - - current_user.authorized_projects.sort_by(&:name_with_namespace).each do |project| - %li - = link_to search_filter_path(project_id: project.id, group_id: nil) do - = project.name_with_namespace + .dropdown-menu.dropdown-select.dropdown-menu-selectable + .dropdown-title + %span Filter results by project + %button.dropdown-title-button.dropdown-menu-close{aria: {label: "Close"}} + = icon('times') + .dropdown-content + %ul + %li + = link_to search_filter_path(project_id: nil), class: ("is-active" if !params[:project_id].present?) do + Any + %li.divider + - current_user.authorized_projects.sort_by(&:name_with_namespace).each do |project| + %li + = link_to search_filter_path(project_id: project.id, group_id: nil), class: ("is-active" if params[:project_id] == project.id.to_s) do + = project.name_with_namespace diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index e55159d996b..42a3c2c3f02 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -7,22 +7,77 @@ class: "check_all_issues left" .issues-other-filters .filter-item.inline - = users_select_tag(:author_id, selected: params[:author_id], - placeholder: 'Author', class: 'trigger-submit', any_user: "Any Author", first_user: true, current_user: true) + - if params[:author_id] + = hidden_field_tag(:author_id, params[:author_id]) + = dropdown_tag("Author", options: { toggle_class: "js-user-search js-filter-submit js-author-search", title: "Filter by author", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-author", + placeholder: "Search authors", data: { any_user: "Any Author", first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), selected: params[:author_id], field_name: "author_id" } }) .filter-item.inline - = users_select_tag(:assignee_id, selected: params[:assignee_id], - placeholder: 'Assignee', class: 'trigger-submit', any_user: "Any Assignee", null_user: true, first_user: true, current_user: true) + - if params[:assignee_id] + = hidden_field_tag(:assignee_id, params[:assignee_id]) + = dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-filter-submit js-assignee-search", title: "Filter by assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee", + placeholder: "Search assignee", data: { any_user: "Any Author", first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: (@project.id if @project), selected: params[:assignee_id], field_name: "assignee_id" } }) .filter-item.inline.milestone-filter - = select_tag('milestone_title', projects_milestones_options, - class: 'select2 trigger-submit', include_blank: true, - data: {placeholder: 'Milestone'}) + - if params[:milestone_title] + = hidden_field_tag(:milestone_title, params[:milestone_title]) + = dropdown_tag("Milestone", options: { title: "Filter by milestone", toggle_class: 'js-milestone-select js-filter-submit', filter: true, dropdown_class: "dropdown-menu-selectable", + placeholder: "Search milestones", footer_content: true, data: { show_no: true, show_any: true, field_name: "milestone_title", selected: params[:milestone_title], project_id: (@project.id if @project), milestones: (namespace_project_milestones_path(@project.namespace, @project, :js) if @project) } }) do + - if @project + %ul.dropdown-footer-list + - if can? current_user, :admin_milestone, @project + %li + = link_to new_namespace_project_milestone_path(@project.namespace, @project), title: "New Milestone" do + Create new + %li + = link_to namespace_project_milestones_path(@project.namespace, @project) do + - if can? current_user, :admin_milestone, @project + Manage milestones + - else + View milestones .filter-item.inline.labels-filter - = select_tag('label_name', projects_labels_options, - class: 'select2 trigger-submit', include_blank: true, - data: {placeholder: 'Label'}) + - if params[:label_name] + = hidden_field_tag(:label_name, params[:label_name]) + .dropdown + %button.dropdown-menu-toggle.js-label-select.js-filter-submit{type: "button", data: {toggle: "dropdown", field_name: "label_name", show_no: "true", show_any: "true", selected: params[:label_name], project_id: (@project.id if @project), labels: (namespace_project_labels_path(@project.namespace, @project, :js) if @project)}} + %span.dropdown-toggle-text + Label + = icon('chevron-down') + .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable + .dropdown-page-one + = dropdown_title("Filter by label") + = dropdown_filter("Search labels") + = dropdown_content + - if @project + = dropdown_footer do + %ul.dropdown-footer-list + - if can? current_user, :admin_label, @project + %li + %a.dropdown-toggle-page{href: "#"} + Create new + %li + = link_to namespace_project_labels_path(@project.namespace, @project) do + - if can? current_user, :admin_label, @project + Manage labels + - else + View labels + - if can? current_user, :admin_label, @project + .dropdown-page-two + = dropdown_title("Create new label", back: true) + = dropdown_content do + %input#new_label_color{type: "hidden"} + %input#new_label_name.dropdown-input-field{type: "text", placeholder: "Name new label"} + .dropdown-label-color-preview.js-dropdown-label-color-preview + .suggest-colors.suggest-colors-dropdown + - suggested_colors.each do |color| + = link_to '#', style: "background-color: #{color}", data: { color: color } do +   + %button.btn.btn-primary.js-new-label-btn{type: "button"} + Create + = dropdown_loading + .dropdown-loading + = icon('spinner spin') .pull-right = render 'shared/sort_dropdown' @@ -31,11 +86,18 @@ .issues_bulk_update.hide = form_tag bulk_update_namespace_project_issues_path(@project.namespace, @project), method: :post do .filter-item.inline - = select_tag('update[state_event]', options_for_select([['Open', 'reopen'], ['Closed', 'close']]), include_blank: true, data: { placeholder: "Status" }) + = dropdown_tag("Status", options: { toggle_class: "js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[state_event]" } } ) do + %ul + %li + %a{href: "#", data: {id: "reopen"}} Open + %li + %a{href: "#", data: {id: "close"}} Closed .filter-item.inline - = users_select_tag('update[assignee_id]', placeholder: 'Assignee', null_user: true, first_user: true, current_user: true) + = dropdown_tag("Assignee", options: { toggle_class: "js-user-search", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", + placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: "update[assignee_id]" } }) .filter-item.inline - = select_tag('update[milestone_id]', bulk_update_milestone_options, include_blank: true, data: { placeholder: "Milestone" }) + = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select', filter: true, dropdown_class: "dropdown-menu-selectable", + placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :js), use_id: true } }) = hidden_field_tag 'update[issues_ids]', [] = hidden_field_tag :state_event, params[:state_event] .filter-item.inline @@ -47,6 +109,9 @@ :javascript new UsersSelect(); + new LabelsSelect(); + new MilestoneSelect(); + new IssueStatusSelect(); $('form.filter-form').on('submit', function (event) { event.preventDefault(); Turbolinks.visit(this.action + '&' + $(this).serialize()); diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 36f06377886..9020a1330a3 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -6,7 +6,7 @@ of = issuables_count(issuable) %span.pull-right - %a.gutter-toggle{href: '#'} + %a.gutter-toggle.js-sidebar-toggle{href: '#'} = sidebar_gutter_toggle_icon .issuable-nav.hide-collapsed.pull-right.btn-group{role: 'group', "aria-label" => '...'} - if prev_issuable = prev_issuable_for(issuable) diff --git a/app/views/shared/projects/_dropdown.html.haml b/app/views/shared/projects/_dropdown.html.haml new file mode 100644 index 00000000000..e7e04621ff4 --- /dev/null +++ b/app/views/shared/projects/_dropdown.html.haml @@ -0,0 +1,22 @@ +- @sort ||= sort_value_recently_updated +- archived = params[:archived] +.dropdown.inline + %button.dropdown-toggle.btn{type: 'button', 'data-toggle' => 'dropdown'} + %span.light + = projects_sort_options_hash[@sort] + %b.caret + %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable + %li.dropdown-header + Sort by + - projects_sort_options_hash.each do |value, title| + %li + = link_to filter_projects_path(sort: value, archived: archived), class: ("is-active" if @sort == value) do + = title + + %li.divider + %li + = link_to filter_projects_path(sort: @sort, archived: nil), class: ("is-active" unless params[:archived].present?) do + Hide archived projects + %li + = link_to filter_projects_path(sort: @sort, archived: true), class: ("is-active" if params[:archived].present?) do + Show archived projects diff --git a/app/views/shared/snippets/_blob.html.haml b/app/views/shared/snippets/_blob.html.haml index e0e41fc4bea..773ce8ac240 100644 --- a/app/views/shared/snippets/_blob.html.haml +++ b/app/views/shared/snippets/_blob.html.haml @@ -1,5 +1,7 @@ - unless @snippet.content.empty? - if markup?(@snippet.file_name) + %textarea.markdown-snippet-copy.blob-content{data: {blob_id: @snippet.id}} + = @snippet.data .file-content.wiki = render_markup(@snippet.file_name, @snippet.data) - else diff --git a/app/views/votes/_votes_block.html.haml b/app/views/votes/_votes_block.html.haml index 176fd29cb57..20d2d5f317b 100644 --- a/app/views/votes/_votes_block.html.haml +++ b/app/views/votes/_votes_block.html.haml @@ -1,14 +1,17 @@ .awards.votes-block - awards_sort(votable.notes.awards.grouped_awards).each do |emoji, notes| - .award{class: (note_active_class(notes, current_user)), title: emoji_author_list(notes, current_user)} + %button.btn.award-control.js-emoji-btn.has_tooltip{class: (note_active_class(notes, current_user)), title: emoji_author_list(notes, current_user), data: {placement: "top"}} = emoji_icon(emoji) - .counter + %span.award-control-text.js-counter = notes.count - if current_user - .awards-controls - %a.add-award{"href" => "#"} - = icon('smile-o') + %div.award-menu-holder.js-award-holder + %a.btn.award-control.js-add-award{"href" => "#"} + = icon('smile-o', {class: "award-control-icon"}) + = icon('spinner spin', {class: "award-control-icon award-control-icon-loading"}) + %span.award-control-text + Add - if current_user :javascript @@ -23,17 +26,3 @@ noteable_id, aliases ); - - $(".awards").on("click", ".emoji-menu-content li", function(e) { - var emoji = $(this).find(".emoji-icon").data("emoji"); - awards_handler.addAward(emoji); - }); - - $(".awards").on("click", ".award", function(e) { - var emoji = $(this).find(".icon").data("emoji"); - awards_handler.addAward(emoji); - }); - - $(".award").tooltip(); - - $(".emoji-menu-content").niceScroll({cursorwidth: "7px", autohidemode: false}); diff --git a/config/application.rb b/config/application.rb index 7fd75ebe69e..2b103c4592d 100644 --- a/config/application.rb +++ b/config/application.rb @@ -4,6 +4,7 @@ require 'rails/all' require 'devise' I18n.config.enforce_available_locales = false Bundler.require(:default, Rails.env) +require_relative '../lib/gitlab/redis_config' module Gitlab REDIS_CACHE_NAMESPACE = 'cache:gitlab' @@ -33,7 +34,7 @@ module Gitlab config.encoding = "utf-8" # Configure sensitive parameters which will be filtered from the log file. - config.filter_parameters.push(:password, :password_confirmation, :private_token, :otp_attempt, :variables) + config.filter_parameters.push(:password, :password_confirmation, :private_token, :otp_attempt, :variables, :import_url) # Enable escaping HTML in JSON. config.active_support.escape_html_entities_in_json = true @@ -67,22 +68,7 @@ module Gitlab end end - # Use Redis caching across all environments - redis_config_file = Rails.root.join('config', 'resque.yml') - - redis_url_string = if File.exists?(redis_config_file) - YAML.load_file(redis_config_file)[Rails.env] - else - "redis://localhost:6379" - end - - # Redis::Store does not handle Unix sockets well, so let's do it for them - redis_config_hash = Redis::Store::Factory.extract_host_options_from_uri(redis_url_string) - redis_uri = URI.parse(redis_url_string) - if redis_uri.scheme == 'unix' - redis_config_hash[:path] = redis_uri.path - end - + redis_config_hash = Gitlab::RedisConfig.redis_store_options redis_config_hash[:namespace] = REDIS_CACHE_NAMESPACE redis_config_hash[:expires_in] = 2.weeks # Cache should not grow forever config.cache_store = :redis_store, redis_config_hash diff --git a/config/environments/test.rb b/config/environments/test.rb index d6842affa6c..f96ac6f9753 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -7,8 +7,6 @@ Rails.application.configure do # and recreated between test runs. Don't rely on the data there! config.cache_classes = false - config.cache_store = :null_store - # Configure static asset server for tests with Cache-Control for performance config.serve_static_files = true config.static_cache_control = "public, max-age=3600" diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index d82cfb3ec0c..31dceaebcad 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -203,11 +203,11 @@ Devise.setup do |config| # If you want to use other strategies, that are not supported by Devise, or # change the failure app, you can configure them inside the config.warden block. # - # config.warden do |manager| - # manager.failure_app = AnotherApp - # manager.intercept_401 = false - # manager.default_strategies(scope: :user).unshift :some_external_strategy - # end + config.warden do |manager| + manager.failure_app = Gitlab::DeviseFailure + # manager.intercept_401 = false + # manager.default_strategies(scope: :user).unshift :some_external_strategy + end if Gitlab::LDAP::Config.enabled? Gitlab.config.ldap.servers.values.each do |server| diff --git a/config/initializers/go_get.rb b/config/initializers/go_get.rb new file mode 100644 index 00000000000..7e7896b4900 --- /dev/null +++ b/config/initializers/go_get.rb @@ -0,0 +1 @@ +Rails.application.config.middleware.use(Gitlab::Middleware::Go) diff --git a/config/initializers/mysql_ignore_postgresql_options.rb b/config/initializers/mysql_ignore_postgresql_options.rb new file mode 100644 index 00000000000..835f3ec5574 --- /dev/null +++ b/config/initializers/mysql_ignore_postgresql_options.rb @@ -0,0 +1,49 @@ +# This patches ActiveRecord so indexes created using the MySQL adapter ignore +# any PostgreSQL specific options (e.g. `using: :gin`). +# +# These patches do the following for MySQL: +# +# 1. Indexes created using the :opclasses option are ignored (as they serve no +# purpose on MySQL). +# 2. When creating an index with `using: :gin` the `using` option is discarded +# as :gin is not a valid value for MySQL. +# 3. The `:opclasses` option is stripped from add_index_options in case it's +# used anywhere other than in the add_index methods. + +if defined?(ActiveRecord::ConnectionAdapters::Mysql2Adapter) + module ActiveRecord + module ConnectionAdapters + class Mysql2Adapter < AbstractMysqlAdapter + alias_method :__gitlab_add_index, :add_index + alias_method :__gitlab_add_index_sql, :add_index_sql + alias_method :__gitlab_add_index_options, :add_index_options + + def add_index(table_name, column_name, options = {}) + unless options[:opclasses] + __gitlab_add_index(table_name, column_name, options) + end + end + + def add_index_sql(table_name, column_name, options = {}) + unless options[:opclasses] + __gitlab_add_index_sql(table_name, column_name, options) + end + end + + def add_index_options(table_name, column_name, options = {}) + if options[:using] and options[:using] == :gin + options = options.dup + options.delete(:using) + end + + if options[:opclasses] + options = options.dup + options.delete(:opclasses) + end + + __gitlab_add_index_options(table_name, column_name, options) + end + end + end + end +end diff --git a/config/initializers/postgresql_opclasses_support.rb b/config/initializers/postgresql_opclasses_support.rb new file mode 100644 index 00000000000..820cc89ef57 --- /dev/null +++ b/config/initializers/postgresql_opclasses_support.rb @@ -0,0 +1,188 @@ +# rubocop:disable all + +# These changes add support for PostgreSQL operator classes when creating +# indexes and dumping/loading schemas. Taken from Rails pull request +# https://github.com/rails/rails/pull/19090. +# +# License: +# +# Copyright (c) 2004-2016 David Heinemeier Hansson +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +require 'date' +require 'set' +require 'bigdecimal' +require 'bigdecimal/util' + +# As the Struct definition is changed in this PR/patch we have to first remove +# the existing one. +ActiveRecord::ConnectionAdapters.send(:remove_const, :IndexDefinition) + +module ActiveRecord + module ConnectionAdapters #:nodoc: + # Abstract representation of an index definition on a table. Instances of + # this type are typically created and returned by methods in database + # adapters. e.g. ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter#indexes + class IndexDefinition < Struct.new(:table, :name, :unique, :columns, :lengths, :orders, :where, :type, :using, :opclasses) #:nodoc: + end + end +end + + +module ActiveRecord + module ConnectionAdapters # :nodoc: + module SchemaStatements + def add_index_options(table_name, column_name, options = {}) #:nodoc: + column_names = Array(column_name) + index_name = index_name(table_name, column: column_names) + + options.assert_valid_keys(:unique, :order, :name, :where, :length, :internal, :using, :algorithm, :type, :opclasses) + + index_type = options[:unique] ? "UNIQUE" : "" + index_type = options[:type].to_s if options.key?(:type) + index_name = options[:name].to_s if options.key?(:name) + max_index_length = options.fetch(:internal, false) ? index_name_length : allowed_index_name_length + + if options.key?(:algorithm) + algorithm = index_algorithms.fetch(options[:algorithm]) { + raise ArgumentError.new("Algorithm must be one of the following: #{index_algorithms.keys.map(&:inspect).join(', ')}") + } + end + + using = "USING #{options[:using]}" if options[:using].present? + + if supports_partial_index? + index_options = options[:where] ? " WHERE #{options[:where]}" : "" + end + + if index_name.length > max_index_length + raise ArgumentError, "Index name '#{index_name}' on table '#{table_name}' is too long; the limit is #{max_index_length} characters" + end + if table_exists?(table_name) && index_name_exists?(table_name, index_name, false) + raise ArgumentError, "Index name '#{index_name}' on table '#{table_name}' already exists" + end + index_columns = quoted_columns_for_index(column_names, options).join(", ") + + [index_name, index_type, index_columns, index_options, algorithm, using] + end + end + end +end + +module ActiveRecord + module ConnectionAdapters + module PostgreSQL + module SchemaStatements + # Returns an array of indexes for the given table. + def indexes(table_name, name = nil) + result = query(<<-SQL, 'SCHEMA') + SELECT distinct i.relname, d.indisunique, d.indkey, pg_get_indexdef(d.indexrelid), t.oid + FROM pg_class t + INNER JOIN pg_index d ON t.oid = d.indrelid + INNER JOIN pg_class i ON d.indexrelid = i.oid + WHERE i.relkind = 'i' + AND d.indisprimary = 'f' + AND t.relname = '#{table_name}' + AND i.relnamespace IN (SELECT oid FROM pg_namespace WHERE nspname = ANY (current_schemas(false)) ) + ORDER BY i.relname + SQL + + result.map do |row| + index_name = row[0] + unique = row[1] == 't' + indkey = row[2].split(" ") + inddef = row[3] + oid = row[4] + + columns = Hash[query(<<-SQL, "SCHEMA")] + SELECT a.attnum, a.attname + FROM pg_attribute a + WHERE a.attrelid = #{oid} + AND a.attnum IN (#{indkey.join(",")}) + SQL + + column_names = columns.values_at(*indkey).compact + + unless column_names.empty? + # add info on sort order for columns (only desc order is explicitly specified, asc is the default) + desc_order_columns = inddef.scan(/(\w+) DESC/).flatten + orders = desc_order_columns.any? ? Hash[desc_order_columns.map {|order_column| [order_column, :desc]}] : {} + where = inddef.scan(/WHERE (.+)$/).flatten[0] + using = inddef.scan(/USING (.+?) /).flatten[0].to_sym + opclasses = Hash[inddef.scan(/\((.+)\)$/).flatten[0].split(',').map do |column_and_opclass| + column, opclass = column_and_opclass.split(' ').map(&:strip) + [column, opclass] if opclass + end.compact] + + IndexDefinition.new(table_name, index_name, unique, column_names, [], orders, where, nil, using, opclasses) + end + end.compact + end + + def add_index(table_name, column_name, options = {}) #:nodoc: + index_name, index_type, index_columns_and_opclasses, index_options, index_algorithm, index_using = add_index_options(table_name, column_name, options) + execute "CREATE #{index_type} INDEX #{index_algorithm} #{quote_column_name(index_name)} ON #{quote_table_name(table_name)} #{index_using} (#{index_columns_and_opclasses})#{index_options}" + end + + protected + + def quoted_columns_for_index(column_names, options = {}) + column_opclasses = options[:opclasses] || {} + column_names.map {|name| "#{quote_column_name(name)} #{column_opclasses[name]}"} + end + end + end + end +end + +module ActiveRecord + class SchemaDumper + private + + def indexes(table, stream) + if (indexes = @connection.indexes(table)).any? + add_index_statements = indexes.map do |index| + statement_parts = [ + "add_index #{remove_prefix_and_suffix(index.table).inspect}", + index.columns.inspect, + "name: #{index.name.inspect}", + ] + statement_parts << 'unique: true' if index.unique + + index_lengths = (index.lengths || []).compact + statement_parts << "length: #{Hash[index.columns.zip(index.lengths)].inspect}" if index_lengths.any? + + index_orders = index.orders || {} + statement_parts << "order: #{index.orders.inspect}" if index_orders.any? + statement_parts << "where: #{index.where.inspect}" if index.where + statement_parts << "using: #{index.using.inspect}" if index.using + statement_parts << "type: #{index.type.inspect}" if index.type + statement_parts << "opclasses: #{index.opclasses}" if index.opclasses.present? + + " #{statement_parts.join(', ')}" + end + + stream.puts add_index_statements.sort.join("\n") + stream.puts + end + end + end +end diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb index 0fc725842ba..3da5d46be92 100644 --- a/config/initializers/session_store.rb +++ b/config/initializers/session_store.rb @@ -13,9 +13,12 @@ end if Rails.env.test? Gitlab::Application.config.session_store :cookie_store, key: "_gitlab_session" else + redis_config = Gitlab::RedisConfig.redis_store_options + redis_config[:namespace] = 'session:gitlab' + Gitlab::Application.config.session_store( :redis_store, # Using the cookie_store would enable session replay attacks. - servers: Rails.application.config.cache_store[1].merge(namespace: 'session:gitlab'), # re-use the Redis config from the Rails cache store + servers: redis_config, key: '_gitlab_session', secure: Gitlab.config.gitlab.https, httponly: true, diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index dcf6ce74d96..cc83137745a 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -1,16 +1,9 @@ -# Custom Redis configuration -config_file = Rails.root.join('config', 'resque.yml') - -resque_url = if File.exists?(config_file) - YAML.load_file(config_file)[Rails.env] - else - "redis://localhost:6379" - end +SIDEKIQ_REDIS_NAMESPACE = 'resque:gitlab' Sidekiq.configure_server do |config| config.redis = { - url: resque_url, - namespace: 'resque:gitlab' + url: Gitlab::RedisConfig.url, + namespace: SIDEKIQ_REDIS_NAMESPACE } config.server_middleware do |chain| @@ -36,7 +29,7 @@ end Sidekiq.configure_client do |config| config.redis = { - url: resque_url, - namespace: 'resque:gitlab' + url: Gitlab::RedisConfig.url, + namespace: SIDEKIQ_REDIS_NAMESPACE } end diff --git a/config/mail_room.yml b/config/mail_room.yml index f266a70ee0d..aed55f74eab 100644 --- a/config/mail_room.yml +++ b/config/mail_room.yml @@ -2,6 +2,7 @@ <% require "yaml" require "json" +require_relative "lib/gitlab/redis_config" rails_env = ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development" @@ -17,13 +18,7 @@ if File.exists?(config_file) config['mailbox'] = "inbox" if config['mailbox'].nil? if config['enabled'] && config['address'] && config['address'].include?('%{key}') - redis_config_file = "config/resque.yml" - redis_url = - if File.exists?(redis_config_file) - YAML.load_file(redis_config_file)[rails_env] - else - "redis://localhost:6379" - end + redis_url = Gitlab::RedisConfig.new(rails_env).url %> - :host: <%= config['host'].to_json %> diff --git a/config/routes.rb b/config/routes.rb index a918b5bd3f0..780ad757c5b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -295,7 +295,7 @@ Rails.application.routes.draw do resource :profile, only: [:show, :update] do member do get :audit_log - get :applications + get :applications, to: 'oauth/applications#index' put :reset_private_token put :update_username @@ -382,7 +382,7 @@ Rails.application.routes.draw do get :issues get :merge_requests get :projects - get :events + get :activity end scope module: :groups do @@ -701,6 +701,8 @@ Rails.application.routes.draw do end end + resources :group_links, only: [:index, :create, :destroy], constraints: { id: /\d+/ } + resources :notes, only: [:index, :create, :destroy, :update], constraints: { id: /\d+/ } do member do delete :delete_attachment diff --git a/db/migrate/20130711063759_create_project_group_links.rb b/db/migrate/20130711063759_create_project_group_links.rb new file mode 100644 index 00000000000..395083f2a03 --- /dev/null +++ b/db/migrate/20130711063759_create_project_group_links.rb @@ -0,0 +1,10 @@ +class CreateProjectGroupLinks < ActiveRecord::Migration + def change + create_table :project_group_links do |t| + t.integer :project_id, null: false + t.integer :group_id, null: false + + t.timestamps + end + end +end diff --git a/db/migrate/20130820102832_add_access_to_project_group_link.rb b/db/migrate/20130820102832_add_access_to_project_group_link.rb new file mode 100644 index 00000000000..00e3947a6bb --- /dev/null +++ b/db/migrate/20130820102832_add_access_to_project_group_link.rb @@ -0,0 +1,5 @@ +class AddAccessToProjectGroupLink < ActiveRecord::Migration + def change + add_column :project_group_links, :group_access, :integer, null: false, default: ProjectGroupLink.default_access + end +end diff --git a/db/migrate/20150930110012_add_group_share_lock.rb b/db/migrate/20150930110012_add_group_share_lock.rb new file mode 100644 index 00000000000..78d1a4538f2 --- /dev/null +++ b/db/migrate/20150930110012_add_group_share_lock.rb @@ -0,0 +1,5 @@ +class AddGroupShareLock < ActiveRecord::Migration + def change + add_column :namespaces, :share_with_group_lock, :boolean, default: false + end +end diff --git a/db/migrate/20160226114608_add_trigram_indexes_for_searching.rb b/db/migrate/20160226114608_add_trigram_indexes_for_searching.rb new file mode 100644 index 00000000000..003169c13c6 --- /dev/null +++ b/db/migrate/20160226114608_add_trigram_indexes_for_searching.rb @@ -0,0 +1,53 @@ +class AddTrigramIndexesForSearching < ActiveRecord::Migration + disable_ddl_transaction! + + def up + return unless Gitlab::Database.postgresql? + + unless trigrams_enabled? + raise 'You must enable the pg_trgm extension. You can do so by running ' \ + '"CREATE EXTENSION pg_trgm;" as a PostgreSQL super user, this must be ' \ + 'done for every GitLab database. For more information see ' \ + 'http://www.postgresql.org/docs/current/static/sql-createextension.html' + end + + # trigram indexes are case-insensitive so we can just index the column + # instead of indexing lower(column) + to_index.each do |table, columns| + columns.each do |column| + execute "CREATE INDEX CONCURRENTLY index_#{table}_on_#{column}_trigram ON #{table} USING gin(#{column} gin_trgm_ops);" + end + end + end + + def down + return unless Gitlab::Database.postgresql? + + to_index.each do |table, columns| + columns.each do |column| + remove_index table, name: "index_#{table}_on_#{column}_trigram" + end + end + end + + def trigrams_enabled? + res = execute("SELECT true AS enabled FROM pg_available_extensions WHERE name = 'pg_trgm' AND installed_version IS NOT NULL;") + row = res.first + + row && row['enabled'] == 't' ? true : false + end + + def to_index + { + ci_runners: [:token, :description], + issues: [:title, :description], + merge_requests: [:title, :description], + milestones: [:title, :description], + namespaces: [:name, :path], + notes: [:note], + projects: [:name, :path, :description], + snippets: [:title, :file_name], + users: [:username, :name, :email] + } + end +end diff --git a/db/migrate/20160305220806_remove_expires_at_from_snippets.rb b/db/migrate/20160305220806_remove_expires_at_from_snippets.rb new file mode 100644 index 00000000000..fc12b5b09e6 --- /dev/null +++ b/db/migrate/20160305220806_remove_expires_at_from_snippets.rb @@ -0,0 +1,5 @@ +class RemoveExpiresAtFromSnippets < ActiveRecord::Migration + def change + remove_column :snippets, :expires_at, :datetime + end +end diff --git a/db/migrate/20160307221555_disallow_blank_line_code_on_note.rb b/db/migrate/20160307221555_disallow_blank_line_code_on_note.rb new file mode 100644 index 00000000000..49e787d9a9a --- /dev/null +++ b/db/migrate/20160307221555_disallow_blank_line_code_on_note.rb @@ -0,0 +1,9 @@ +class DisallowBlankLineCodeOnNote < ActiveRecord::Migration + def up + execute("UPDATE notes SET line_code = NULL WHERE line_code = ''") + end + + def down + # noop + end +end diff --git a/db/migrate/20160309140734_fix_todos.rb b/db/migrate/20160309140734_fix_todos.rb new file mode 100644 index 00000000000..ebe0fc82305 --- /dev/null +++ b/db/migrate/20160309140734_fix_todos.rb @@ -0,0 +1,16 @@ +class FixTodos < ActiveRecord::Migration + def up + execute <<-SQL + DELETE FROM todos + WHERE todos.target_type IN ('Commit', 'ProjectSnippet') + OR NOT EXISTS ( + SELECT * + FROM projects + WHERE projects.id = todos.project_id + ) + SQL + end + + def down + end +end diff --git a/db/migrate/20160314143402_projects_add_pushes_since_gc.rb b/db/migrate/20160314143402_projects_add_pushes_since_gc.rb new file mode 100644 index 00000000000..5d30a38bc99 --- /dev/null +++ b/db/migrate/20160314143402_projects_add_pushes_since_gc.rb @@ -0,0 +1,5 @@ +class ProjectsAddPushesSinceGc < ActiveRecord::Migration + def change + add_column :projects, :pushes_since_gc, :integer, default: 0 + end +end diff --git a/db/schema.rb b/db/schema.rb index 71d9257a31e..5027d2ba32f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,10 +11,11 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20160222153918) do +ActiveRecord::Schema.define(version: 20160314143402) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" + enable_extension "pg_trgm" create_table "abuse_reports", force: :cascade do |t| t.integer "reporter_id" @@ -258,6 +259,9 @@ ActiveRecord::Schema.define(version: 20160222153918) do t.string "architecture" end + add_index "ci_runners", ["description"], name: "index_ci_runners_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"} + add_index "ci_runners", ["token"], name: "index_ci_runners_on_token_trigram", using: :gin, opclasses: {"token"=>"gin_trgm_ops"} + create_table "ci_services", force: :cascade do |t| t.string "type" t.string "title" @@ -417,11 +421,13 @@ ActiveRecord::Schema.define(version: 20160222153918) do add_index "issues", ["author_id"], name: "index_issues_on_author_id", using: :btree add_index "issues", ["created_at", "id"], name: "index_issues_on_created_at_and_id", using: :btree add_index "issues", ["created_at"], name: "index_issues_on_created_at", using: :btree + add_index "issues", ["description"], name: "index_issues_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"} add_index "issues", ["milestone_id"], name: "index_issues_on_milestone_id", using: :btree add_index "issues", ["project_id", "iid"], name: "index_issues_on_project_id_and_iid", unique: true, using: :btree add_index "issues", ["project_id"], name: "index_issues_on_project_id", using: :btree add_index "issues", ["state"], name: "index_issues_on_state", using: :btree add_index "issues", ["title"], name: "index_issues_on_title", using: :btree + add_index "issues", ["title"], name: "index_issues_on_title_trigram", using: :gin, opclasses: {"title"=>"gin_trgm_ops"} create_table "keys", force: :cascade do |t| t.integer "user_id" @@ -543,12 +549,14 @@ ActiveRecord::Schema.define(version: 20160222153918) do add_index "merge_requests", ["author_id"], name: "index_merge_requests_on_author_id", using: :btree add_index "merge_requests", ["created_at", "id"], name: "index_merge_requests_on_created_at_and_id", using: :btree add_index "merge_requests", ["created_at"], name: "index_merge_requests_on_created_at", using: :btree + add_index "merge_requests", ["description"], name: "index_merge_requests_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"} add_index "merge_requests", ["milestone_id"], name: "index_merge_requests_on_milestone_id", using: :btree add_index "merge_requests", ["source_branch"], name: "index_merge_requests_on_source_branch", using: :btree add_index "merge_requests", ["source_project_id"], name: "index_merge_requests_on_source_project_id", using: :btree add_index "merge_requests", ["target_branch"], name: "index_merge_requests_on_target_branch", using: :btree add_index "merge_requests", ["target_project_id", "iid"], name: "index_merge_requests_on_target_project_id_and_iid", unique: true, using: :btree add_index "merge_requests", ["title"], name: "index_merge_requests_on_title", using: :btree + add_index "merge_requests", ["title"], name: "index_merge_requests_on_title_trigram", using: :gin, opclasses: {"title"=>"gin_trgm_ops"} create_table "milestones", force: :cascade do |t| t.string "title", null: false @@ -562,26 +570,31 @@ ActiveRecord::Schema.define(version: 20160222153918) do end add_index "milestones", ["created_at", "id"], name: "index_milestones_on_created_at_and_id", using: :btree + add_index "milestones", ["description"], name: "index_milestones_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"} add_index "milestones", ["due_date"], name: "index_milestones_on_due_date", using: :btree add_index "milestones", ["project_id", "iid"], name: "index_milestones_on_project_id_and_iid", unique: true, using: :btree add_index "milestones", ["project_id"], name: "index_milestones_on_project_id", using: :btree add_index "milestones", ["title"], name: "index_milestones_on_title", using: :btree + add_index "milestones", ["title"], name: "index_milestones_on_title_trigram", using: :gin, opclasses: {"title"=>"gin_trgm_ops"} create_table "namespaces", force: :cascade do |t| - t.string "name", null: false - t.string "path", null: false + t.string "name", null: false + t.string "path", null: false t.integer "owner_id" t.datetime "created_at" t.datetime "updated_at" t.string "type" - t.string "description", default: "", null: false + t.string "description", default: "", null: false t.string "avatar" + t.boolean "share_with_group_lock", default: false end add_index "namespaces", ["created_at", "id"], name: "index_namespaces_on_created_at_and_id", using: :btree add_index "namespaces", ["name"], name: "index_namespaces_on_name", unique: true, using: :btree + add_index "namespaces", ["name"], name: "index_namespaces_on_name_trigram", using: :gin, opclasses: {"name"=>"gin_trgm_ops"} add_index "namespaces", ["owner_id"], name: "index_namespaces_on_owner_id", using: :btree add_index "namespaces", ["path"], name: "index_namespaces_on_path", unique: true, using: :btree + add_index "namespaces", ["path"], name: "index_namespaces_on_path_trigram", using: :gin, opclasses: {"path"=>"gin_trgm_ops"} add_index "namespaces", ["type"], name: "index_namespaces_on_type", using: :btree create_table "notes", force: :cascade do |t| @@ -607,6 +620,7 @@ ActiveRecord::Schema.define(version: 20160222153918) do add_index "notes", ["created_at"], name: "index_notes_on_created_at", using: :btree add_index "notes", ["is_award"], name: "index_notes_on_is_award", using: :btree add_index "notes", ["line_code"], name: "index_notes_on_line_code", using: :btree + add_index "notes", ["note"], name: "index_notes_on_note_trigram", using: :gin, opclasses: {"note"=>"gin_trgm_ops"} add_index "notes", ["noteable_id", "noteable_type"], name: "index_notes_on_noteable_id_and_noteable_type", using: :btree add_index "notes", ["noteable_type"], name: "index_notes_on_noteable_type", using: :btree add_index "notes", ["project_id", "noteable_type"], name: "index_notes_on_project_id_and_noteable_type", using: :btree @@ -656,6 +670,14 @@ ActiveRecord::Schema.define(version: 20160222153918) do add_index "oauth_applications", ["owner_id", "owner_type"], name: "index_oauth_applications_on_owner_id_and_owner_type", using: :btree add_index "oauth_applications", ["uid"], name: "index_oauth_applications_on_uid", unique: true, using: :btree + create_table "project_group_links", force: :cascade do |t| + t.integer "project_id", null: false + t.integer "group_id", null: false + t.datetime "created_at" + t.datetime "updated_at" + t.integer "group_access", default: 30, null: false + end + create_table "project_import_data", force: :cascade do |t| t.integer "project_id" t.text "data" @@ -697,6 +719,8 @@ ActiveRecord::Schema.define(version: 20160222153918) do t.integer "build_timeout", default: 3600, null: false t.boolean "pending_delete", default: false t.boolean "public_builds", default: true, null: false + t.string "main_language" + t.integer "pushes_since_gc", default: 0 end add_index "projects", ["builds_enabled", "shared_runners_enabled"], name: "index_projects_on_builds_enabled_and_shared_runners_enabled", using: :btree @@ -704,9 +728,12 @@ ActiveRecord::Schema.define(version: 20160222153918) do add_index "projects", ["ci_id"], name: "index_projects_on_ci_id", using: :btree add_index "projects", ["created_at", "id"], name: "index_projects_on_created_at_and_id", using: :btree add_index "projects", ["creator_id"], name: "index_projects_on_creator_id", using: :btree + add_index "projects", ["description"], name: "index_projects_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"} add_index "projects", ["last_activity_at"], name: "index_projects_on_last_activity_at", using: :btree + add_index "projects", ["name"], name: "index_projects_on_name_trigram", using: :gin, opclasses: {"name"=>"gin_trgm_ops"} add_index "projects", ["namespace_id"], name: "index_projects_on_namespace_id", using: :btree add_index "projects", ["path"], name: "index_projects_on_path", using: :btree + add_index "projects", ["path"], name: "index_projects_on_path_trigram", using: :gin, opclasses: {"path"=>"gin_trgm_ops"} add_index "projects", ["runners_token"], name: "index_projects_on_runners_token", using: :btree add_index "projects", ["star_count"], name: "index_projects_on_star_count", using: :btree add_index "projects", ["visibility_level"], name: "index_projects_on_visibility_level", using: :btree @@ -748,9 +775,9 @@ ActiveRecord::Schema.define(version: 20160222153918) do t.string "type" t.string "title" t.integer "project_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.boolean "active", null: false + t.datetime "created_at" + t.datetime "updated_at" + t.boolean "active", default: false, null: false t.text "properties" t.boolean "template", default: false t.boolean "push_events", default: true @@ -777,7 +804,6 @@ ActiveRecord::Schema.define(version: 20160222153918) do t.datetime "created_at" t.datetime "updated_at" t.string "file_name" - t.datetime "expires_at" t.string "type" t.integer "visibility_level", default: 0, null: false end @@ -785,8 +811,9 @@ ActiveRecord::Schema.define(version: 20160222153918) do add_index "snippets", ["author_id"], name: "index_snippets_on_author_id", using: :btree add_index "snippets", ["created_at", "id"], name: "index_snippets_on_created_at_and_id", using: :btree add_index "snippets", ["created_at"], name: "index_snippets_on_created_at", using: :btree - add_index "snippets", ["expires_at"], name: "index_snippets_on_expires_at", using: :btree + add_index "snippets", ["file_name"], name: "index_snippets_on_file_name_trigram", using: :gin, opclasses: {"file_name"=>"gin_trgm_ops"} add_index "snippets", ["project_id"], name: "index_snippets_on_project_id", using: :btree + add_index "snippets", ["title"], name: "index_snippets_on_title_trigram", using: :gin, opclasses: {"title"=>"gin_trgm_ops"} add_index "snippets", ["updated_at"], name: "index_snippets_on_updated_at", using: :btree add_index "snippets", ["visibility_level"], name: "index_snippets_on_visibility_level", using: :btree @@ -920,9 +947,12 @@ ActiveRecord::Schema.define(version: 20160222153918) do add_index "users", ["created_at", "id"], name: "index_users_on_created_at_and_id", using: :btree add_index "users", ["current_sign_in_at"], name: "index_users_on_current_sign_in_at", using: :btree add_index "users", ["email"], name: "index_users_on_email", unique: true, using: :btree + add_index "users", ["email"], name: "index_users_on_email_trigram", using: :gin, opclasses: {"email"=>"gin_trgm_ops"} add_index "users", ["name"], name: "index_users_on_name", using: :btree + add_index "users", ["name"], name: "index_users_on_name_trigram", using: :gin, opclasses: {"name"=>"gin_trgm_ops"} add_index "users", ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true, using: :btree add_index "users", ["username"], name: "index_users_on_username", using: :btree + add_index "users", ["username"], name: "index_users_on_username_trigram", using: :gin, opclasses: {"username"=>"gin_trgm_ops"} create_table "users_star_projects", force: :cascade do |t| t.integer "project_id", null: false diff --git a/doc/README.md b/doc/README.md index be6c5f96ea1..0ca30e4e0f2 100644 --- a/doc/README.md +++ b/doc/README.md @@ -13,7 +13,7 @@ - [Project Services](project_services/project_services.md) Integrate a project with external services, such as CI and chat. - [Public access](public_access/public_access.md) Learn how you can allow public and internal access to projects. - [SSH](ssh/README.md) Setup your ssh keys and deploy keys for secure access to your projects. -- [Web hooks](web_hooks/web_hooks.md) Let GitLab notify you when new code has been pushed to your project. +- [Webhooks](web_hooks/web_hooks.md) Let GitLab notify you when new code has been pushed to your project. - [Workflow](workflow/README.md) Using GitLab functionality and importing projects from GitHub and SVN. ## CI User documentation @@ -54,7 +54,7 @@ be linked with your base image. Below is a list of examples you may use: ## Administrator documentation -- [Custom git hooks](hooks/custom_hooks.md) Custom git hooks (on the filesystem) for when web hooks aren't enough. +- [Custom git hooks](hooks/custom_hooks.md) Custom git hooks (on the filesystem) for when webhooks aren't enough. - [Install](install/README.md) Requirements, directory structures and installation from source. - [Restart GitLab](administration/restart_gitlab.md) Learn how to restart GitLab and its components - [Integration](integration/README.md) How to integrate with systems such as JIRA, Redmine, LDAP and Twitter. @@ -63,7 +63,7 @@ be linked with your base image. Below is a list of examples you may use: - [Log system](logs/logs.md) Log system. - [Environment Variables](administration/environment_variables.md) to configure GitLab. - [Operations](operations/README.md) Keeping GitLab up and running -- [Raketasks](raketasks/README.md) Backups, maintenance, automatic web hook setup and the importing of projects. +- [Raketasks](raketasks/README.md) Backups, maintenance, automatic webhook setup and the importing of projects. - [Security](security/README.md) Learn what you can do to further secure your GitLab instance. - [System hooks](system_hooks/system_hooks.md) Notifications when users, projects and keys are changed. - [Update](update/README.md) Update guides to upgrade your installation. diff --git a/doc/api/builds.md b/doc/api/builds.md index d3ce72e59fc..4c0a47d1ea0 100644 --- a/doc/api/builds.md +++ b/doc/api/builds.md @@ -33,7 +33,6 @@ Example of response }, "coverage": null, "created_at": "2015-12-24T15:51:21.802Z", - "download_url": null, "artifacts_file": { "filename": "artifacts.zip", "size": 1000 @@ -75,7 +74,6 @@ Example of response }, "coverage": null, "created_at": "2015-12-24T15:51:21.727Z", - "download_url": null, "artifacts_file": null, "finished_at": "2015-12-24T17:54:24.921Z", "id": 6, @@ -139,7 +137,6 @@ Example of response }, "coverage": null, "created_at": "2016-01-11T10:13:33.506Z", - "download_url": null, "artifacts_file": null, "finished_at": "2016-01-11T10:14:09.526Z", "id": 69, @@ -164,7 +161,6 @@ Example of response }, "coverage": null, "created_at": "2015-12-24T15:51:21.957Z", - "download_url": null, "artifacts_file": null, "finished_at": "2015-12-24T17:54:33.913Z", "id": 9, @@ -226,7 +222,6 @@ Example of response }, "coverage": null, "created_at": "2015-12-24T15:51:21.880Z", - "download_url": null, "artifacts_file": null, "finished_at": "2015-12-24T17:54:31.198Z", "id": 8, @@ -315,7 +310,6 @@ Example of response }, "coverage": null, "created_at": "2016-01-11T10:13:33.506Z", - "download_url": null, "artifacts_file": null, "finished_at": "2016-01-11T10:14:09.526Z", "id": 69, @@ -362,7 +356,6 @@ Example of response }, "coverage": null, "created_at": "2016-01-11T10:13:33.506Z", - "download_url": null, "artifacts_file": null, "finished_at": null, "id": 69, diff --git a/doc/api/projects.md b/doc/api/projects.md index 9e9486cd87a..3703f4b327a 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -619,6 +619,20 @@ Revoking team membership for a user who is not currently a team member is consid Please note that the returned JSON currently differs slightly. Thus you should not rely on the returned JSON structure. +### Share project with group + +Allow to share project with group. + +``` +POST /projects/:id/share +``` + +Parameters: + +- `id` (required) - The ID of a project +- `group_id` (required) - The ID of a group +- `group_access` (required) - Level of permissions for sharing + ## Hooks Also called Project Hooks and Webhooks. diff --git a/doc/api/users.md b/doc/api/users.md index b7fc903825e..82c57a2fd43 100644 --- a/doc/api/users.md +++ b/doc/api/users.md @@ -151,6 +151,8 @@ Parameters: "name": "John Smith", "state": "active", "created_at": "2012-05-23T08:00:58Z", + "confirmed_at": "2012-05-23T08:00:58Z", + "last_sign_in_at": "2015-03-23T08:00:58Z", "bio": null, "skype": "", "linkedin": "", diff --git a/doc/ci/README.md b/doc/ci/README.md index 2120b5b2850..4abc45bf9bb 100644 --- a/doc/ci/README.md +++ b/doc/ci/README.md @@ -3,6 +3,7 @@ ### CI User documentation - [Get started with GitLab CI](quick_start/README.md) +- [CI examples for various languages](examples/README.md) - [Learn how to enable or disable GitLab CI](enable_or_disable_ci.md) - [Learn how `.gitlab-ci.yml` works](yaml/README.md) - [Configure a Runner, the application that runs your builds](runners/README.md) @@ -14,24 +15,4 @@ - [Build artifacts](build_artifacts/README.md) - [User permissions](permissions/README.md) - [API](api/README.md) - -### CI Examples - -- [The .gitlab-ci.yml file for GitLab itself](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/.gitlab-ci.yml) -- [Test your PHP applications](examples/php.md) -- [Test and deploy Ruby applications to Heroku](examples/test-and-deploy-ruby-application-to-heroku.md) -- [Test and deploy Python applications to Heroku](examples/test-and-deploy-python-application-to-heroku.md) -- [Test Clojure applications](examples/test-clojure-application.md) -- [Using `dpl` as deployment tool](deployment/README.md) -- Help your favorite programming language and GitLab by sending a merge request - with a guide for that language. - -### CI Services - -GitLab CI uses the `services` keyword to define what docker containers should -be linked with your base image. Below is a list of examples you may use: - -- [Using MySQL](services/mysql.md) -- [Using PostgreSQL](services/postgres.md) -- [Using Redis](services/redis.md) -- [Using Other Services](docker/using_docker_images.md#how-to-use-other-images-as-services) +- [CI services (linked docker containers)](services/README.md) diff --git a/doc/ci/enable_or_disable_ci.md b/doc/ci/enable_or_disable_ci.md index 9bd2f5aff22..c10f82054e2 100644 --- a/doc/ci/enable_or_disable_ci.md +++ b/doc/ci/enable_or_disable_ci.md @@ -64,7 +64,7 @@ Save the file and restart GitLab: `sudo service gitlab restart`. For Omnibus installations, edit `/etc/gitlab/gitlab.rb` and add the line: ``` -gitlab-rails['gitlab_default_projects_features_builds'] = false +gitlab_rails['gitlab_default_projects_features_builds'] = false ``` Save the file and reconfigure GitLab: `sudo gitlab-ctl reconfigure`. diff --git a/doc/ci/examples/README.md b/doc/ci/examples/README.md index 31f29f4a082..cc059dc4376 100644 --- a/doc/ci/examples/README.md +++ b/doc/ci/examples/README.md @@ -1,13 +1,15 @@ -## Build script examples +# CI Examples +- [Testing a PHP application](php.md) - [Test and deploy a Ruby application to Heroku](test-and-deploy-ruby-application-to-heroku.md) - [Test and deploy a Python application to Heroku](test-and-deploy-python-application-to-heroku.md) - [Test a Clojure application](test-clojure-application.md) +- [Using `dpl` as deployment tool](deployment/README.md) +- Help your favorite programming language and GitLab by sending a merge request + with a guide for that language. -## Languages +## Outside the documentation -This is a list of languages you can test with GitLab CI. Each section has -comprehensive documentation and comes with a test repository hosted on -GitLab.com. - -- [Testing PHP](php.md) +- [Blost post about using GitLab CI for iOS projects](https://about.gitlab.com/2016/03/10/setting-up-gitlab-ci-for-ios-projects/) +- [Repo's with examples for various languages](https://gitlab.com/groups/gitlab-examples) +- [The .gitlab-ci.yml file for GitLab itself](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/.gitlab-ci.yml) diff --git a/doc/ci/quick_start/README.md b/doc/ci/quick_start/README.md index 624d9899c79..9aba4326e11 100644 --- a/doc/ci/quick_start/README.md +++ b/doc/ci/quick_start/README.md @@ -223,20 +223,13 @@ You can access a builds badge image using following link: http://example.gitlab.com/namespace/project/badges/branch/build.svg ``` +Awesome! You started using CI in GitLab! + ## Examples Visit the [examples README][examples] to see a list of examples using GitLab CI with various languages. -## Next steps - -Awesome! You started using CI in GitLab! - -Next you can look into doing more with the CI. Many people are using GitLab -to package, containerize, test and deploy software. - -Visit our various languages examples at <https://gitlab.com/groups/gitlab-examples>. - [runner-install]: https://gitlab.com/gitlab-org/gitlab-ci-multi-runner/tree/master#installation [blog-ci]: https://about.gitlab.com/2015/05/06/why-were-replacing-gitlab-ci-jobs-with-gitlab-ci-dot-yml/ [examples]: ../examples/README.md diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 051eaa04152..5158e3c387c 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -116,7 +116,8 @@ Alias for [stages](#stages). ### variables -_**Note:** Introduced in GitLab Runner v0.5.0._ +>**Note:** +Introduced in GitLab Runner v0.5.0. GitLab CI allows you to add to `.gitlab-ci.yml` variables that are set in build environment. The variables are stored in the git repository and are meant to @@ -153,7 +154,8 @@ cache: #### cache:key -_**Note:** Introduced in GitLab Runner v1.0.0._ +>**Note:** +Introduced in GitLab Runner v1.0.0. The `key` directive allows you to define the affinity of caching between jobs, allowing to have a single cache for all jobs, @@ -234,13 +236,14 @@ job_name: | Keyword | Required | Description | |---------------|----------|-------------| | script | yes | Defines a shell script which is executed by runner | -| stage | no (default: `test`) | Defines a build stage | +| stage | no | Defines a build stage (default: `test`) | | type | no | Alias for `stage` | | only | no | Defines a list of git refs for which build is created | | except | no | Defines a list of git refs for which build is not created | | tags | no | Defines a list of tags which are used to select runner | | allow_failure | no | Allow build to fail. Failed build doesn't contribute to commit status | | when | no | Define when to run build. Can be `on_success`, `on_failure` or `always` | +| dependencies | no | Define other builds that a build depends on so that you can pass artifacts between them| | artifacts | no | Define list build artifacts | | cache | no | Define list of files that should be cached between subsequent runs | @@ -393,15 +396,18 @@ The above script will: ### artifacts -_**Note:** Introduced in GitLab Runner v0.7.0 for non-Windows platforms._ - -_**Note:** Limited Windows support was added in GitLab Runner v.1.0.0. -Currently not all executors are supported._ - -_**Note:** Build artifacts are only collected for successful builds._ +>**Notes:** +> +> - Introduced in GitLab Runner v0.7.0 for non-Windows platforms. +> - Windows support was added in GitLab Runner v.1.0.0. +> - Currently not all executors are supported. +> - Build artifacts are only collected for successful builds. `artifacts` is used to specify list of files and directories which should be -attached to build after success. Below are some examples. +attached to build after success. To pass artifacts between different builds, +see [dependencies](#dependencies). + +Below are some examples. Send all files in `binaries` and `.config`: @@ -453,9 +459,130 @@ release-job: The artifacts will be sent to GitLab after a successful build and will be available for download in the GitLab UI. +#### artifacts:name + +>**Note:** +Introduced in GitLab 8.6 and GitLab Runner v1.1.0. + +The `name` directive allows you to define the name of the created artifacts +archive. That way, you can have a unique name of every archive which could be +useful when you'd like to download the archive from GitLab. The `artifacts:name` +variable can make use of any of the [predefined variables](../variables/README.md). + +--- + +**Example configurations** + +To create an archive with a name of the current build: + +```yaml +job: + artifacts: + name: "$CI_BUILD_NAME" +``` + +To create an archive with a name of the current branch or tag including only +the files that are untracked by Git: + +```yaml +job: + artifacts: + name: "$CI_BUILD_REF_NAME" + untracked: true +``` + +To create an archive with a name of the current build and the current branch or +tag including only the files that are untracked by Git: + +```yaml +job: + artifacts: + name: "${CI_BUILD_NAME}_${CI_BUILD_REF_NAME}" + untracked: true +``` + +To create an archive with a name of the current [stage](#stages) and branch name: + +```yaml +job: + artifacts: + name: "${CI_BUILD_STAGE}_${CI_BUILD_REF_NAME}" + untracked: true +``` + +--- + +If you use **Windows Batch** to run your shell scripts you need to replace +`$` with `%`: + +```yaml +job: + artifacts: + name: "%CI_BUILD_STAGE%_%CI_BUILD_REF_NAME%" + untracked: true +``` + +### dependencies + +>**Note:** +Introduced in GitLab 8.6 and GitLab Runner v1.1.1. + +This feature should be used in conjunction with [`artifacts`](#artifacts) and +allows you to define the artifacts to pass between different builds. + +Note that `artifacts` from previous [stages](#stages) are passed by default. + +To use this feature, define `dependencies` in context of the job and pass +a list of all previous builds from which the artifacts should be downloaded. +You can only define builds from stages that are executed before the current one. +An error will be shown if you define builds from the current stage or next ones. + +--- + +In the following example, we define two jobs with artifacts, `build:osx` and +`build:linux`. When the `test:osx` is executed, the artifacts from `build:osx` +will be downloaded and extracted in the context of the build. The same happens +for `test:linux` and artifacts from `build:linux`. + +The job `deploy` will download artifacts from all previous builds because of +the [stage](#stages) precedence: + +```yaml +build:osx: + stage: build + script: make build:osx + artifacts: + paths: + - binaries/ + +build:linux: + stage: build + script: make build:linux + artifacts: + paths: + - binaries/ + +test:osx: + stage: test + script: make test:osx + dependencies: + - build:osx + +test:linux: + stage: test + script: make test:linux + dependencies: + - build:linux + +deploy: + stage: deploy + script: make deploy +``` + ### cache -_**Note:** Introduced in GitLab Runner v0.7.0._ +>**Note:** +Introduced in GitLab Runner v0.7.0. `cache` is used to specify list of files and directories which should be cached between builds. Below are some examples: @@ -509,6 +636,155 @@ rspec: The cache is provided on best effort basis, so don't expect that cache will be always present. For implementation details please check GitLab Runner. +## Hidden jobs + +>**Note:** +Introduced in GitLab 8.6 and GitLab Runner v1.1.1. + +Jobs that start with a dot (`.`) will be not processed by GitLab CI. You can +use this feature to ignore jobs, or use the +[special YAML features](#special-yaml-features) and transform the hidden jobs +into templates. + +In the following example, `.job_name` will be ignored: + +```yaml +.job_name: + script: + - rake spec +``` + +## Special YAML features + +It's possible to use special YAML features like anchors (`&`), aliases (`*`) +and map merging (`<<`), which will allow you to greatly reduce the complexity +of `.gitlab-ci.yml`. + +Read more about the various [YAML features](https://learnxinyminutes.com/docs/yaml/). + +### Anchors + +>**Note:** +Introduced in GitLab 8.6 and GitLab Runner v1.1.1. + +YAML also has a handy feature called 'anchors', which let you easily duplicate +content across your document. Anchors can be used to duplicate/inherit +properties, and is a perfect example to be used with [hidden jobs](#hidden-jobs) +to provide templates for your jobs. + +The following example uses anchors and map merging. It will create two jobs, +`test1` and `test2`, that will inherit the parameters of `.job_template`, each +having their own custom `script` defined: + +```yaml +.job_template: &job_definition # Hidden job that defines an anchor named 'job_definition' + image: ruby:2.1 + services: + - postgres + - redis + +test1: + <<: *job_definition # Merge the contents of the 'job_definition' alias + script: + - test1 project + +test2: + <<: *job_definition # Merge the contents of the 'job_definition' alias + script: + - test2 project +``` + +`&` sets up the name of the anchor (`job_definition`), `<<` means "merge the +given hash into the current one", and `*` includes the named anchor +(`job_definition` again). The expanded version looks like this: + +```yaml +.job_template: + image: ruby:2.1 + services: + - postgres + - redis + +test1: + image: ruby:2.1 + services: + - postgres + - redis + script: + - test1 project + +test2: + image: ruby:2.1 + services: + - postgres + - redis + script: + - test2 project +``` + +Let's see another one example. This time we will use anchors to define two sets +of services. This will create two jobs, `test:postgres` and `test:mysql`, that +will share the `script` directive defined in `.job_template`, and the `services` +directive defined in `.postgres_services` and `.mysql_services` respectively: + +```yaml +.job_template: &job_definition + script: + - test project + +.postgres_services: + services: &postgres_definition + - postgres + - ruby + +.mysql_services: + services: &mysql_definition + - mysql + - ruby + +test:postgres: + << *job_definition + services: *postgres_definition + +test:mysql: + << *job_definition + services: *mysql_definition +``` + +The expanded version looks like this: + +```yaml +.job_template: + script: + - test project + +.postgres_services: + services: + - postgres + - ruby + +.mysql_services: + services: + - mysql + - ruby + +test:postgres: + script: + - test project + services: + - postgres + - ruby + +test:mysql: + script: + - test project + services: + - mysql + - ruby +``` + +You can see that the hidden jobs are conveniently used as templates. + ## Validate the .gitlab-ci.yml Each instance of GitLab CI has an embedded debug tool called Lint. diff --git a/doc/development/README.md b/doc/development/README.md index b9a0d81e5ba..1b281809afc 100644 --- a/doc/development/README.md +++ b/doc/development/README.md @@ -1,7 +1,6 @@ # Development - [Architecture](architecture.md) of GitLab -- [Benchmarking](benchmarking.md) - [CI setup](ci_setup.md) for testing GitLab - [Gotchas](gotchas.md) to avoid - [How to dump production data to staging](db_dump.md) @@ -9,4 +8,5 @@ - [Rake tasks](rake_tasks.md) for development - [Shell commands](shell_commands.md) in the GitLab codebase - [Sidekiq debugging](sidekiq_debugging.md) +- [SQL guidelines](sql.md) for SQL guidelines - [UI guide](ui_guide.md) for building GitLab with existing css styles and elements diff --git a/doc/development/benchmarking.md b/doc/development/benchmarking.md deleted file mode 100644 index 88e18ee95f9..00000000000 --- a/doc/development/benchmarking.md +++ /dev/null @@ -1,69 +0,0 @@ -# Benchmarking - -GitLab CE comes with a set of benchmarks that are executed for every build. This -makes it easier to measure performance of certain components over time. - -Benchmarks are written as RSpec tests using a few extra helpers. To write a -benchmark, first tag the top-level `describe`: - -```ruby -describe MaruTheCat, benchmark: true do - -end -``` - -This ensures the benchmark is executed separately from other test collections. -It also exposes the various RSpec matchers used for writing benchmarks to the -test group. - -Next, lets write the actual benchmark: - -```ruby -describe MaruTheCat, benchmark: true do - let(:maru) { MaruTheChat.new } - - describe '#jump_in_box' do - benchmark_subject { maru.jump_in_box } - - it { is_expected.to iterate_per_second(9000) } - end -end -``` - -Here `benchmark_subject` is a small wrapper around RSpec's `subject` method that -makes it easier to specify the subject of a benchmark. Using RSpec's regular -`subject` would require us to write the following instead: - -```ruby -subject { -> { maru.jump_in_box } } -``` - -The `iterate_per_second` matcher defines the amount of times per second a -subject should be executed. The higher the amount of iterations the better. - -By default the allowed standard deviation is a maximum of 30%. This can be -adjusted by chaining the `with_maximum_stddev` on the `iterate_per_second` -matcher: - -```ruby -it { is_expected.to iterate_per_second(9000).with_maximum_stddev(50) } -``` - -This can be useful if the code in question depends on external resources of -which the performance can vary a lot (e.g. physical HDDs, network calls, etc). -However, in most cases 30% should be enough so only change this when really -needed. - -## Benchmarks Location - -Benchmarks should be stored in `spec/benchmarks` and should follow the regular -Rails specs structure. That is, model benchmarks go in `spec/benchmark/models`, -benchmarks for code in the `lib` directory go in `spec/benchmarks/lib`, etc. - -## Underlying Technology - -The benchmark setup uses [benchmark-ips][benchmark-ips] which takes care of the -heavy lifting such as warming up code, calculating iterations, standard -deviation, etc. - -[benchmark-ips]: https://github.com/evanphx/benchmark-ips diff --git a/doc/development/sql.md b/doc/development/sql.md new file mode 100644 index 00000000000..23fd7604957 --- /dev/null +++ b/doc/development/sql.md @@ -0,0 +1,219 @@ +# SQL Query Guidelines + +This document describes various guidelines to follow when writing SQL queries, +either using ActiveRecord/Arel or raw SQL queries. + +## Using LIKE Statements + +The most common way to search for data is using the `LIKE` statement. For +example, to get all issues with a title starting with "WIP:" you'd write the +following query: + +```sql +SELECT * +FROM issues +WHERE title LIKE 'WIP:%'; +``` + +On PostgreSQL the `LIKE` statement is case-sensitive. On MySQL this depends on +the case-sensitivity of the collation, which is usually case-insensitive. To +perform a case-insensitive `LIKE` on PostgreSQL you have to use `ILIKE` instead. +This statement in turn isn't supported on MySQL. + +To work around this problem you should write `LIKE` queries using Arel instead +of raw SQL fragments as Arel automatically uses `ILIKE` on PostgreSQL and `LIKE` +on MySQL. This means that instead of this: + +```ruby +Issue.where('title LIKE ?', 'WIP:%') +``` + +You'd write this instead: + +```ruby +Issue.where(Issue.arel_table[:title].matches('WIP:%')) +``` + +Here `matches` generates the correct `LIKE` / `ILIKE` statement depending on the +database being used. + +If you need to chain multiple `OR` conditions you can also do this using Arel: + +```ruby +table = Issue.arel_table + +Issue.where(table[:title].matches('WIP:%').or(table[:foo].matches('WIP:%'))) +``` + +For PostgreSQL this produces: + +```sql +SELECT * +FROM issues +WHERE (title ILIKE 'WIP:%' OR foo ILIKE 'WIP:%') +``` + +In turn for MySQL this produces: + +```sql +SELECT * +FROM issues +WHERE (title LIKE 'WIP:%' OR foo LIKE 'WIP:%') +``` + +## LIKE & Indexes + +Neither PostgreSQL nor MySQL use any indexes when using `LIKE` / `ILIKE` with a +wildcard at the start. For example, this will not use any indexes: + +```sql +SELECT * +FROM issues +WHERE title ILIKE '%WIP:%'; +``` + +Because the value for `ILIKE` starts with a wildcard the database is not able to +use an index as it doesn't know where to start scanning the indexes. + +MySQL provides no known solution to this problem. Luckily PostgreSQL _does_ +provide a solution: trigram GIN indexes. These indexes can be created as +follows: + +```sql +CREATE INDEX [CONCURRENTLY] index_name_here +ON table_name +USING GIN(column_name gin_trgm_ops); +``` + +The key here is the `GIN(column_name gin_trgm_ops)` part. This creates a [GIN +index][gin-index] with the operator class set to `gin_trgm_ops`. These indexes +_can_ be used by `ILIKE` / `LIKE` and can lead to greatly improved performance. +One downside of these indexes is that they can easily get quite large (depending +on the amount of data indexed). + +To keep naming of these indexes consistent please use the following naming +pattern: + + index_TABLE_on_COLUMN_trigram + +For example, a GIN/trigram index for `issues.title` would be called +`index_issues_on_title_trigram`. + +Due to these indexes taking quite some time to be built they should be built +concurrently. This can be done by using `CREATE INDEX CONCURRENTLY` instead of +just `CREATE INDEX`. Concurrent indexes can _not_ be created inside a +transaction. Transactions for migrations can be disabled using the following +pattern: + +```ruby +class MigrationName < ActiveRecord::Migration + disable_ddl_transaction! +end +``` + +For example: + +```ruby +class AddUsersLowerUsernameEmailIndexes < ActiveRecord::Migration + disable_ddl_transaction! + + def up + return unless Gitlab::Database.postgresql? + + execute 'CREATE INDEX CONCURRENTLY index_on_users_lower_username ON users (LOWER(username));' + execute 'CREATE INDEX CONCURRENTLY index_on_users_lower_email ON users (LOWER(email));' + end + + def down + return unless Gitlab::Database.postgresql? + + remove_index :users, :index_on_users_lower_username + remove_index :users, :index_on_users_lower_email + end +end +``` + +## Plucking IDs + +This can't be stressed enough: **never** use ActiveRecord's `pluck` to pluck a +set of values into memory only to use them as an argument for another query. For +example, this will make the database **very** sad: + +```ruby +projects = Project.all.pluck(:id) + +MergeRequest.where(source_project_id: projects) +``` + +Instead you can just use sub-queries which perform far better: + +```ruby +MergeRequest.where(source_project_id: Project.all.select(:id)) +``` + +The _only_ time you should use `pluck` is when you actually need to operate on +the values in Ruby itself (e.g. write them to a file). In almost all other cases +you should ask yourself "Can I not just use a sub-query?". + +## Use UNIONs + +UNIONs aren't very commonly used in most Rails applications but they're very +powerful and useful. In most applications queries tend to use a lot of JOINs to +get related data or data based on certain criteria, but JOIN performance can +quickly deteriorate as the data involved grows. + +For example, if you want to get a list of projects where the name contains a +value _or_ the name of the namespace contains a value most people would write +the following query: + +```sql +SELECT * +FROM projects +JOIN namespaces ON namespaces.id = projects.namespace_id +WHERE projects.name ILIKE '%gitlab%' +OR namespaces.name ILIKE '%gitlab%'; +``` + +Using a large database this query can easily take around 800 milliseconds to +run. Using a UNION we'd write the following instead: + +```sql +SELECT projects.* +FROM projects +WHERE projects.name ILIKE '%gitlab%' + +UNION + +SELECT projects.* +FROM projects +JOIN namespaces ON namespaces.id = projects.namespace_id +WHERE namespaces.name ILIKE '%gitlab%'; +``` + +This query in turn only takes around 15 milliseconds to complete while returning +the exact same records. + +This doesn't mean you should start using UNIONs everywhere, but it's something +to keep in mind when using lots of JOINs in a query and filtering out records +based on the joined data. + +GitLab comes with a `Gitlab::SQL::Union` class that can be used to build a UNION +of multiple `ActiveRecord::Relation` objects. You can use this class as +follows: + +```ruby +union = Gitlab::SQL::Union.new([projects, more_projects, ...]) + +Project.from("(#{union.to_sql}) projects") +``` + +## Ordering by Creation Date + +When ordering records based on the time they were created you can simply order +by the `id` column instead of ordering by `created_at`. Because IDs are always +unique and incremented in the order that rows are created this will produce the +exact same results. This also means there's no need to add an index on +`created_at` to ensure consistent performance as `id` is already indexed by +default. + +[gin-index]: http://www.postgresql.org/docs/current/static/gin.html diff --git a/doc/hooks/custom_hooks.md b/doc/hooks/custom_hooks.md index 0f2665a3bf7..15051dd76f9 100644 --- a/doc/hooks/custom_hooks.md +++ b/doc/hooks/custom_hooks.md @@ -2,7 +2,7 @@ **Note: Custom git hooks must be configured on the filesystem of the GitLab server. Only GitLab server administrators will be able to complete these tasks. -Please explore webhooks as an option if you do not have filesystem access. For a user configurable Git Hooks interface, please see [GitLab Enterprise Edition Git Hooks](http://doc.gitlab.com/ee/git_hooks/git_hooks.html).** +Please explore [webhooks](doc/web_hooks/web_hooks.md) as an option if you do not have filesystem access. For a user configurable Git Hooks interface, please see [GitLab Enterprise Edition Git Hooks](http://doc.gitlab.com/ee/git_hooks/git_hooks.html).** Git natively supports hooks that are executed on different actions. Examples of server-side git hooks include pre-receive, post-receive, and update. diff --git a/doc/install/installation.md b/doc/install/installation.md index 0fd54be58b0..4f011397269 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -233,9 +233,9 @@ sudo usermod -aG redis git ### Clone the Source # Clone GitLab repository - sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 8-5-stable gitlab + sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 8-6-stable gitlab -**Note:** You can change `8-5-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server! +**Note:** You can change `8-6-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server! ### Configure It diff --git a/doc/install/requirements.md b/doc/install/requirements.md index 8df142c531b..d59b7f0e84d 100644 --- a/doc/install/requirements.md +++ b/doc/install/requirements.md @@ -97,6 +97,17 @@ To change the Unicorn workers when you have the Omnibus package please see [the If you want to run the database separately expect a size of about 1 MB per user. +### PostgreSQL Requirements + +Users using PostgreSQL must ensure the `pg_trgm` extension is loaded into every +GitLab database. This extension can be enabled (using a PostgreSQL super user) +by running the following query for every database: + + CREATE EXTENSION pg_trgm; + +On some systems you may need to install an additional package (e.g. +`postgresql-contrib`) for this extension to become available. + ## Redis and Sidekiq Redis stores all user sessions and the background task queue. diff --git a/doc/integration/saml.md b/doc/integration/saml.md index 148c4ac1886..1c3dc707f6d 100644 --- a/doc/integration/saml.md +++ b/doc/integration/saml.md @@ -131,6 +131,58 @@ On the sign in page there should now be a SAML button below the regular sign in Click the icon to begin the authentication process. If everything goes well the user will be returned to GitLab and will be signed in. +## Customization + +### `attribute_statements` + +>**Note:** +This setting is only available on GitLab 8.6 and above. +This setting should only be used to map attributes that are part of the +OmniAuth info hash schema. + +`attribute_statements` is used to map Attribute Names in a SAMLResponse to entries +in the OmniAuth [info hash](https://github.com/intridea/omniauth/wiki/Auth-Hash-Schema#schema-10-and-later). + +For example, if your SAMLResponse contains an Attribute called 'EmailAddress', +specify `{ email: ['EmailAddress'] }` to map the Attribute to the +corresponding key in the info hash. URI-named Attributes are also supported, e.g. +`{ email: ['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] }`. + +This setting allows you tell GitLab where to look for certain attributes required +to create an account. Like mentioned above, if your IdP sends the user's email +address as `EmailAddress` instead of `email`, let GitLab know by setting it on +your configuration: + +```yaml +args: { + assertion_consumer_service_url: 'https://gitlab.example.com/users/auth/saml/callback', + idp_cert_fingerprint: '43:51:43:a1:b5:fc:8b:b7:0a:3a:a9:b1:0f:66:73:a8', + idp_sso_target_url: 'https://login.example.com/idp', + issuer: 'https://gitlab.example.com', + name_identifier_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient', + attribute_statements: { email: ['EmailAddress'] } +} +``` + +### `allowed_clock_drift` + +The clock of the Identity Provider may drift slightly ahead of your system clocks. +To allow for a small amount of clock drift you can use `allowed_clock_drift` within +your settings. Its value must be given in a number (and/or fraction) of seconds. +The value given is added to the current time at which the response is validated. + +```yaml +args: { + assertion_consumer_service_url: 'https://gitlab.example.com/users/auth/saml/callback', + idp_cert_fingerprint: '43:51:43:a1:b5:fc:8b:b7:0a:3a:a9:b1:0f:66:73:a8', + idp_sso_target_url: 'https://login.example.com/idp', + issuer: 'https://gitlab.example.com', + name_identifier_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient', + attribute_statements: { email: ['EmailAddress'] }, + allowed_clock_drift: 1 # for one second clock drift +} +``` + ## Troubleshooting ### 500 error after login diff --git a/doc/integration/slack.md b/doc/integration/slack.md index ecbe0d3e887..f6ba80f46d5 100644 --- a/doc/integration/slack.md +++ b/doc/integration/slack.md @@ -2,19 +2,11 @@ ## On Slack -To enable Slack integration you must create an Incoming WebHooks integration on Slack; +To enable Slack integration you must create an Incoming WebHooks integration on Slack: 1. [Sign in to Slack](https://slack.com/signin) -1. Select **Apps & Custom Integrations** from the dropdown next to your team name. - -1. Click the **Configure** link (right-upper corner). - -1. Select the **Custom integrations** tab. - -1. Click the **Incoming WebHooks** row. - -1. Click the **Add configuration** button. +1. Visit [Incoming WebHooks](https://my.slack.com/services/new/incoming-webhook/) 1. Choose the channel name you want to send notifications to. diff --git a/doc/markdown/markdown.md b/doc/markdown/markdown.md index cbf57db5684..e6eb1cf3819 100644 --- a/doc/markdown/markdown.md +++ b/doc/markdown/markdown.md @@ -29,6 +29,8 @@ ## GitLab Flavored Markdown (GFM) +_GitLab uses the [Redcarpet Ruby library][redcarpet] for Markdown processing._ + For GitLab we developed something we call "GitLab Flavored Markdown" (GFM). It extends the standard Markdown in a few significant ways to add some useful functionality. You can use GFM in @@ -88,8 +90,8 @@ GFM will autolink almost any URL you copy and paste into your text. ## Code and Syntax Highlighting -_GitLab uses the [rouge ruby library][rouge] for syntax highlighting. For a -list of supported languages visit the rouge website._ +_GitLab uses the [Rouge Ruby library][rouge] for syntax highlighting. For a +list of supported languages visit the Rouge website._ Blocks of code are either fenced by lines with three back-ticks <code>```</code>, or are indented with four spaces. Only the fenced code blocks support syntax highlighting. @@ -591,3 +593,4 @@ By including colons in the header row, you can align the text within that column - [Dillinger.io](http://dillinger.io) is a handy tool for testing standard markdown. [rouge]: http://rouge.jneen.net/ "Rouge website" +[redcarpet]: https://github.com/vmg/redcarpet "Redcarpet website" diff --git a/doc/raketasks/README.md b/doc/raketasks/README.md index cc8a22cd003..6be954ad68b 100644 --- a/doc/raketasks/README.md +++ b/doc/raketasks/README.md @@ -6,6 +6,6 @@ - [Features](features.md) - [Maintenance](maintenance.md) and self-checks - [User management](user_management.md) -- [Web hooks](web_hooks.md) +- [Webhooks](web_hooks.md) - [Import](import.md) of git repositories in bulk - [Rebuild authorized_keys file](http://doc.gitlab.com/ce/raketasks/maintenance.html#rebuild-authorized_keys-file) task for administrators diff --git a/doc/raketasks/web_hooks.md b/doc/raketasks/web_hooks.md index 5a8b94af9b4..2ebf7c48f4e 100644 --- a/doc/raketasks/web_hooks.md +++ b/doc/raketasks/web_hooks.md @@ -1,41 +1,41 @@ -# Web hooks +# Webhooks -## Add a web hook for **ALL** projects: +## Add a webhook for **ALL** projects: # omnibus-gitlab sudo gitlab-rake gitlab:web_hook:add URL="http://example.com/hook" # source installations bundle exec rake gitlab:web_hook:add URL="http://example.com/hook" RAILS_ENV=production -## Add a web hook for projects in a given **NAMESPACE**: +## Add a webhook for projects in a given **NAMESPACE**: # omnibus-gitlab sudo gitlab-rake gitlab:web_hook:add URL="http://example.com/hook" NAMESPACE=acme # source installations bundle exec rake gitlab:web_hook:add URL="http://example.com/hook" NAMESPACE=acme RAILS_ENV=production -## Remove a web hook from **ALL** projects using: +## Remove a webhook from **ALL** projects using: # omnibus-gitlab sudo gitlab-rake gitlab:web_hook:rm URL="http://example.com/hook" # source installations bundle exec rake gitlab:web_hook:rm URL="http://example.com/hook" RAILS_ENV=production -## Remove a web hook from projects in a given **NAMESPACE**: +## Remove a webhook from projects in a given **NAMESPACE**: # omnibus-gitlab sudo gitlab-rake gitlab:web_hook:rm URL="http://example.com/hook" NAMESPACE=acme # source installations bundle exec rake gitlab:web_hook:rm URL="http://example.com/hook" NAMESPACE=acme RAILS_ENV=production -## List **ALL** web hooks: +## List **ALL** webhooks: # omnibus-gitlab sudo gitlab-rake gitlab:web_hook:list # source installations bundle exec rake gitlab:web_hook:list RAILS_ENV=production -## List the web hooks from projects in a given **NAMESPACE**: +## List the webhooks from projects in a given **NAMESPACE**: # omnibus-gitlab sudo gitlab-rake gitlab:web_hook:list NAMESPACE=/ diff --git a/doc/security/README.md b/doc/security/README.md index be1abb88c3d..4cd0fdd4094 100644 --- a/doc/security/README.md +++ b/doc/security/README.md @@ -2,7 +2,7 @@ - [Password length limits](password_length_limits.md) - [Rack attack](rack_attack.md) -- [Web Hooks and insecure internal web services](webhooks.md) +- [Webhooks and insecure internal web services](webhooks.md) - [Information exclusivity](information_exclusivity.md) - [Reset your root password](reset_root_password.md) - [User File Uploads](user_file_uploads.md) diff --git a/doc/security/webhooks.md b/doc/security/webhooks.md index 1e9d33e87c3..bb46aebf4b5 100644 --- a/doc/security/webhooks.md +++ b/doc/security/webhooks.md @@ -1,13 +1,13 @@ -# Web Hooks and insecure internal web services +# Webhooks and insecure internal web services -If you have non-GitLab web services running on your GitLab server or within its local network, these may be vulnerable to exploitation via Web Hooks. +If you have non-GitLab web services running on your GitLab server or within its local network, these may be vulnerable to exploitation via Webhooks. -With [Web Hooks](../web_hooks/web_hooks.md), you and your project masters and owners can set up URLs to be triggered when specific things happen to projects. Normally, these requests are sent to external web services specifically set up for this purpose, that process the request and its attached data in some appropriate way. +With [Webhooks](../web_hooks/web_hooks.md), you and your project masters and owners can set up URLs to be triggered when specific things happen to projects. Normally, these requests are sent to external web services specifically set up for this purpose, that process the request and its attached data in some appropriate way. -Things get hairy, however, when a Web Hook is set up with a URL that doesn't point to an external, but to an internal service, that may do something completely unintended when the web hook is triggered and the POST request is sent. +Things get hairy, however, when a Webhook is set up with a URL that doesn't point to an external, but to an internal service, that may do something completely unintended when the webhook is triggered and the POST request is sent. -Because Web Hook requests are made by the GitLab server itself, these have complete access to everything running on the server (http://localhost:123) or within the server's local network (http://192.168.1.12:345), even if these services are otherwise protected and inaccessible from the outside world. +Because Webhook requests are made by the GitLab server itself, these have complete access to everything running on the server (http://localhost:123) or within the server's local network (http://192.168.1.12:345), even if these services are otherwise protected and inaccessible from the outside world. -If a web service does not require authentication, Web Hooks can be used to trigger destructive commands by getting the GitLab server to make POST requests to endpoints like "http://localhost:123/some-resource/delete". +If a web service does not require authentication, Webhooks can be used to trigger destructive commands by getting the GitLab server to make POST requests to endpoints like "http://localhost:123/some-resource/delete". To prevent this type of exploitation from happening, make sure that you are aware of every web service GitLab could potentially have access to, and that all of these are set up to require authentication for every potentially destructive command. Enabling authentication but leaving a default password is not enough.
\ No newline at end of file diff --git a/doc/update/8.5-to-8.6.md b/doc/update/8.5-to-8.6.md new file mode 100644 index 00000000000..024f6e8a433 --- /dev/null +++ b/doc/update/8.5-to-8.6.md @@ -0,0 +1,164 @@ +# From 8.5 to 8.6 + +### 1. Stop server + + sudo service gitlab stop + +### 2. Backup + +```bash +cd /home/git/gitlab +sudo -u git -H bundle exec rake gitlab:backup:create RAILS_ENV=production +``` + +### 3. Get latest code + +```bash +sudo -u git -H git fetch --all +sudo -u git -H git checkout -- db/schema.rb # local changes will be restored automatically +``` + +For GitLab Community Edition: + +```bash +sudo -u git -H git checkout 8-6-stable +``` + +OR + +For GitLab Enterprise Edition: + +```bash +sudo -u git -H git checkout 8-6-stable-ee +``` + +### 4. Update gitlab-shell + +```bash +cd /home/git/gitlab-shell +sudo -u git -H git fetch --all +sudo -u git -H git checkout v2.6.11 +``` + +### 5. Update gitlab-workhorse + +Install and compile gitlab-workhorse. This requires +[Go 1.5](https://golang.org/dl) which should already be on your system from +GitLab 8.1. + +```bash +cd /home/git/gitlab-workhorse +sudo -u git -H git fetch --all +sudo -u git -H git checkout 0.6.5 +sudo -u git -H make +``` + +### 6. Install libs, migrations, etc. + +```bash +cd /home/git/gitlab + +# MySQL installations (note: the line below states '--without postgres') +sudo -u git -H bundle install --without postgres development test --deployment + +# PostgreSQL installations (note: the line below states '--without mysql') +sudo -u git -H bundle install --without mysql development test --deployment + +# Optional: clean up old gems +sudo -u git -H bundle clean + +# Run database migrations +sudo -u git -H bundle exec rake db:migrate RAILS_ENV=production + +# Clean up assets and cache +sudo -u git -H bundle exec rake assets:clean assets:precompile cache:clear RAILS_ENV=production + +``` + +### 7. Update configuration files + +#### New configuration options for `gitlab.yml` + +There are new configuration options available for [`gitlab.yml`](config/gitlab.yml.example). View them with the command below and apply them manually to your current `gitlab.yml`: + +```sh +git diff origin/8-5-stable:config/gitlab.yml.example origin/8-6-stable:config/gitlab.yml.example +``` + +#### Nginx configuration + +Ensure you're still up-to-date with the latest NGINX configuration changes: + +```sh +# For HTTPS configurations +git diff origin/8-5-stable:lib/support/nginx/gitlab-ssl origin/8-6-stable:lib/support/nginx/gitlab-ssl + +# For HTTP configurations +git diff origin/8-5-stable:lib/support/nginx/gitlab origin/8-6-stable:lib/support/nginx/gitlab +``` + +If you are using Apache instead of NGINX please see the updated [Apache templates]. +Also note that because Apache does not support upstreams behind Unix sockets you +will need to let gitlab-workhorse listen on a TCP port. You can do this +via [/etc/default/gitlab]. + +[Apache templates]: https://gitlab.com/gitlab-org/gitlab-recipes/tree/master/web-server/apache +[/etc/default/gitlab]: https://gitlab.com/gitlab-org/gitlab-ce/blob/8-6-stable/lib/support/init.d/gitlab.default.example#L37 + +#### Init script + +Ensure you're still up-to-date with the latest init script changes: + + sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab + +### 8. Updates for PostgreSQL Users + +Starting with 8.6 users using GitLab in combination with PostgreSQL are required +to have the `pg_trgm` extension enabled for all GitLab databases. If you're +using GitLab's Omnibus packages there's nothing you'll need to do manually as +this extension is enabled automatically. Users who install GitLab without using +Omnibus (e.g. by building from source) have to enable this extension manually. +To enable this extension run the following SQL command as a PostgreSQL super +user for _every_ GitLab database: + +```sql +CREATE EXTENSION IF NOT EXISTS pg_trgm; +``` + +Certain operating systems might require the installation of extra packages for +this extension to be available. For example, users using Ubuntu will have to +install the `postgresql-contrib` package in order for this extension to be +available. + +### 9. Start application + + sudo service gitlab start + sudo service nginx restart + +### 10. Check application status + +Check if GitLab and its environment are configured correctly: + + sudo -u git -H bundle exec rake gitlab:env:info RAILS_ENV=production + +To make sure you didn't miss anything run a more thorough check: + + sudo -u git -H bundle exec rake gitlab:check RAILS_ENV=production + +If all items are green, then congratulations, the upgrade is complete! + +## Things went south? Revert to previous version (8.5) + +### 1. Revert the code to the previous version + +Follow the [upgrade guide from 8.4 to 8.5](8.4-to-8.5.md), except for the +database migration (the backup is already migrated to the previous version). + +### 2. Restore from the backup + +```bash +cd /home/git/gitlab +sudo -u git -H bundle exec rake gitlab:backup:restore RAILS_ENV=production +``` + +If you have more than one backup `*.tar` file(s) please add `BACKUP=timestamp_of_backup` to the command above. diff --git a/doc/update/upgrader.md b/doc/update/upgrader.md index fd0327686b1..5fa39ef1b0a 100644 --- a/doc/update/upgrader.md +++ b/doc/update/upgrader.md @@ -4,7 +4,7 @@ Although deprecated, if someone wants to make this script into a gem or otherwise improve it merge requests are welcome. -*Make sure you view this [upgrade guide from the 'master' branch](../../../master/doc/update/upgrader.md) for the most up to date instructions.* +*Make sure you view this [upgrade guide from the 'master' branch](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/update/upgrader.md) for the most up to date instructions.* GitLab Upgrader - a ruby script that allows you easily upgrade GitLab to latest minor version. diff --git a/doc/web_hooks/web_hooks.md b/doc/web_hooks/web_hooks.md index b82306bd1da..afdf1a682e2 100644 --- a/doc/web_hooks/web_hooks.md +++ b/doc/web_hooks/web_hooks.md @@ -1,4 +1,4 @@ -# Web hooks +# Webhooks _**Note:** Starting from GitLab 8.5:_ @@ -7,11 +7,11 @@ Starting from GitLab 8.5:_ - _the `project.ssh_url` key is deprecated in favor of the `project.git_ssh_url` key_ - _the `project.http_url` key is deprecated in favor of the `project.git_http_url` key_ -Project web hooks allow you to trigger an URL if new code is pushed or a new issue is created. +Project webhooks allow you to trigger an URL if new code is pushed or a new issue is created. -You can configure web hooks to listen for specific events like pushes, issues or merge requests. GitLab will send a POST request with data to the web hook URL. +You can configure webhooks to listen for specific events like pushes, issues or merge requests. GitLab will send a POST request with data to the webhook URL. -Web hooks can be used to update an external issue tracker, trigger CI builds, update a backup mirror, or even deploy to your production server. +Webhooks can be used to update an external issue tracker, trigger CI builds, update a backup mirror, or even deploy to your production server. ## SSL Verification @@ -19,7 +19,7 @@ By default, the SSL certificate of the webhook endpoint is verified based on an internal list of Certificate Authorities, which means the certificate cannot be self-signed. -You can turn this off in the web hook settings in your GitLab projects. +You can turn this off in the webhook settings in your GitLab projects.  diff --git a/doc/workflow/README.md b/doc/workflow/README.md index 2ac32373ce9..25893f948ea 100644 --- a/doc/workflow/README.md +++ b/doc/workflow/README.md @@ -13,6 +13,8 @@ - [Project forking workflow](forking_workflow.md) - [Project users](add-user/add-user.md) - [Protected branches](protected_branches.md) +- [Sharing a project with a group](share_with_group.md) +- [Share projects with other groups](share_projects_with_other_groups.md) - [Web Editor](web_editor.md) - [Releases](releases.md) - [Milestones](milestones.md) diff --git a/doc/workflow/groups/max_access_level.png b/doc/workflow/groups/max_access_level.png Binary files differnew file mode 100644 index 00000000000..71106a8a5a0 --- /dev/null +++ b/doc/workflow/groups/max_access_level.png diff --git a/doc/workflow/groups/other_group_sees_shared_project.png b/doc/workflow/groups/other_group_sees_shared_project.png Binary files differnew file mode 100644 index 00000000000..cbf2c3c1fdc --- /dev/null +++ b/doc/workflow/groups/other_group_sees_shared_project.png diff --git a/doc/workflow/groups/share_project_with_groups.png b/doc/workflow/groups/share_project_with_groups.png Binary files differnew file mode 100644 index 00000000000..a5dbc89fe90 --- /dev/null +++ b/doc/workflow/groups/share_project_with_groups.png diff --git a/doc/workflow/share_projects_with_other_groups.md b/doc/workflow/share_projects_with_other_groups.md new file mode 100644 index 00000000000..4c59f59c587 --- /dev/null +++ b/doc/workflow/share_projects_with_other_groups.md @@ -0,0 +1,30 @@ +# Share Projects with other Groups + +In GitLab Enterprise Edition you can share projects with other groups. +This makes it possible to add a group of users to a project with a single action. + +## Groups as collections of users + +In GitLab Community Edition groups are used primarily to [create collections of projects](groups.md). +In GitLab Enterprise Edition you can also take advantage of the fact that groups define collections of _users_, namely the group members. + +## Sharing a project with a group of users + +The primary mechanism to give a group of users, say 'Engineering', access to a project, say 'Project Acme', in GitLab is to make the 'Engineering' group the owner of 'Project Acme'. +But what if 'Project Acme' already belongs to another group, say 'Open Source'? +This is where the (Enterprise Edition only) 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. + + + +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. + + + +## Maximum access level + + + +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'. diff --git a/doc/workflow/share_with_group.md b/doc/workflow/share_with_group.md new file mode 100644 index 00000000000..3b7690973cb --- /dev/null +++ b/doc/workflow/share_with_group.md @@ -0,0 +1,13 @@ +# Sharing a project with a group + +If you want to share a single project in a group with another group, +you can do so easily. By setting the permission you can quickly +give a select group of users access to a project in a restricted manner. + +In a project go to the project settings -> groups. + +Now you can select a group that you want to share this project with and with +which maximum access level. Users in that group are able to access this project +with their set group access level, up to the maximum level that you've set. + + diff --git a/doc/workflow/share_with_group.png b/doc/workflow/share_with_group.png Binary files differnew file mode 100644 index 00000000000..a0ca6f14552 --- /dev/null +++ b/doc/workflow/share_with_group.png diff --git a/features/admin/groups.feature b/features/admin/groups.feature index 2edb3964f70..ab7de7ac315 100644 --- a/features/admin/groups.feature +++ b/features/admin/groups.feature @@ -21,6 +21,11 @@ Feature: Admin Groups When I select user "John Doe" from user list as "Reporter" Then I should see "John Doe" in team list in every project as "Reporter" + Scenario: Shared projects + Given group has shared projects + When I visit group page + Then I should see project shared with group + @javascript Scenario: Remove user from group Given we have user "John Doe" in group diff --git a/features/dashboard/archived_projects.feature b/features/dashboard/archived_projects.feature index 69b3a776441..bed9282f1c6 100644 --- a/features/dashboard/archived_projects.feature +++ b/features/dashboard/archived_projects.feature @@ -10,3 +10,8 @@ Feature: Dashboard Archived Projects Scenario: I should see non-archived projects on dashboard Then I should see "Shop" project link And I should not see "Forum" project link + + Scenario: I toggle show of archived projects on dashboard + When I click "Show archived projects" link + Then I should see "Shop" project link + And I should see "Forum" project link diff --git a/features/explore/projects.feature b/features/explore/projects.feature index 7df6b6f09ba..092e18d1b86 100644 --- a/features/explore/projects.feature +++ b/features/explore/projects.feature @@ -140,4 +140,4 @@ Feature: Explore Projects When I visit the explore starred projects Then I should see project "Community" And I should see project "Internal" - And I should see project "Archive" + And I should not see project "Archive" diff --git a/features/groups.feature b/features/groups.feature index a60c3860b83..419a5d3963d 100644 --- a/features/groups.feature +++ b/features/groups.feature @@ -15,6 +15,10 @@ Feature: Groups Scenario: I should see group "Owned" dashboard list When I visit group "Owned" page Then I should see group "Owned" projects list + + @javascript + Scenario: I should see group "Owned" activity feed + When I visit group "Owned" activity page And I should see projects activity feed Scenario: I should see group "Owned" issues list diff --git a/features/profile/profile.feature b/features/profile/profile.feature index 168d9d30b50..447dd92a458 100644 --- a/features/profile/profile.feature +++ b/features/profile/profile.feature @@ -76,8 +76,7 @@ Feature: Profile Scenario: I can manage application Given I visit profile applications page - Then I click on new application button - And I should see application form + Then I should see application form Then I fill application form out and submit And I see application Then I click edit diff --git a/features/project/group_links.feature b/features/project/group_links.feature new file mode 100644 index 00000000000..2657c4487ad --- /dev/null +++ b/features/project/group_links.feature @@ -0,0 +1,16 @@ +Feature: Project Group Links + Background: + Given I sign in as a user + And I own project "Shop" + And project "Shop" is shared with group "Ops" + And project "Shop" is not shared with group "Market" + And I visit project group links page + + Scenario: I should see list of groups + Then I should see project already shared with group "Ops" + Then I should see project is not shared with group "Market" + + @javascript + Scenario: I share project with group + When I select group "Market" for share + Then I should see project is shared with group "Market" diff --git a/features/project/network_graph.feature b/features/project/network_graph.feature index 6cc89a15a78..89a02706bd2 100644 --- a/features/project/network_graph.feature +++ b/features/project/network_graph.feature @@ -34,9 +34,10 @@ Feature: Project Network Graph @javascript Scenario: I should filter selected tag When I switch ref to "v1.0.0" + Then page should have "v1.0.0" in title Then page should have content not containing "v1.0.0" When click "Show only selected branch" checkbox - Then page should not have content not containing "v1.0.0" + Then page should only have content from "v1.0.0" When click "Show only selected branch" checkbox Then page should have content not containing "v1.0.0" diff --git a/features/project/team_management.feature b/features/project/team_management.feature index 06fb45c8bde..5888662fc3f 100644 --- a/features/project/team_management.feature +++ b/features/project/team_management.feature @@ -39,3 +39,8 @@ Feature: Project Team Management And I click link "Import team from another project" And I submit "Website" project for import team Then I should see "Mike" in team list as "Reporter" + + Scenario: See all members of projects shared group + Given I share project with group "OpenSource" + And I visit project "Shop" team page + Then I should see "Opensource" group user listing diff --git a/features/steps/admin/groups.rb b/features/steps/admin/groups.rb index 43fd91d0d4c..e1f1db2872f 100644 --- a/features/steps/admin/groups.rb +++ b/features/steps/admin/groups.rb @@ -73,6 +73,21 @@ class Spinach::Features::AdminGroups < Spinach::FeatureSteps end end + step 'group has shared projects' do + share_link = shared_project.project_group_links.new(group_access: Gitlab::Access::MASTER) + share_link.group_id = current_group.id + share_link.save! + end + + step 'I visit group page' do + visit admin_group_path(current_group) + end + + step 'I should see project shared with group' do + expect(page).to have_content(shared_project.name_with_namespace) + expect(page).to have_content "Projects shared with" + end + step 'we have user "John Doe" in group' do current_group.add_reporter(user_john) end @@ -123,6 +138,10 @@ class Spinach::Features::AdminGroups < Spinach::FeatureSteps @group ||= Group.first end + def shared_project + @shared_project ||= create(:empty_project) + end + def user_john @user_john ||= User.find_by(name: "John Doe") end diff --git a/features/steps/dashboard/archived_projects.rb b/features/steps/dashboard/archived_projects.rb index 36e092f50c6..6510f8d9b32 100644 --- a/features/steps/dashboard/archived_projects.rb +++ b/features/steps/dashboard/archived_projects.rb @@ -19,4 +19,8 @@ class Spinach::Features::DashboardArchivedProjects < Spinach::FeatureSteps step 'I should see "Forum" project link' do expect(page).to have_link "Forum" end + + step 'I click "Show archived projects" link' do + click_link "Show archived projects" + end end diff --git a/features/steps/dashboard/issues.rb b/features/steps/dashboard/issues.rb index cbe54e2dc79..f4a56865532 100644 --- a/features/steps/dashboard/issues.rb +++ b/features/steps/dashboard/issues.rb @@ -36,13 +36,17 @@ class Spinach::Features::DashboardIssues < Spinach::FeatureSteps end step 'I click "Authored by me" link' do - select2(current_user.id, from: "#author_id") - select2(nil, from: "#assignee_id") + find("#assignee_id").set("") + find(".js-author-search", match: :first).click + find(".dropdown-menu-author li a", match: :first, text: current_user.to_reference).click end step 'I click "All" link' do - select2(nil, from: "#author_id") - select2(nil, from: "#assignee_id") + find('.js-author-search').click + find('.dropdown-menu-user-full-name', match: :first).click + + find('.js-assignee-search').click + find('.dropdown-menu-user-full-name', match: :first).click end def should_see(issue) diff --git a/features/steps/dashboard/merge_requests.rb b/features/steps/dashboard/merge_requests.rb index 28c8c6b6015..a2adc87f8ef 100644 --- a/features/steps/dashboard/merge_requests.rb +++ b/features/steps/dashboard/merge_requests.rb @@ -40,13 +40,16 @@ class Spinach::Features::DashboardMergeRequests < Spinach::FeatureSteps end step 'I click "Authored by me" link' do - select2(current_user.id, from: "#author_id") - select2(nil, from: "#assignee_id") + find("#assignee_id").set("") + find(".js-author-search", match: :first).click + find(".dropdown-menu-author li a", match: :first, text: current_user.to_reference).click end step 'I click "All" link' do - select2(nil, from: "#author_id") - select2(nil, from: "#assignee_id") + find(".js-author-search").click + find(".dropdown-menu-author li a", match: :first).click + find(".js-assignee-search").click + find(".dropdown-menu-assignee li a", match: :first).click end def should_see(merge_request) diff --git a/features/steps/profile/profile.rb b/features/steps/profile/profile.rb index 0c60328583a..fd9aaeb3b09 100644 --- a/features/steps/profile/profile.rb +++ b/features/steps/profile/profile.rb @@ -99,9 +99,9 @@ class Spinach::Features::Profile < Spinach::FeatureSteps end step 'I reset my token' do - page.within '.update-token' do + page.within '.private-token' do @old_token = @user.private_token - click_button "Reset" + click_button "Reset private token" end end @@ -180,18 +180,14 @@ class Spinach::Features::Profile < Spinach::FeatureSteps end end - step 'I click on new application button' do - click_on 'New Application' - end - step 'I should see application form' do - expect(page).to have_content "New Application" + expect(page).to have_content "Add new application" end step 'I fill application form out and submit' do fill_in :doorkeeper_application_name, with: 'test' fill_in :doorkeeper_application_redirect_uri, with: 'https://test.com' - click_on "Submit" + click_on "Save application" end step 'I see application' do @@ -211,7 +207,7 @@ class Spinach::Features::Profile < Spinach::FeatureSteps step 'I change name of application and submit' do expect(page).to have_content "Edit application" fill_in :doorkeeper_application_name, with: 'test_changed' - click_on "Submit" + click_on "Save application" end step 'I see that application was changed' do diff --git a/features/steps/project/active_tab.rb b/features/steps/project/active_tab.rb index 9e96fa5ba49..19d81453d8c 100644 --- a/features/steps/project/active_tab.rb +++ b/features/steps/project/active_tab.rb @@ -26,7 +26,7 @@ class Spinach::Features::ProjectActiveTab < Spinach::FeatureSteps end step 'I click the "Hooks" tab' do - click_link('Web Hooks') + click_link('Webhooks') end step 'I click the "Deploy Keys" tab' do @@ -42,7 +42,7 @@ class Spinach::Features::ProjectActiveTab < Spinach::FeatureSteps end step 'the active sub nav should be Hooks' do - ensure_active_sub_nav('Web Hooks') + ensure_active_sub_nav('Webhooks') end step 'the active sub nav should be Deploy Keys' do diff --git a/features/steps/project/hooks.rb b/features/steps/project/hooks.rb index be4db770948..4994df589a7 100644 --- a/features/steps/project/hooks.rb +++ b/features/steps/project/hooks.rb @@ -25,14 +25,14 @@ class Spinach::Features::ProjectHooks < Spinach::FeatureSteps step 'I submit new hook' do @url = FFaker::Internet.uri("http") fill_in "hook_url", with: @url - expect { click_button "Add Web Hook" }.to change(ProjectHook, :count).by(1) + expect { click_button "Add Webhook" }.to change(ProjectHook, :count).by(1) end step 'I submit new hook with SSL verification enabled' do @url = FFaker::Internet.uri("http") fill_in "hook_url", with: @url check "hook_enable_ssl_verification" - expect { click_button "Add Web Hook" }.to change(ProjectHook, :count).by(1) + expect { click_button "Add Webhook" }.to change(ProjectHook, :count).by(1) end step 'I should see newly created hook' do diff --git a/features/steps/project/issues/award_emoji.rb b/features/steps/project/issues/award_emoji.rb index 277c63914d1..ce2554bc80d 100644 --- a/features/steps/project/issues/award_emoji.rb +++ b/features/steps/project/issues/award_emoji.rb @@ -10,31 +10,30 @@ class Spinach::Features::AwardEmoji < Spinach::FeatureSteps step 'I click the thumbsup award Emoji' do page.within '.awards' do - thumbsup = page.find('.award .emoji-1F44D') + thumbsup = page.first('.award-control') thumbsup.click thumbsup.hover - sleep 0.3 end end step 'I click to emoji-picker' do - page.within '.awards-controls' do - page.find('.add-award').click + page.within '.awards' do + page.find('.js-add-award').click end end step 'I click to emoji in the picker' do page.within '.emoji-menu-content' do - page.first('.emoji-icon').click + page.first('.js-emoji-btn').click end end step 'I can remove it by clicking to icon' do page.within '.awards' do expect do - page.find('.award.active').click + page.find('.js-emoji-btn.active').click sleep 0.3 - end.to change{ page.all(".award").size }.from(3).to(2) + end.to change{ page.all(".award-control.js-emoji-btn").size }.from(3).to(2) end end @@ -46,26 +45,24 @@ class Spinach::Features::AwardEmoji < Spinach::FeatureSteps end step 'I have award added' do - sleep 0.2 - page.within '.awards' do - expect(page).to have_selector '.award' - expect(page.find('.award.active .counter')).to have_content '1' - expect(page.find('.award.active')['data-original-title']).to eq('me') + expect(page).to have_selector '.js-emoji-btn' + expect(page.find('.js-emoji-btn.active .js-counter')).to have_content '1' + expect(page).to have_css(".js-emoji-btn.active[data-original-title='me']") end end step 'I have no awards added' do page.within '.awards' do - expect(page).to have_selector '.award' - expect(page.all('.award').size).to eq(2) + expect(page).to have_selector '.award-control.js-emoji-btn' + expect(page.all('.award-control.js-emoji-btn').size).to eq(2) # Check tooltip data - page.all('.award').each do |element| + page.all('.award-control.js-emoji-btn').each do |element| expect(element['title']).to eq("") end - page.all('.award .counter').each do |element| + page.all('.award-control .js-counter').each do |element| expect(element).to have_content '0' end end @@ -79,7 +76,7 @@ class Spinach::Features::AwardEmoji < Spinach::FeatureSteps step 'I leave comment with a single emoji' do page.within('.js-main-target-form') do fill_in 'note[note]', with: ':smile:' - click_button 'Add Comment' + click_button 'Comment' end end diff --git a/features/steps/project/issues/filter_labels.rb b/features/steps/project/issues/filter_labels.rb index 50bb32429b9..6d50501a722 100644 --- a/features/steps/project/issues/filter_labels.rb +++ b/features/steps/project/issues/filter_labels.rb @@ -29,7 +29,10 @@ class Spinach::Features::ProjectIssuesFilterLabels < Spinach::FeatureSteps end step 'I click link "bug"' do - select2('bug', from: "#label_name") + page.find('.js-label-select').click + sleep 0.5 + execute_script("$('.dropdown-menu-labels li:contains(\"bug\") a').click()") + sleep 2 end step 'I click link "feature"' do diff --git a/features/steps/project/issues/issues.rb b/features/steps/project/issues/issues.rb index d9842ccf95e..8c31fa890b2 100644 --- a/features/steps/project/issues/issues.rb +++ b/features/steps/project/issues/issues.rb @@ -27,7 +27,7 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps end step 'I click link "Closed"' do - click_link "Closed" + find('.issues-state-filters a', text: "Closed").click end step 'I click button "Unsubscribe"' do @@ -63,14 +63,15 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps end step 'I click "author" dropdown' do - first('#s2id_author_id').click + page.find('.js-author-search').click + sleep 1 end step 'I see current user as the first user' do - expect(page).to have_selector('.user-result', visible: true, count: 3) - users = page.all('.user-name') + expect(page).to have_selector('.dropdown-content', visible: true) + users = page.all('.dropdown-menu-author .dropdown-content li a') expect(users[0].text).to eq 'Any Author' - expect(users[1].text).to eq current_user.name + expect(users[1].text).to eq "#{current_user.name} #{current_user.to_reference}" end step 'I submit new issue "500 error on profile"' do @@ -267,7 +268,7 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps step 'I leave a comment with code block' do page.within(".js-main-target-form") do fill_in "note[note]", with: "```\nCommand [1]: /usr/local/bin/git , see [text](doc/text)\n```" - click_button "Add Comment" + click_button "Comment" sleep 0.05 end end diff --git a/features/steps/project/merge_requests.rb b/features/steps/project/merge_requests.rb index 2eb98e91dbf..91fe19dd477 100644 --- a/features/steps/project/merge_requests.rb +++ b/features/steps/project/merge_requests.rb @@ -439,7 +439,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps page.within(".js-discussion-note-form") do fill_in "note_note", with: "Line is correct" - click_button "Add Comment" + click_button "Comment" end page.within ".files [id^=diff]:nth-child(2) .note-body > .note-text" do @@ -452,7 +452,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps page.within(".js-discussion-note-form") do fill_in "note_note", with: "Line is wrong on here" - click_button "Add Comment" + click_button "Comment" end end @@ -548,7 +548,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps def leave_comment(message) page.within(".js-discussion-note-form", visible: true) do fill_in "note_note", with: message - click_button "Add Comment" + click_button "Comment" end page.within(".notes_holder", visible: true) do expect(page).to have_content message diff --git a/features/steps/project/network_graph.rb b/features/steps/project/network_graph.rb index 7a83d32a240..9b59b682676 100644 --- a/features/steps/project/network_graph.rb +++ b/features/steps/project/network_graph.rb @@ -41,17 +41,14 @@ class Spinach::Features::ProjectNetworkGraph < Spinach::FeatureSteps When 'I switch ref to "feature"' do select 'feature', from: 'ref' - sleep 2 end When 'I switch ref to "v1.0.0"' do select 'v1.0.0', from: 'ref' - sleep 2 end When 'click "Show only selected branch" checkbox' do find('#filter_ref').click - sleep 2 end step 'page should have content not containing "v1.0.0"' do @@ -60,7 +57,11 @@ class Spinach::Features::ProjectNetworkGraph < Spinach::FeatureSteps end end - step 'page should not have content not containing "v1.0.0"' do + step 'page should have "v1.0.0" in title' do + expect(page).to have_css 'title', text: 'Network · v1.0.0', visible: false + end + + step 'page should only have content from "v1.0.0"' do page.within '.network-graph' do expect(page).not_to have_content 'Change some files' end diff --git a/features/steps/project/project_group_links.rb b/features/steps/project/project_group_links.rb new file mode 100644 index 00000000000..739a85e5fa4 --- /dev/null +++ b/features/steps/project/project_group_links.rb @@ -0,0 +1,50 @@ +class Spinach::Features::ProjectGroupLinks < Spinach::FeatureSteps + include SharedAuthentication + include SharedProject + include SharedPaths + include Select2Helper + + step 'I should see project already shared with group "Ops"' do + page.within '.enabled-groups' do + expect(page).to have_content "Ops" + end + end + + step 'I should see project is not shared with group "Market"' do + page.within '.enabled-groups' do + expect(page).not_to have_content "Market" + end + end + + step 'I select group "Market" for share' do + group = Group.find_by(path: 'market') + select2(group.id, from: "#link_group_id") + select "Master", from: 'link_group_access' + click_button "Share" + end + + step 'I should see project is shared with group "Market"' do + page.within '.enabled-groups' do + expect(page).to have_content "Market" + end + end + + step 'project "Shop" is shared with group "Ops"' do + group = create(:group, name: 'Ops') + share_link = project.project_group_links.new(group_access: Gitlab::Access::MASTER) + share_link.group_id = group.id + share_link.save! + end + + step 'project "Shop" is not shared with group "Market"' do + create(:group, name: 'Market', path: 'market') + end + + step 'I visit project group links page' do + visit namespace_project_group_links_path(project.namespace, project) + end + + def project + @project ||= Project.find_by_name "Shop" + end +end diff --git a/features/steps/project/snippets.rb b/features/steps/project/snippets.rb index 504654f90dd..786a0cad975 100644 --- a/features/steps/project/snippets.rb +++ b/features/steps/project/snippets.rb @@ -77,7 +77,7 @@ class Spinach::Features::ProjectSnippets < Spinach::FeatureSteps step 'I leave a comment like "Good snippet!"' do page.within('.js-main-target-form') do fill_in "note_note", with: "Good snippet!" - click_button "Add Comment" + click_button "Comment" end end diff --git a/features/steps/project/source/browse_files.rb b/features/steps/project/source/browse_files.rb index 51b15791674..243469b8e7d 100644 --- a/features/steps/project/source/browse_files.rb +++ b/features/steps/project/source/browse_files.rb @@ -361,7 +361,7 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps end step 'I can see the new rendered SVG image' do - expect(find('.file-content')).to have_css('img') + expect(page).to have_css('.file-content img') end private diff --git a/features/steps/project/team_management.rb b/features/steps/project/team_management.rb index caad52def79..3fbcf770b62 100644 --- a/features/steps/project/team_management.rb +++ b/features/steps/project/team_management.rb @@ -123,4 +123,23 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps click_link('Remove user from team') end end + + step 'I share project with group "OpenSource"' do + project = Project.find_by(name: 'Shop') + os_group = create(:group, name: 'OpenSource') + create(:project, group: os_group) + @os_user1 = create(:user) + @os_user2 = create(:user) + os_group.add_owner(@os_user1) + os_group.add_user(@os_user2, Gitlab::Access::DEVELOPER) + share_link = project.project_group_links.new(group_access: Gitlab::Access::MASTER) + share_link.group_id = os_group.id + share_link.save! + end + + step 'I should see "Opensource" group user listing' do + expect(page).to have_content("Shared with OpenSource group, members with Master role (2)") + expect(page).to have_content(@os_user1.name) + expect(page).to have_content(@os_user2.name) + end end diff --git a/features/steps/shared/builds.rb b/features/steps/shared/builds.rb index f33ed7834fe..c4c7672a432 100644 --- a/features/steps/shared/builds.rb +++ b/features/steps/shared/builds.rb @@ -68,7 +68,7 @@ module SharedBuilds end step 'I see the build' do - page.within('.commit_status') do + page.within('.build') do expect(page).to have_content "##{@build.id}" expect(page).to have_content @build.sha[0..7] expect(page).to have_content @build.ref diff --git a/features/steps/shared/diff_note.rb b/features/steps/shared/diff_note.rb index 06e69441894..906b66a4a63 100644 --- a/features/steps/shared/diff_note.rb +++ b/features/steps/shared/diff_note.rb @@ -93,14 +93,14 @@ module SharedDiffNote page.within("form[id$='#{sample_commit.line_code}']") do fill_in 'note[note]', with: ':smile:' - click_button('Add Comment') + click_button('Comment') end end end step 'I submit the diff comment' do page.within(diff_file_selector) do - click_button("Add Comment") + click_button("Comment") end end diff --git a/features/steps/shared/issuable.rb b/features/steps/shared/issuable.rb index 7c094bab8aa..b6d70a26c21 100644 --- a/features/steps/shared/issuable.rb +++ b/features/steps/shared/issuable.rb @@ -186,7 +186,7 @@ module SharedIssuable page.within('.js-main-target-form') do fill_in 'note[note]', with: "##{issuable.to_reference(project)}" - click_button 'Add Comment' + click_button 'Comment' end end diff --git a/features/steps/shared/note.rb b/features/steps/shared/note.rb index 444d6726f99..fb0462d6e04 100644 --- a/features/steps/shared/note.rb +++ b/features/steps/shared/note.rb @@ -17,7 +17,7 @@ module SharedNote step 'I leave a comment like "XML attached"' do page.within(".js-main-target-form") do fill_in "note[note]", with: "XML attached" - click_button "Add Comment" + click_button "Comment" end end @@ -30,7 +30,7 @@ module SharedNote step 'I submit the comment' do page.within(".js-main-target-form") do - click_button "Add Comment" + click_button "Comment" end end @@ -115,7 +115,7 @@ module SharedNote step 'I leave a comment with a header containing "Comment with a header"' do page.within(".js-main-target-form") do fill_in "note[note]", with: "# Comment with a header" - click_button "Add Comment" + click_button "Comment" sleep 0.05 end end diff --git a/features/steps/shared/paths.rb b/features/steps/shared/paths.rb index da9d1503ebc..2bd8ea745e4 100644 --- a/features/steps/shared/paths.rb +++ b/features/steps/shared/paths.rb @@ -27,6 +27,10 @@ module SharedPaths visit group_path(Group.find_by(name: "Owned")) end + step 'I visit group "Owned" activity page' do + visit activity_group_path(Group.find_by(name: "Owned")) + end + step 'I visit group "Owned" issues page' do visit issues_group_path(Group.find_by(name: "Owned")) end diff --git a/features/support/env.rb b/features/support/env.rb index 62c80b9c948..357d164d87f 100644 --- a/features/support/env.rb +++ b/features/support/env.rb @@ -14,6 +14,7 @@ require 'sidekiq/testing/inline' require_relative 'capybara' require_relative 'db_cleaner' +require_relative 'rerun' %w(select2_helper test_env repo_helpers).each do |f| require Rails.root.join('spec', 'support', f) diff --git a/features/support/rerun.rb b/features/support/rerun.rb new file mode 100644 index 00000000000..8b176c5be89 --- /dev/null +++ b/features/support/rerun.rb @@ -0,0 +1,14 @@ +# The spinach-rerun-reporter doesn't define the on_undefined_step +# See it here: https://github.com/javierav/spinach-rerun-reporter/blob/master/lib/spinach/reporter/rerun.rb +module Spinach + class Reporter + class Rerun + def on_undefined_step(step_data, failure, step_definitions = nil) + super step_data, failure, step_definitions + + # save feature file and scenario line + @rerun << "#{current_feature.filename}:#{current_scenario.line}" + end + end + end +end diff --git a/lib/api/entities.rb b/lib/api/entities.rb index b021db8fa5b..9805e53624e 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -23,6 +23,8 @@ module API end class UserFull < User + expose :last_sign_in_at + expose :confirmed_at expose :email expose :theme_id, :color_scheme_id, :projects_limit, :current_sign_in_at expose :identities, using: Entities::Identity @@ -141,7 +143,10 @@ module API class ProjectSnippet < Grape::Entity expose :id, :title, :file_name expose :author, using: Entities::UserBasic - expose :expires_at, :updated_at, :created_at + expose :updated_at, :created_at + + # TODO (rspeicher): Deprecated; remove in 9.0 + expose(:expires_at) { |snippet| nil } end class ProjectEntity < Grape::Entity @@ -241,6 +246,10 @@ module API end end + class ProjectGroupLink < Grape::Entity + expose :id, :project_id, :group_id, :group_access + end + class Namespace < Grape::Entity expose :id, :path, :kind end @@ -399,13 +408,6 @@ module API expose :id, :status, :stage, :name, :ref, :tag, :coverage expose :created_at, :started_at, :finished_at expose :user, with: User - # TODO: download_url in Ci:Build model is an GitLab Web Interface URL, not API URL. We should think on some API - # for downloading of artifacts (see: https://gitlab.com/gitlab-org/gitlab-ce/issues/4255) - expose :download_url do |repo_obj, options| - if options[:user_can_download_artifacts] - repo_obj.artifacts_download_url - end - end expose :artifacts_file, using: BuildArtifactFile, if: -> (build, opts) { build.artifacts? } expose :commit, with: RepoCommit do |repo_obj, _options| if repo_obj.respond_to?(:commit) diff --git a/lib/api/projects.rb b/lib/api/projects.rb index 6067c8b4a5e..6fcb5261e40 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -290,6 +290,33 @@ module API end end + # Share project with group + # + # Parameters: + # id (required) - The ID of a project + # group_id (required) - The ID of a group + # group_access (required) - Level of permissions for sharing + # + # Example Request: + # POST /projects/:id/share + post ":id/share" do + authorize! :admin_project, user_project + required_attributes! [:group_id, :group_access] + + unless user_project.allowed_to_share_with_group? + return render_api_error!("The project sharing with group is disabled", 400) + end + + link = user_project.project_group_links.new + link.group_id = params[:group_id] + link.group_access = params[:group_access] + if link.save + present link, with: Entities::ProjectGroupLink + else + render_api_error!(link.errors.full_messages.first, 409) + end + end + # Upload a file # # Parameters: diff --git a/lib/banzai/filter/sanitization_filter.rb b/lib/banzai/filter/sanitization_filter.rb index abd79b329ae..e8011519608 100644 --- a/lib/banzai/filter/sanitization_filter.rb +++ b/lib/banzai/filter/sanitization_filter.rb @@ -7,7 +7,7 @@ module Banzai # # Extends HTML::Pipeline::SanitizationFilter with a custom whitelist. class SanitizationFilter < HTML::Pipeline::SanitizationFilter - UNSAFE_PROTOCOLS = %w(javascript :javascript data vbscript).freeze + UNSAFE_PROTOCOLS = %w(data javascript vbscript).freeze def whitelist whitelist = super @@ -64,7 +64,12 @@ module Banzai return unless node.name == 'a' return unless node.has_attribute?('href') - if node['href'].start_with?(*UNSAFE_PROTOCOLS) + begin + uri = Addressable::URI.parse(node['href']) + uri.scheme.strip! if uri.scheme + + node.remove_attribute('href') if UNSAFE_PROTOCOLS.include?(uri.scheme) + rescue Addressable::URI::InvalidURIError node.remove_attribute('href') end end diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index 1a3f662811a..c89e1b51019 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -5,12 +5,14 @@ module Ci DEFAULT_STAGES = %w(build test deploy) DEFAULT_STAGE = 'test' ALLOWED_YAML_KEYS = [:before_script, :image, :services, :types, :stages, :variables, :cache] - ALLOWED_JOB_KEYS = [:tags, :script, :only, :except, :type, :image, :services, :allow_failure, :type, :stage, :when, :artifacts, :cache] + ALLOWED_JOB_KEYS = [:tags, :script, :only, :except, :type, :image, :services, + :allow_failure, :type, :stage, :when, :artifacts, :cache, + :dependencies] attr_reader :before_script, :image, :services, :variables, :path, :cache def initialize(config, path = nil) - @config = YAML.safe_load(config, [Symbol]) + @config = YAML.safe_load(config, [Symbol], [], true) @path = path unless @config.is_a? Hash @@ -60,6 +62,7 @@ module Ci @jobs = {} @config.each do |key, job| + next if key.to_s.start_with?('.') stage = job[:stage] || job[:type] || DEFAULT_STAGE @jobs[key] = { stage: stage }.merge(job) end @@ -81,6 +84,7 @@ module Ci services: job[:services] || @services, artifacts: job[:artifacts], cache: job[:cache] || @cache, + dependencies: job[:dependencies], }.compact } end @@ -143,6 +147,7 @@ module Ci validate_job_stage!(name, job) if job[:stage] validate_job_cache!(name, job) if job[:cache] validate_job_artifacts!(name, job) if job[:artifacts] + validate_job_dependencies!(name, job) if job[:dependencies] end private @@ -216,6 +221,10 @@ module Ci end def validate_job_artifacts!(name, job) + if job[:artifacts][:name] && !validate_string(job[:artifacts][:name]) + raise ValidationError, "#{name} job: artifacts:name parameter should be a string" + end + if job[:artifacts][:untracked] && !validate_boolean(job[:artifacts][:untracked]) raise ValidationError, "#{name} job: artifacts:untracked parameter should be an boolean" end @@ -225,6 +234,22 @@ module Ci end end + def validate_job_dependencies!(name, job) + if !validate_array_of_strings(job[:dependencies]) + raise ValidationError, "#{name} job: dependencies parameter should be an array of strings" + end + + stage_index = stages.index(job[:stage]) + + job[:dependencies].each do |dependency| + raise ValidationError, "#{name} job: undefined dependency: #{dependency}" unless @jobs[dependency] + + unless stages.index(@jobs[dependency][:stage]) < stage_index + raise ValidationError, "#{name} job: dependency #{dependency} is not defined in prior stages" + end + end + end + def validate_array_of_strings(values) values.is_a?(Array) && values.all? { |value| validate_string(value) } end diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index 3f483847efa..46e51a4bf6d 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -76,7 +76,7 @@ module Gitlab project.issues.create!( description: body, title: issue["title"], - state: %w(resolved invalid duplicate wontfix).include?(issue["status"]) ? 'closed' : 'opened', + state: %w(resolved invalid duplicate wontfix closed).include?(issue["status"]) ? 'closed' : 'opened', author_id: gl_user_id(project, reporter) ) end diff --git a/lib/gitlab/devise_failure.rb b/lib/gitlab/devise_failure.rb new file mode 100644 index 00000000000..a78fde9d782 --- /dev/null +++ b/lib/gitlab/devise_failure.rb @@ -0,0 +1,23 @@ +module Gitlab + class DeviseFailure < Devise::FailureApp + protected + + # Override `Devise::FailureApp#request_format` to handle a special case + # + # This tells Devise to handle an unauthenticated `.zip` request as an HTML + # request (i.e., redirect to sign in). + # + # Otherwise, Devise would respond with a 401 Unauthorized with + # `Content-Type: application/zip` and a response body in plaintext, and the + # browser would freak out. + # + # See https://gitlab.com/gitlab-org/gitlab-ce/issues/12944 + def request_format + if request.format == :zip + Mime::Type.lookup_by_extension(:html).ref + else + super + end + end + end +end diff --git a/lib/gitlab/exclusive_lease.rb b/lib/gitlab/exclusive_lease.rb new file mode 100644 index 00000000000..2ef50286b1d --- /dev/null +++ b/lib/gitlab/exclusive_lease.rb @@ -0,0 +1,41 @@ +module Gitlab + # This class implements an 'exclusive lease'. We call it a 'lease' + # because it has a set expiry time. We call it 'exclusive' because only + # one caller may obtain a lease for a given key at a time. The + # implementation is intended to work across GitLab processes and across + # servers. It is a 'cheap' alternative to using SQL queries and updates: + # you do not need to change the SQL schema to start using + # ExclusiveLease. + # + # It is important to choose the timeout wisely. If the timeout is very + # high (1 hour) then the throughput of your operation gets very low (at + # most once an hour). If the timeout is lower than how long your + # operation may take then you cannot count on exclusivity. For example, + # if the timeout is 10 seconds and you do an operation which may take 20 + # seconds then two overlapping operations may hold a lease for the same + # key at the same time. + # + class ExclusiveLease + def initialize(key, timeout:) + @key, @timeout = key, timeout + end + + # Try to obtain the lease. Return true on success, + # false if the lease is already taken. + def try_obtain + # Performing a single SET is atomic + !!redis.set(redis_key, '1', nx: true, ex: @timeout) + end + + private + + def redis + # Maybe someday we want to use a connection pool... + @redis ||= Redis.new(url: Gitlab::RedisConfig.url) + end + + def redis_key + "gitlab:exclusive_lease:#{@key}" + end + end +end diff --git a/lib/gitlab/github_import/importer.rb b/lib/gitlab/github_import/importer.rb index e2a85f29825..172c5441e36 100644 --- a/lib/gitlab/github_import/importer.rb +++ b/lib/gitlab/github_import/importer.rb @@ -45,10 +45,13 @@ module Gitlab direction: :asc).each do |raw_data| pull_request = PullRequestFormatter.new(project, raw_data) - if !pull_request.cross_project? && pull_request.valid? - merge_request = MergeRequest.create!(pull_request.attributes) - import_comments(pull_request.number, merge_request) - import_comments_on_diff(pull_request.number, merge_request) + if pull_request.valid? + merge_request = MergeRequest.new(pull_request.attributes) + + if merge_request.save + import_comments(pull_request.number, merge_request) + import_comments_on_diff(pull_request.number, merge_request) + end end end diff --git a/lib/gitlab/github_import/pull_request_formatter.rb b/lib/gitlab/github_import/pull_request_formatter.rb index f96fed0f5cf..4e507b090e8 100644 --- a/lib/gitlab/github_import/pull_request_formatter.rb +++ b/lib/gitlab/github_import/pull_request_formatter.rb @@ -17,16 +17,12 @@ module Gitlab } end - def cross_project? - source_repo.id != target_repo.id - end - def number raw_data.number end def valid? - source_branch.present? && target_branch.present? + !cross_project? && source_branch.present? && target_branch.present? end private @@ -53,6 +49,10 @@ module Gitlab raw_data.body || "" end + def cross_project? + source_repo.present? && target_repo.present? && source_repo.id != target_repo.id + end + def description formatter.author_line(author) + body end diff --git a/lib/gitlab/middleware/go.rb b/lib/gitlab/middleware/go.rb new file mode 100644 index 00000000000..50b0dd32380 --- /dev/null +++ b/lib/gitlab/middleware/go.rb @@ -0,0 +1,50 @@ +# A dumb middleware that returns a Go HTML document if the go-get=1 query string +# is used irrespective if the namespace/project exists +module Gitlab + module Middleware + class Go + def initialize(app) + @app = app + end + + def call(env) + request = Rack::Request.new(env) + + if go_request?(request) + render_go_doc(request) + else + @app.call(env) + end + end + + private + + def render_go_doc(request) + body = go_body(request) + response = Rack::Response.new(body, 200, { 'Content-Type' => 'text/html' }) + response.finish + end + + def go_request?(request) + request["go-get"].to_i == 1 && request.env["PATH_INFO"].present? + end + + def go_body(request) + base_url = Gitlab.config.gitlab.url + # Go subpackages may be in the form of namespace/project/path1/path2/../pathN + # We can just ignore the paths and leave the namespace/project + path_info = request.env["PATH_INFO"] + path_info.sub!(/^\//, '') + project_path = path_info.split('/').first(2).join('/') + request_url = URI.join(base_url, project_path) + domain_path = strip_url(request_url.to_s) + + "<!DOCTYPE html><html><head><meta content='#{domain_path} git #{request_url}.git' name='go-import'></head></html>\n"; + end + + def strip_url(url) + url.gsub(/\Ahttps?:\/\//, '') + end + end + end +end diff --git a/lib/gitlab/project_search_results.rb b/lib/gitlab/project_search_results.rb index 70de6a74e76..0607a8b9592 100644 --- a/lib/gitlab/project_search_results.rb +++ b/lib/gitlab/project_search_results.rb @@ -2,8 +2,8 @@ module Gitlab class ProjectSearchResults < SearchResults attr_reader :project, :repository_ref - def initialize(project_id, query, repository_ref = nil) - @project = Project.find(project_id) + def initialize(project, query, repository_ref = nil) + @project = project @repository_ref = if repository_ref.present? repository_ref else @@ -73,7 +73,7 @@ module Gitlab end def notes - Note.where(project_id: limit_project_ids).user.search(query).order('updated_at DESC') + project.notes.user.search(query).order('updated_at DESC') end def commits @@ -84,8 +84,8 @@ module Gitlab end end - def limit_project_ids - [project.id] + def project_ids_relation + project end end end diff --git a/lib/gitlab/push_data_builder.rb b/lib/gitlab/push_data_builder.rb index da1c15fef61..97d1edab9c1 100644 --- a/lib/gitlab/push_data_builder.rb +++ b/lib/gitlab/push_data_builder.rb @@ -63,7 +63,7 @@ module Gitlab end # This method provide a sample data generated with - # existing project and commits to test web hooks + # existing project and commits to test webhooks def build_sample(project, user) commits = project.repository.commits(project.default_branch, nil, 3) ref = "#{Gitlab::Git::BRANCH_REF_PREFIX}#{project.default_branch}" diff --git a/lib/gitlab/redis_config.rb b/lib/gitlab/redis_config.rb new file mode 100644 index 00000000000..4949c6db539 --- /dev/null +++ b/lib/gitlab/redis_config.rb @@ -0,0 +1,30 @@ +module Gitlab + class RedisConfig + attr_reader :url + + def self.url + new.url + end + + def self.redis_store_options + url = new.url + redis_config_hash = Redis::Store::Factory.extract_host_options_from_uri(url) + # Redis::Store does not handle Unix sockets well, so let's do it for them + redis_uri = URI.parse(url) + if redis_uri.scheme == 'unix' + redis_config_hash[:path] = redis_uri.path + end + redis_config_hash + end + + def initialize(rails_env=nil) + rails_env ||= Rails.env + config_file = File.expand_path('../../../config/resque.yml', __FILE__) + + @url = "redis://localhost:6379" + if File.exists?(config_file) + @url =YAML.load_file(config_file)[rails_env] + end + end + end +end diff --git a/lib/gitlab/search_results.rb b/lib/gitlab/search_results.rb index 2ab2d4af797..f13528a2eea 100644 --- a/lib/gitlab/search_results.rb +++ b/lib/gitlab/search_results.rb @@ -2,12 +2,12 @@ module Gitlab class SearchResults attr_reader :query - # Limit search results by passed project ids + # Limit search results by passed projects # It allows us to search only for projects user has access to - attr_reader :limit_project_ids + attr_reader :limit_projects - def initialize(limit_project_ids, query) - @limit_project_ids = limit_project_ids || Project.all + def initialize(limit_projects, query) + @limit_projects = limit_projects || Project.all @query = Shellwords.shellescape(query) if query.present? end @@ -27,7 +27,8 @@ module Gitlab end def total_count - @total_count ||= projects_count + issues_count + merge_requests_count + milestones_count + @total_count ||= projects_count + issues_count + merge_requests_count + + milestones_count end def projects_count @@ -53,27 +54,29 @@ module Gitlab private def projects - Project.where(id: limit_project_ids).search(query) + limit_projects.search(query) end def issues - issues = Issue.where(project_id: limit_project_ids) + issues = Issue.where(project_id: project_ids_relation) + if query =~ /#(\d+)\z/ issues = issues.where(iid: $1) else issues = issues.full_search(query) end + issues.order('updated_at DESC') end def milestones - milestones = Milestone.where(project_id: limit_project_ids) + milestones = Milestone.where(project_id: project_ids_relation) milestones = milestones.search(query) milestones.order('updated_at DESC') end def merge_requests - merge_requests = MergeRequest.in_projects(limit_project_ids) + merge_requests = MergeRequest.in_projects(project_ids_relation) if query =~ /[#!](\d+)\z/ merge_requests = merge_requests.where(iid: $1) else @@ -89,5 +92,9 @@ module Gitlab def per_page 20 end + + def project_ids_relation + limit_projects.select(:id).reorder(nil) + end end end diff --git a/lib/gitlab/snippet_search_results.rb b/lib/gitlab/snippet_search_results.rb index addda95be2b..e0e74ff8359 100644 --- a/lib/gitlab/snippet_search_results.rb +++ b/lib/gitlab/snippet_search_results.rb @@ -2,10 +2,10 @@ module Gitlab class SnippetSearchResults < SearchResults include SnippetsHelper - attr_reader :limit_snippet_ids + attr_reader :limit_snippets - def initialize(limit_snippet_ids, query) - @limit_snippet_ids = limit_snippet_ids + def initialize(limit_snippets, query) + @limit_snippets = limit_snippets @query = query end @@ -35,11 +35,11 @@ module Gitlab private def snippet_titles - Snippet.where(id: limit_snippet_ids).search(query).order('updated_at DESC') + limit_snippets.search(query).order('updated_at DESC') end def snippet_blobs - Snippet.where(id: limit_snippet_ids).search_code(query).order('updated_at DESC') + limit_snippets.search_code(query).order('updated_at DESC') end def default_scope diff --git a/lib/gitlab/user_access.rb b/lib/gitlab/user_access.rb index 4885baf9526..d1b42c1f9b9 100644 --- a/lib/gitlab/user_access.rb +++ b/lib/gitlab/user_access.rb @@ -3,7 +3,7 @@ module Gitlab def self.allowed?(user) return false if user.blocked? - if user.requires_ldap_check? + if user.requires_ldap_check? && user.try_obtain_ldap_lease return false unless Gitlab::LDAP::Access.allowed?(user) end diff --git a/lib/tasks/cache.rake b/lib/tasks/cache.rake index f221afcf73a..51e746ef923 100644 --- a/lib/tasks/cache.rake +++ b/lib/tasks/cache.rake @@ -4,16 +4,16 @@ namespace :cache do desc "GitLab | Clear redis cache" task :clear => :environment do - redis_store = Rails.cache.instance_variable_get(:@data) + redis = Redis.new(url: Gitlab::RedisConfig.url) cursor = REDIS_SCAN_START_STOP loop do - cursor, keys = redis_store.scan( + cursor, keys = redis.scan( cursor, match: "#{Gitlab::REDIS_CACHE_NAMESPACE}*", count: CLEAR_BATCH_SIZE ) - redis_store.del(*keys) if keys.any? + redis.del(*keys) if keys.any? break if cursor == REDIS_SCAN_START_STOP end diff --git a/lib/tasks/gemojione.rake b/lib/tasks/gemojione.rake new file mode 100644 index 00000000000..ebe301c1fc7 --- /dev/null +++ b/lib/tasks/gemojione.rake @@ -0,0 +1,121 @@ +# This task will generate a standard and Retina sprite of all of the current +# Gemojione Emojis, with the accompanying SCSS map. +# +# It will not appear in `rake -T` output, and the dependent gems are not +# included in the Gemfile by default, because this task will only be needed +# occasionally, such as when new Emojis are added to Gemojione. + +begin + require 'sprite_factory' + require 'rmagick' +rescue LoadError + # noop +end + +namespace :gemojione do + task sprite: :environment do + check_requirements! + + SIZE = 20 + RETINA = SIZE * 2 + + Dir.mktmpdir do |tmpdir| + # Copy the Gemojione assets to the temporary folder for resizing + FileUtils.cp_r(Gemojione.index.images_path, tmpdir) + + Dir.chdir(tmpdir) do + Dir["**/*.png"].each do |png| + resize!(File.join(tmpdir, png), SIZE) + end + end + + style_path = Rails.root.join(*%w(app assets stylesheets pages emojis.scss)) + + # Combine the resized assets into a packed sprite and re-generate the SCSS + SpriteFactory.cssurl = "image-url('$IMAGE')" + SpriteFactory.run!(File.join(tmpdir, 'images'), { + output_style: style_path, + output_image: "app/assets/images/emoji.png", + selector: '.emoji-', + style: :scss, + nocomments: true, + pngcrush: true, + layout: :packed + }) + + # SpriteFactory's SCSS is a bit too verbose for our purposes here, so + # let's simplify it + system(%Q(sed -i '' "s/width: #{SIZE}px; height: #{SIZE}px; background: image-url('emoji.png')/background-position:/" #{style_path})) + system(%Q(sed -i '' "s/ no-repeat//" #{style_path})) + + # Append a generic rule that applies to all Emojis + File.open(style_path, 'a') do |f| + f.puts + f.puts <<-CSS.strip_heredoc + .emoji-icon { + background-image: image-url('emoji.png'); + background-repeat: no-repeat; + height: #{SIZE}px; + width: #{SIZE}px; + + @media only screen and (-webkit-min-device-pixel-ratio: 2), + only screen and (min--moz-device-pixel-ratio: 2), + only screen and (-o-min-device-pixel-ratio: 2/1), + only screen and (min-device-pixel-ratio: 2), + only screen and (min-resolution: 192dpi), + only screen and (min-resolution: 2dppx) { + background-image: image-url('emoji@2x.png'); + background-size: 840px 820px; + } + } + CSS + end + end + + # Now do it again but for Retina + Dir.mktmpdir do |tmpdir| + # Copy the Gemojione assets to the temporary folder for resizing + FileUtils.cp_r(Gemojione.index.images_path, tmpdir) + + Dir.chdir(tmpdir) do + Dir["**/*.png"].each do |png| + resize!(File.join(tmpdir, png), RETINA) + end + end + + # Combine the resized assets into a packed sprite and re-generate the SCSS + SpriteFactory.run!(File.join(tmpdir, 'images'), { + output_image: "app/assets/images/emoji@2x.png", + style: false, + nocomments: true, + pngcrush: true, + layout: :packed + }) + end + end + + def check_requirements! + return if defined?(SpriteFactory) && defined?(Magick) + + puts <<-MSG.strip_heredoc + This task is disabled by default and should only be run when the Gemojione + gem is updated with new Emojis. + + To enable this task, *temporarily* add the following lines to Gemfile and + re-bundle: + + gem 'sprite-factory' + gem 'rmagick' + MSG + + exit 1 + end + + def resize!(image_path, size) + # Resize the image in-place, save it, and free the object + image = Magick::Image.read(image_path).first + image.resize!(size, size) + image.write(image_path) { self.quality = 100 } + image.destroy! + end +end diff --git a/lib/tasks/gitlab/web_hook.rake b/lib/tasks/gitlab/web_hook.rake index 76e443e55ee..cc0f668474e 100644 --- a/lib/tasks/gitlab/web_hook.rake +++ b/lib/tasks/gitlab/web_hook.rake @@ -1,13 +1,13 @@ namespace :gitlab do namespace :web_hook do - desc "GitLab | Adds a web hook to the projects" + desc "GitLab | Adds a webhook to the projects" task :add => :environment do web_hook_url = ENV['URL'] namespace_path = ENV['NAMESPACE'] projects = find_projects(namespace_path) - puts "Adding web hook '#{web_hook_url}' to:" + puts "Adding webhook '#{web_hook_url}' to:" projects.find_each(batch_size: 1000) do |project| print "- #{project.name} ... " web_hook = project.hooks.new(url: web_hook_url) @@ -20,7 +20,7 @@ namespace :gitlab do end end - desc "GitLab | Remove a web hook from the projects" + desc "GitLab | Remove a webhook from the projects" task :rm => :environment do web_hook_url = ENV['URL'] namespace_path = ENV['NAMESPACE'] @@ -28,12 +28,12 @@ namespace :gitlab do projects = find_projects(namespace_path) projects_ids = projects.pluck(:id) - puts "Removing web hooks with the url '#{web_hook_url}' ... " + puts "Removing webhooks with the url '#{web_hook_url}' ... " count = WebHook.where(url: web_hook_url, project_id: projects_ids, type: 'ProjectHook').delete_all - puts "#{count} web hooks were removed." + puts "#{count} webhooks were removed." end - desc "GitLab | List web hooks" + desc "GitLab | List webhooks" task :list => :environment do namespace_path = ENV['NAMESPACE'] @@ -43,7 +43,7 @@ namespace :gitlab do puts "#{hook.project.name.truncate(20).ljust(20)} -> #{hook.url}" end - puts "\n#{web_hooks.size} web hooks found." + puts "\n#{web_hooks.size} webhooks found." end end diff --git a/lib/tasks/spec.rake b/lib/tasks/spec.rake index 0985ef3a669..2cf7a25a0fd 100644 --- a/lib/tasks/spec.rake +++ b/lib/tasks/spec.rake @@ -46,20 +46,11 @@ namespace :spec do run_commands(cmds) end - desc 'GitLab | Rspec | Run benchmark specs' - task :benchmark do - cmds = [ - %W(rake gitlab:setup), - %W(rspec spec --tag @benchmark) - ] - run_commands(cmds) - end - desc 'GitLab | Rspec | Run other specs' task :other do cmds = [ %W(rake gitlab:setup), - %W(rspec spec --tag ~@api --tag ~@feature --tag ~@models --tag ~@lib --tag ~@services --tag ~@benchmark) + %W(rspec spec --tag ~@api --tag ~@feature --tag ~@models --tag ~@lib --tag ~@services) ] run_commands(cmds) end @@ -69,7 +60,7 @@ desc "GitLab | Run specs" task :spec do cmds = [ %W(rake gitlab:setup), - %W(rspec spec --tag ~@benchmark), + %W(rspec spec), ] run_commands(cmds) end diff --git a/lib/tasks/spinach.rake b/lib/tasks/spinach.rake index 3acfc6e2075..01d23b89bb7 100644 --- a/lib/tasks/spinach.rake +++ b/lib/tasks/spinach.rake @@ -4,53 +4,59 @@ namespace :spinach do namespace :project do desc "GitLab | Spinach | Run project commits, issues and merge requests spinach features" task :half do - cmds = [ - %W(rake gitlab:setup), - %W(spinach --tags @project_commits,@project_issues,@project_merge_requests), - ] - run_commands(cmds) + run_spinach_tests('@project_commits,@project_issues,@project_merge_requests') end desc "GitLab | Spinach | Run remaining project spinach features" task :rest do - cmds = [ - %W(rake gitlab:setup), - %W(spinach --tags ~@admin,~@dashboard,~@profile,~@public,~@snippets,~@project_commits,~@project_issues,~@project_merge_requests), - ] - run_commands(cmds) + run_spinach_tests('~@admin,~@dashboard,~@profile,~@public,~@snippets,~@project_commits,~@project_issues,~@project_merge_requests') end end desc "GitLab | Spinach | Run project spinach features" task :project do - cmds = [ - %W(rake gitlab:setup), - %W(spinach --tags ~@admin,~@dashboard,~@profile,~@public,~@snippets), - ] - run_commands(cmds) + run_spinach_tests('~@admin,~@dashboard,~@profile,~@public,~@snippets') end desc "GitLab | Spinach | Run other spinach features" task :other do - cmds = [ - %W(rake gitlab:setup), - %W(spinach --tags @admin,@dashboard,@profile,@public,@snippets), - ] - run_commands(cmds) + run_spinach_tests('@admin,@dashboard,@profile,@public,@snippets') + end + + desc "GitLab | Spinach | Run other spinach features" + task :builds do + run_spinach_tests('@builds') end end desc "GitLab | Run spinach" task :spinach do - cmds = [ - %W(rake gitlab:setup), - %W(spinach), - ] - run_commands(cmds) + run_spinach_tests(nil) +end + +def run_command(cmd) + system({'RAILS_ENV' => 'test', 'force' => 'yes'}, *cmd) end -def run_commands(cmds) - cmds.each do |cmd| - system({'RAILS_ENV' => 'test', 'force' => 'yes'}, *cmd) or raise("#{cmd} failed!") +def run_spinach_command(args) + run_command(%w(spinach -r rerun) + args) +end + +def run_spinach_tests(tags) + #run_command(%w(rake gitlab:setup)) or raise('gitlab:setup failed!') + + success = run_spinach_command(%W(--tags #{tags})) + 3.times do |_| + break if success + break unless File.exists?('tmp/spinach-rerun.txt') + + tests = File.foreach('tmp/spinach-rerun.txt').map(&:chomp) + puts '' + puts "Spinach tests for #{tags}: Retrying tests... #{tests}".red + puts '' + sleep(3) + success = run_spinach_command(tests) end + + raise("spinach tests for #{tags} failed!") unless success end diff --git a/public/logo.svg b/public/logo.svg index c09785cb96f..fc4553137f7 100644 --- a/public/logo.svg +++ b/public/logo.svg @@ -1,26 +1,9 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<svg width="210px" height="210px" viewBox="0 0 210 210" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns"> - <!-- Generator: Sketch 3.3.2 (12043) - http://www.bohemiancoding.com/sketch --> - <title>Slice 1</title> - <desc>Created with Sketch.</desc> - <defs></defs> - <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage"> - <g id="logo" sketch:type="MSLayerGroup" transform="translate(0.000000, 10.000000)"> - <g id="Page-1" sketch:type="MSShapeGroup"> - <g id="Fill-1-+-Group-24"> - <g id="Group-24"> - <g id="Group"> - <path d="M105.0614,193.655 L105.0614,193.655 L143.7014,74.734 L66.4214,74.734 L105.0614,193.655 L105.0614,193.655 Z" id="Fill-4" fill="#E24329"></path> - <path d="M105.0614,193.6548 L66.4214,74.7338 L12.2684,74.7338 L105.0614,193.6548 L105.0614,193.6548 Z" id="Fill-8" fill="#FC6D26"></path> - <path d="M12.2685,74.7341 L12.2685,74.7341 L0.5265,110.8731 C-0.5445,114.1691 0.6285,117.7801 3.4325,119.8171 L105.0615,193.6551 L12.2685,74.7341 L12.2685,74.7341 Z" id="Fill-12" fill="#FCA326"></path> - <path d="M12.2685,74.7342 L66.4215,74.7342 L43.1485,3.1092 C41.9515,-0.5768 36.7375,-0.5758 35.5405,3.1092 L12.2685,74.7342 L12.2685,74.7342 Z" id="Fill-16" fill="#E24329"></path> - <path d="M105.0614,193.6548 L143.7014,74.7338 L197.8544,74.7338 L105.0614,193.6548 L105.0614,193.6548 Z" id="Fill-18" fill="#FC6D26"></path> - <path d="M197.8544,74.7341 L197.8544,74.7341 L209.5964,110.8731 C210.6674,114.1691 209.4944,117.7801 206.6904,119.8171 L105.0614,193.6551 L197.8544,74.7341 L197.8544,74.7341 Z" id="Fill-20" fill="#FCA326"></path> - <path d="M197.8544,74.7342 L143.7014,74.7342 L166.9744,3.1092 C168.1714,-0.5768 173.3854,-0.5758 174.5824,3.1092 L197.8544,74.7342 L197.8544,74.7342 Z" id="Fill-22" fill="#E24329"></path> - </g> - </g> - </g> - </g> - </g> - </g> -</svg>
\ No newline at end of file +<svg width="210" height="210" viewBox="0 0 210 210" xmlns="http://www.w3.org/2000/svg"> + <path d="M105.0614 203.655l38.64-118.921h-77.28l38.64 118.921z" fill="#e24329"/> + <path d="M105.0614 203.6548l-38.64-118.921h-54.153l92.793 118.921z" fill="#fc6d26"/> + <path d="M12.2685 84.7341l-11.742 36.139c-1.071 3.296.102 6.907 2.906 8.944l101.629 73.838-92.793-118.921z" fill="#fca326"/> + <path d="M12.2685 84.7342h54.153l-23.273-71.625c-1.197-3.686-6.411-3.685-7.608 0l-23.272 71.625z" fill="#e24329"/> + <path d="M105.0614 203.6548l38.64-118.921h54.153l-92.793 118.921z" fill="#fc6d26"/> + <path d="M197.8544 84.7341l11.742 36.139c1.071 3.296-.102 6.907-2.906 8.944l-101.629 73.838 92.793-118.921z" fill="#fca326"/> + <path d="M197.8544 84.7342h-54.153l23.273-71.625c1.197-3.686 6.411-3.685 7.608 0l23.272 71.625z" fill="#e24329"/> +</svg> diff --git a/scripts/prepare_build.sh b/scripts/prepare_build.sh index b6f076a90c3..4a7ee7dbb64 100755 --- a/scripts/prepare_build.sh +++ b/scripts/prepare_build.sh @@ -1,16 +1,30 @@ #!/bin/bash +retry() { + for i in $(seq 1 3); do + if eval "$@"; then + return 0 + fi + sleep 3s + echo "Retrying..." + done + return 1 +} + if [ -f /.dockerinit ]; then mkdir -p vendor - if [ ! -e vendor/phantomjs_1.9.8-0jessie_amd64.deb ]; then + + # Install phantomjs package + pushd vendor + if [ ! -e phantomjs_1.9.8-0jessie_amd64.deb ]; then wget -q https://gitlab.com/axil/phantomjs-debian/raw/master/phantomjs_1.9.8-0jessie_amd64.deb - mv phantomjs_1.9.8-0jessie_amd64.deb vendor/ fi - dpkg -i vendor/phantomjs_1.9.8-0jessie_amd64.deb + dpkg -i phantomjs_1.9.8-0jessie_amd64.deb + popd - apt-get update -qq - apt-get -o dir::cache::archives="vendor/apt" install -y -qq --force-yes \ - libicu-dev libkrb5-dev cmake nodejs postgresql-client mysql-client unzip + # Try to install packages + retry 'apt-get update -yqqq; apt-get -o dir::cache::archives="vendor/apt" install -y -qq --force-yes \ + libicu-dev libkrb5-dev cmake nodejs postgresql-client mysql-client unzip' cp config/database.yml.mysql config/database.yml sed -i 's/username:.*/username: root/g' config/database.yml @@ -20,7 +34,7 @@ if [ -f /.dockerinit ]; then cp config/resque.yml.example config/resque.yml sed -i 's/localhost/redis/g' config/resque.yml - export FLAGS=(--path vendor) + export FLAGS=(--path vendor --retry 3) else export PATH=$HOME/bin:/usr/local/bin:/usr/bin:/bin cp config/database.yml.mysql config/database.yml diff --git a/spec/benchmarks/finders/issues_finder_spec.rb b/spec/benchmarks/finders/issues_finder_spec.rb deleted file mode 100644 index b57a33004a4..00000000000 --- a/spec/benchmarks/finders/issues_finder_spec.rb +++ /dev/null @@ -1,55 +0,0 @@ -require 'spec_helper' - -describe IssuesFinder, benchmark: true do - describe '#execute' do - let(:user) { create(:user) } - let(:project) { create(:project, :public) } - - let(:label1) { create(:label, project: project, title: 'A') } - let(:label2) { create(:label, project: project, title: 'B') } - - before do - 10.times do |n| - issue = create(:issue, author: user, project: project) - - if n > 4 - create(:label_link, label: label1, target: issue) - create(:label_link, label: label2, target: issue) - end - end - end - - describe 'retrieving issues without labels' do - let(:finder) do - IssuesFinder.new(user, scope: 'all', label_name: Label::None.title, - state: 'opened') - end - - benchmark_subject { finder.execute } - - it { is_expected.to iterate_per_second(2000) } - end - - describe 'retrieving issues with labels' do - let(:finder) do - IssuesFinder.new(user, scope: 'all', label_name: label1.title, - state: 'opened') - end - - benchmark_subject { finder.execute } - - it { is_expected.to iterate_per_second(1000) } - end - - describe 'retrieving issues for a single project' do - let(:finder) do - IssuesFinder.new(user, scope: 'all', label_name: Label::None.title, - state: 'opened', project_id: project.id) - end - - benchmark_subject { finder.execute } - - it { is_expected.to iterate_per_second(2000) } - end - end -end diff --git a/spec/benchmarks/finders/trending_projects_finder_spec.rb b/spec/benchmarks/finders/trending_projects_finder_spec.rb deleted file mode 100644 index 551ce21840d..00000000000 --- a/spec/benchmarks/finders/trending_projects_finder_spec.rb +++ /dev/null @@ -1,14 +0,0 @@ -require 'spec_helper' - -describe TrendingProjectsFinder, benchmark: true do - describe '#execute' do - let(:finder) { described_class.new } - let(:user) { create(:user) } - - # to_a is used to force actually running the query (instead of just building - # it). - benchmark_subject { finder.execute(user).non_archived.to_a } - - it { is_expected.to iterate_per_second(500) } - end -end diff --git a/spec/benchmarks/lib/gitlab/markdown/reference_filter_spec.rb b/spec/benchmarks/lib/gitlab/markdown/reference_filter_spec.rb deleted file mode 100644 index 3855763b200..00000000000 --- a/spec/benchmarks/lib/gitlab/markdown/reference_filter_spec.rb +++ /dev/null @@ -1,41 +0,0 @@ -require 'spec_helper' - -describe Banzai::Filter::ReferenceFilter, benchmark: true do - let(:input) do - html = <<-EOF -<p>Hello @alice and @bob, how are you doing today?</p> -<p>This is simple @dummy text to see how the @ReferenceFilter class performs -when @processing HTML.</p> - EOF - - Nokogiri::HTML.fragment(html) - end - - let(:project) { create(:empty_project) } - - let(:filter) { described_class.new(input, project: project) } - - describe '#replace_text_nodes_matching' do - let(:iterations) { 6000 } - - describe 'with identical input and output HTML' do - benchmark_subject do - filter.replace_text_nodes_matching(User.reference_pattern) do |content| - content - end - end - - it { is_expected.to iterate_per_second(iterations) } - end - - describe 'with different input and output HTML' do - benchmark_subject do - filter.replace_text_nodes_matching(User.reference_pattern) do |content| - '@eve' - end - end - - it { is_expected.to iterate_per_second(iterations) } - end - end -end diff --git a/spec/benchmarks/models/milestone_spec.rb b/spec/benchmarks/models/milestone_spec.rb deleted file mode 100644 index a94afc4c40d..00000000000 --- a/spec/benchmarks/models/milestone_spec.rb +++ /dev/null @@ -1,17 +0,0 @@ -require 'spec_helper' - -describe Milestone, benchmark: true do - describe '#sort_issues' do - let(:milestone) { create(:milestone) } - - let(:issue1) { create(:issue, milestone: milestone) } - let(:issue2) { create(:issue, milestone: milestone) } - let(:issue3) { create(:issue, milestone: milestone) } - - let(:issue_ids) { [issue3.id, issue2.id, issue1.id] } - - benchmark_subject { milestone.sort_issues(issue_ids) } - - it { is_expected.to iterate_per_second(500) } - end -end diff --git a/spec/benchmarks/models/project_spec.rb b/spec/benchmarks/models/project_spec.rb deleted file mode 100644 index cee0949edc5..00000000000 --- a/spec/benchmarks/models/project_spec.rb +++ /dev/null @@ -1,50 +0,0 @@ -require 'spec_helper' - -describe Project, benchmark: true do - describe '.trending' do - let(:group) { create(:group) } - let(:project1) { create(:empty_project, :public, group: group) } - let(:project2) { create(:empty_project, :public, group: group) } - - let(:iterations) { 500 } - - before do - 2.times do - create(:note_on_commit, project: project1) - end - - create(:note_on_commit, project: project2) - end - - describe 'without an explicit start date' do - benchmark_subject { described_class.trending.to_a } - - it { is_expected.to iterate_per_second(iterations) } - end - - describe 'with an explicit start date' do - let(:date) { 1.month.ago } - - benchmark_subject { described_class.trending(date).to_a } - - it { is_expected.to iterate_per_second(iterations) } - end - end - - describe '.find_with_namespace' do - let(:group) { create(:group, name: 'sisinmaru') } - let(:project) { create(:project, name: 'maru', namespace: group) } - - describe 'using a capitalized namespace' do - benchmark_subject { described_class.find_with_namespace('sisinmaru/MARU') } - - it { is_expected.to iterate_per_second(600) } - end - - describe 'using a lowercased namespace' do - benchmark_subject { described_class.find_with_namespace('sisinmaru/maru') } - - it { is_expected.to iterate_per_second(600) } - end - end -end diff --git a/spec/benchmarks/models/project_team_spec.rb b/spec/benchmarks/models/project_team_spec.rb deleted file mode 100644 index 8b039ef7317..00000000000 --- a/spec/benchmarks/models/project_team_spec.rb +++ /dev/null @@ -1,23 +0,0 @@ -require 'spec_helper' - -describe ProjectTeam, benchmark: true do - describe '#max_member_access' do - let(:group) { create(:group) } - let(:project) { create(:empty_project, group: group) } - let(:user) { create(:user) } - - before do - project.team << [user, :master] - - 5.times do - project.team << [create(:user), :reporter] - - project.group.add_user(create(:user), :reporter) - end - end - - benchmark_subject { project.team.max_member_access(user.id) } - - it { is_expected.to iterate_per_second(35000) } - end -end diff --git a/spec/benchmarks/models/user_spec.rb b/spec/benchmarks/models/user_spec.rb deleted file mode 100644 index 1be7a8d3ed9..00000000000 --- a/spec/benchmarks/models/user_spec.rb +++ /dev/null @@ -1,78 +0,0 @@ -require 'spec_helper' - -describe User, benchmark: true do - describe '.all' do - before do - 10.times { create(:user) } - end - - benchmark_subject { User.all.to_a } - - it { is_expected.to iterate_per_second(500) } - end - - describe '.by_login' do - before do - %w{Alice Bob Eve}.each do |name| - create(:user, - email: "#{name}@gitlab.com", - username: name, - name: name) - end - end - - # The iteration count is based on the query taking little over 1 ms when - # using PostgreSQL. - let(:iterations) { 900 } - - describe 'using a capitalized username' do - benchmark_subject { User.by_login('Alice') } - - it { is_expected.to iterate_per_second(iterations) } - end - - describe 'using a lowercase username' do - benchmark_subject { User.by_login('alice') } - - it { is_expected.to iterate_per_second(iterations) } - end - - describe 'using a capitalized Email address' do - benchmark_subject { User.by_login('Alice@gitlab.com') } - - it { is_expected.to iterate_per_second(iterations) } - end - - describe 'using a lowercase Email address' do - benchmark_subject { User.by_login('alice@gitlab.com') } - - it { is_expected.to iterate_per_second(iterations) } - end - end - - describe '.find_by_any_email' do - let(:user) { create(:user) } - - describe 'using a user with only a single Email address' do - let(:email) { user.email } - - benchmark_subject { User.find_by_any_email(email) } - - it { is_expected.to iterate_per_second(1000) } - end - - describe 'using a user with multiple Email addresses' do - let(:email) { user.emails.first.email } - - benchmark_subject { User.find_by_any_email(email) } - - before do - 10.times do - user.emails.create(email: FFaker::Internet.email) - end - end - - it { is_expected.to iterate_per_second(1000) } - end - end -end diff --git a/spec/benchmarks/services/projects/create_service_spec.rb b/spec/benchmarks/services/projects/create_service_spec.rb deleted file mode 100644 index 25ed48c34fd..00000000000 --- a/spec/benchmarks/services/projects/create_service_spec.rb +++ /dev/null @@ -1,28 +0,0 @@ -require 'spec_helper' - -describe Projects::CreateService, benchmark: true do - describe '#execute' do - let(:user) { create(:user, :admin) } - - let(:group) do - group = create(:group) - - create(:group_member, group: group, user: user) - - group - end - - benchmark_subject do - name = SecureRandom.hex - service = described_class.new(user, - name: name, - path: name, - namespace_id: group.id, - visibility_level: Gitlab::VisibilityLevel::PUBLIC) - - service.execute - end - - it { is_expected.to iterate_per_second(0.5) } - end -end diff --git a/spec/controllers/projects/forks_controller_spec.rb b/spec/controllers/projects/forks_controller_spec.rb index 883bbaedd4e..70ed8f3a62e 100644 --- a/spec/controllers/projects/forks_controller_spec.rb +++ b/spec/controllers/projects/forks_controller_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe Projects::ForksController do let(:user) { create(:user) } - let(:project) { create(:project, visibility_level: Project::PUBLIC) } + let(:project) { create(:project, :public) } let(:forked_project) { Projects::ForkService.new(project, user).execute } let(:group) { create(:group, owner: forked_project.creator) } diff --git a/spec/controllers/projects/imports_controller_spec.rb b/spec/controllers/projects/imports_controller_spec.rb index 0147bd2b953..2acbba469e3 100644 --- a/spec/controllers/projects/imports_controller_spec.rb +++ b/spec/controllers/projects/imports_controller_spec.rb @@ -19,7 +19,7 @@ describe Projects::ImportsController do end it 'sets flash.now if params is present' do - get :show, namespace_id: project.namespace.to_param, project_id: project.to_param, continue: { notice_now: 'Started' } + get :show, namespace_id: project.namespace.to_param, project_id: project.to_param, continue: { to: '/', notice_now: 'Started' } expect(flash.now[:notice]).to eq 'Started' end @@ -45,7 +45,7 @@ describe Projects::ImportsController do end it 'sets flash.now if params is present' do - get :show, namespace_id: project.namespace.to_param, project_id: project.to_param, continue: { notice_now: 'In progress' } + get :show, namespace_id: project.namespace.to_param, project_id: project.to_param, continue: { to: '/', notice_now: 'In progress' } expect(flash.now[:notice]).to eq 'In progress' end diff --git a/spec/controllers/projects/repositories_controller_spec.rb b/spec/controllers/projects/repositories_controller_spec.rb index 09ec4f18f9d..0ddbec9eac2 100644 --- a/spec/controllers/projects/repositories_controller_spec.rb +++ b/spec/controllers/projects/repositories_controller_spec.rb @@ -2,30 +2,41 @@ require "spec_helper" describe Projects::RepositoriesController do let(:project) { create(:project) } - let(:user) { create(:user) } describe "GET archive" do - before do - sign_in(user) - project.team << [user, :developer] - end - - it "uses Gitlab::Workhorse" do - expect(Gitlab::Workhorse).to receive(:send_git_archive).with(project, "master", "zip") + context 'as a guest' do + it 'responds with redirect in correct format' do + get :archive, namespace_id: project.namespace.path, project_id: project.path, format: "zip" - get :archive, namespace_id: project.namespace.path, project_id: project.path, ref: "master", format: "zip" + expect(response.content_type).to start_with 'text/html' + expect(response).to be_redirect + end end - context "when the service raises an error" do + context 'as a user' do + let(:user) { create(:user) } before do - allow(Gitlab::Workhorse).to receive(:send_git_archive).and_raise("Archive failed") + project.team << [user, :developer] + sign_in(user) end + it "uses Gitlab::Workhorse" do + expect(Gitlab::Workhorse).to receive(:send_git_archive).with(project, "master", "zip") - it "renders Not Found" do get :archive, namespace_id: project.namespace.path, project_id: project.path, ref: "master", format: "zip" + end + + context "when the service raises an error" do + + before do + allow(Gitlab::Workhorse).to receive(:send_git_archive).and_raise("Archive failed") + end + + it "renders Not Found" do + get :archive, namespace_id: project.namespace.path, project_id: project.path, ref: "master", format: "zip" - expect(response.status).to eq(404) + expect(response.status).to eq(404) + end end end end diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb index 6eee4dfe229..1893e946f5c 100644 --- a/spec/controllers/projects_controller_spec.rb +++ b/spec/controllers/projects_controller_spec.rb @@ -9,19 +9,6 @@ describe ProjectsController do describe "GET show" do - context "when requested by `go get`" do - render_views - - it "renders the go-import meta tag" do - get :show, "go-get" => "1", namespace_id: "bogus_namespace", id: "bogus_project" - - expect(response.body).to include("name='go-import'") - - content = "localhost/bogus_namespace/bogus_project git http://localhost/bogus_namespace/bogus_project.git" - expect(response.body).to include("content='#{content}'") - end - end - context "rendering default project view" do render_views diff --git a/spec/factories/personal_snippets.rb b/spec/factories/personal_snippets.rb index b493a6968ff..0f13b2c1020 100644 --- a/spec/factories/personal_snippets.rb +++ b/spec/factories/personal_snippets.rb @@ -1,15 +1,4 @@ FactoryGirl.define do factory :personal_snippet, parent: :snippet, class: :PersonalSnippet do - trait :public do - visibility_level PersonalSnippet::PUBLIC - end - - trait :internal do - visibility_level PersonalSnippet::INTERNAL - end - - trait :private do - visibility_level PersonalSnippet::PRIVATE - end end end diff --git a/spec/factories/project_group_links.rb b/spec/factories/project_group_links.rb new file mode 100644 index 00000000000..e73cc05f9d7 --- /dev/null +++ b/spec/factories/project_group_links.rb @@ -0,0 +1,6 @@ +FactoryGirl.define do + factory :project_group_link do + project + group + end +end diff --git a/spec/factories/project_snippets.rb b/spec/factories/project_snippets.rb index 154442bd3db..d681a2c8483 100644 --- a/spec/factories/project_snippets.rb +++ b/spec/factories/project_snippets.rb @@ -1,9 +1,5 @@ FactoryGirl.define do - factory :project_snippet do + factory :project_snippet, parent: :snippet, class: :ProjectSnippet do project - author - title - content - file_name end end diff --git a/spec/factories/snippets.rb b/spec/factories/snippets.rb index b9127b3d75e..365f12a0c95 100644 --- a/spec/factories/snippets.rb +++ b/spec/factories/snippets.rb @@ -12,5 +12,17 @@ FactoryGirl.define do title content file_name + + trait :public do + visibility_level Snippet::PUBLIC + end + + trait :internal do + visibility_level Snippet::INTERNAL + end + + trait :private do + visibility_level Snippet::PRIVATE + end end end diff --git a/spec/features/issues/filter_by_milestone_spec.rb b/spec/features/issues/filter_by_milestone_spec.rb index 591866b40d4..f6e33f651c4 100644 --- a/spec/features/issues/filter_by_milestone_spec.rb +++ b/spec/features/issues/filter_by_milestone_spec.rb @@ -1,8 +1,6 @@ require 'rails_helper' feature 'Issue filtering by Milestone', feature: true do - include Select2Helper - let(:project) { create(:project, :public) } let(:milestone) { create(:milestone, project: project) } @@ -31,6 +29,9 @@ feature 'Issue filtering by Milestone', feature: true do end def filter_by_milestone(title) - select2(title, from: '#milestone_title') + find(".js-milestone-select").click + sleep 0.5 + find(".milestone-filter a", text: title).click + sleep 1 end end diff --git a/spec/features/merge_requests/filter_by_milestone_spec.rb b/spec/features/merge_requests/filter_by_milestone_spec.rb index f70214e1122..1b2fd1bab10 100644 --- a/spec/features/merge_requests/filter_by_milestone_spec.rb +++ b/spec/features/merge_requests/filter_by_milestone_spec.rb @@ -1,8 +1,6 @@ require 'rails_helper' feature 'Merge Request filtering by Milestone', feature: true do - include Select2Helper - let(:project) { create(:project, :public) } let(:milestone) { create(:milestone, project: project) } @@ -31,6 +29,9 @@ feature 'Merge Request filtering by Milestone', feature: true do end def filter_by_milestone(title) - select2(title, from: '#milestone_title') + find(".js-milestone-select").click + sleep 0.5 + find(".milestone-filter a", text: title).click + sleep 1 end end diff --git a/spec/features/notes_on_merge_requests_spec.rb b/spec/features/notes_on_merge_requests_spec.rb index 1a360cd1ebc..d9a8058efd9 100644 --- a/spec/features/notes_on_merge_requests_spec.rb +++ b/spec/features/notes_on_merge_requests_spec.rb @@ -22,7 +22,7 @@ describe 'Comments', feature: true do it 'should be valid' do is_expected.to have_css('.js-main-target-form', visible: true, count: 1) expect(find('.js-main-target-form input[type=submit]').value). - to eq('Add Comment') + to eq('Comment') page.within('.js-main-target-form') do expect(page).not_to have_link('Cancel') end @@ -49,7 +49,7 @@ describe 'Comments', feature: true do page.within('.js-main-target-form') do fill_in 'note[note]', with: 'This is awsome!' find('.js-md-preview-button').click - click_button 'Add Comment' + click_button 'Comment' end end @@ -202,7 +202,7 @@ describe 'Comments', feature: true do before do page.within("tr[id='#{line_code_2}'] + .js-temp-notes-holder") do fill_in 'note[note]', with: 'Another comment on line 10' - click_button('Add Comment') + click_button('Comment') end end diff --git a/spec/finders/projects_finder_spec.rb b/spec/finders/projects_finder_spec.rb index f32641ef0f6..fae0da9d898 100644 --- a/spec/finders/projects_finder_spec.rb +++ b/spec/finders/projects_finder_spec.rb @@ -17,6 +17,10 @@ describe ProjectsFinder do create(:project, :public, group: group, name: 'C', path: 'C') end + let!(:shared_project) do + create(:project, :private, name: 'D', path: 'D') + end + let(:finder) { described_class.new } describe 'without a group' do @@ -56,7 +60,35 @@ describe ProjectsFinder do describe 'with a user' do subject { finder.execute(user, group: group) } - it { is_expected.to eq([public_project, internal_project]) } + describe 'without shared projects' do + it { is_expected.to eq([public_project, internal_project]) } + end + + describe 'with shared projects and group membership' do + before do + group.add_user(user, Gitlab::Access::DEVELOPER) + + shared_project.project_group_links. + create(group_access: Gitlab::Access::MASTER, group: group) + end + + it do + is_expected.to eq([shared_project, public_project, internal_project]) + end + end + + describe 'with shared projects and project membership' do + before do + shared_project.team.add_user(user, Gitlab::Access::DEVELOPER) + + shared_project.project_group_links. + create(group_access: Gitlab::Access::MASTER, group: group) + end + + it do + is_expected.to eq([shared_project, public_project, internal_project]) + end + end end end end diff --git a/spec/finders/snippets_finder_spec.rb b/spec/finders/snippets_finder_spec.rb index 1b4ffc2d717..7fdc5e5d7aa 100644 --- a/spec/finders/snippets_finder_spec.rb +++ b/spec/finders/snippets_finder_spec.rb @@ -5,15 +5,14 @@ describe SnippetsFinder do let(:user1) { create :user } let(:group) { create :group } - let(:project1) { create(:empty_project, :public, group: group) } - let(:project2) { create(:empty_project, :private, group: group) } - + let(:project1) { create(:empty_project, :public, group: group) } + let(:project2) { create(:empty_project, :private, group: group) } context ':all filter' do before do - @snippet1 = create(:personal_snippet, visibility_level: Snippet::PRIVATE) - @snippet2 = create(:personal_snippet, visibility_level: Snippet::INTERNAL) - @snippet3 = create(:personal_snippet, visibility_level: Snippet::PUBLIC) + @snippet1 = create(:personal_snippet, :private) + @snippet2 = create(:personal_snippet, :internal) + @snippet3 = create(:personal_snippet, :public) end it "returns all private and internal snippets" do @@ -31,9 +30,9 @@ describe SnippetsFinder do context ':by_user filter' do before do - @snippet1 = create(:personal_snippet, visibility_level: Snippet::PRIVATE, author: user) - @snippet2 = create(:personal_snippet, visibility_level: Snippet::INTERNAL, author: user) - @snippet3 = create(:personal_snippet, visibility_level: Snippet::PUBLIC, author: user) + @snippet1 = create(:personal_snippet, :private, author: user) + @snippet2 = create(:personal_snippet, :internal, author: user) + @snippet3 = create(:personal_snippet, :public, author: user) end it "returns all public and internal snippets" do @@ -75,9 +74,9 @@ describe SnippetsFinder do context 'by_project filter' do before do - @snippet1 = create(:project_snippet, visibility_level: Snippet::PRIVATE, project: project1) - @snippet2 = create(:project_snippet, visibility_level: Snippet::INTERNAL, project: project1) - @snippet3 = create(:project_snippet, visibility_level: Snippet::PUBLIC, project: project1) + @snippet1 = create(:project_snippet, :private, project: project1) + @snippet2 = create(:project_snippet, :internal, project: project1) + @snippet3 = create(:project_snippet, :public, project: project1) end it "returns public snippets for unauthorized user" do @@ -93,7 +92,7 @@ describe SnippetsFinder do end it "returns all snippets for project members" do - project1.team << [user, :developer] + project1.team << [user, :developer] snippets = SnippetsFinder.new.execute(user, filter: :by_project, project: project1) expect(snippets).to include(@snippet1, @snippet2, @snippet3) end diff --git a/spec/helpers/visibility_level_helper_spec.rb b/spec/helpers/visibility_level_helper_spec.rb index aafc24397a9..cd7596a763d 100644 --- a/spec/helpers/visibility_level_helper_spec.rb +++ b/spec/helpers/visibility_level_helper_spec.rb @@ -58,7 +58,7 @@ describe VisibilityLevelHelper do describe "skip_level?" do describe "forks" do - let(:project) { create(:project, visibility_level: Gitlab::VisibilityLevel::INTERNAL) } + let(:project) { create(:project, :internal) } let(:fork_project) { create(:forked_project_with_submodules) } before do @@ -74,7 +74,7 @@ describe VisibilityLevelHelper do end describe "non-forked project" do - let(:project) { create(:project, visibility_level: Gitlab::VisibilityLevel::INTERNAL) } + let(:project) { create(:project, :internal) } it "skips levels" do expect(skip_level?(project, Gitlab::VisibilityLevel::PUBLIC)).to be_falsey @@ -84,7 +84,7 @@ describe VisibilityLevelHelper do end describe "Snippet" do - let(:snippet) { create(:snippet, visibility_level: Gitlab::VisibilityLevel::INTERNAL) } + let(:snippet) { create(:snippet, :internal) } it "skips levels" do expect(skip_level?(snippet, Gitlab::VisibilityLevel::PUBLIC)).to be_falsey diff --git a/spec/lib/banzai/filter/sanitization_filter_spec.rb b/spec/lib/banzai/filter/sanitization_filter_spec.rb index 4a7b00c7660..27ce312b11c 100644 --- a/spec/lib/banzai/filter/sanitization_filter_spec.rb +++ b/spec/lib/banzai/filter/sanitization_filter_spec.rb @@ -149,10 +149,20 @@ describe Banzai::Filter::SanitizationFilter, lib: true do output: '<a href="java"></a>' }, + 'protocol-based JS injection: invalid URL char' => { + input: '<img src=java\script:alert("XSS")>', + output: '<img>' + }, + 'protocol-based JS injection: spaces and entities' => { input: '<a href="  javascript:alert(\'XSS\');">foo</a>', output: '<a href="">foo</a>' }, + + 'protocol whitespace' => { + input: '<a href=" http://example.com/"></a>', + output: '<a href="http://example.com/"></a>' + } } protocols.each do |name, data| @@ -177,6 +187,16 @@ describe Banzai::Filter::SanitizationFilter, lib: true do expect(output.to_html).to eq '<a>XSS</a>' end + it 'disallows invalid URIs' do + expect(Addressable::URI).to receive(:parse).with('foo://example.com'). + and_raise(Addressable::URI::InvalidURIError) + + input = '<a href="foo://example.com">Foo</a>' + output = filter(input) + + expect(output.to_html).to eq '<a>Foo</a>' + end + it 'allows non-standard anchor schemes' do exp = %q{<a href="irc://irc.freenode.net/git">IRC</a>} act = filter(exp) diff --git a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb index f3394910c5b..fab6412d29f 100644 --- a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb +++ b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb @@ -397,7 +397,7 @@ module Ci services: ["mysql"], before_script: ["pwd"], rspec: { - artifacts: { paths: ["logs/", "binaries/"], untracked: true }, + artifacts: { paths: ["logs/", "binaries/"], untracked: true, name: "custom_name" }, script: "rspec" } }) @@ -417,6 +417,7 @@ module Ci image: "ruby:2.1", services: ["mysql"], artifacts: { + name: "custom_name", paths: ["logs/", "binaries/"], untracked: true } @@ -427,6 +428,112 @@ module Ci end end + describe "Dependencies" do + let(:config) do + { + build1: { stage: 'build', script: 'test' }, + build2: { stage: 'build', script: 'test' }, + test1: { stage: 'test', script: 'test', dependencies: dependencies }, + test2: { stage: 'test', script: 'test' }, + deploy: { stage: 'test', script: 'test' } + } + end + + subject { GitlabCiYamlProcessor.new(YAML.dump(config)) } + + context 'no dependencies' do + let(:dependencies) { } + + it { expect { subject }.to_not raise_error } + end + + context 'dependencies to builds' do + let(:dependencies) { [:build1, :build2] } + + it { expect { subject }.to_not raise_error } + end + + context 'undefined dependency' do + let(:dependencies) { [:undefined] } + + it { expect { subject }.to raise_error(GitlabCiYamlProcessor::ValidationError, 'test1 job: undefined dependency: undefined') } + end + + context 'dependencies to deploy' do + let(:dependencies) { [:deploy] } + + it { expect { subject }.to raise_error(GitlabCiYamlProcessor::ValidationError, 'test1 job: dependency deploy is not defined in prior stages') } + end + end + + describe "Hidden jobs" do + let(:config) do + YAML.dump({ + '.hidden_job' => { script: 'test' }, + 'normal_job' => { script: 'test' } + }) + end + + let(:config_processor) { GitlabCiYamlProcessor.new(config) } + + subject { config_processor.builds_for_stage_and_ref("test", "master") } + + it "doesn't create jobs that starts with dot" do + expect(subject.size).to eq(1) + expect(subject.first).to eq({ + except: nil, + stage: "test", + stage_idx: 1, + name: :normal_job, + only: nil, + commands: "\ntest", + tag_list: [], + options: {}, + when: "on_success", + allow_failure: false + }) + end + end + + describe "YAML Alias/Anchor" do + it "is correctly supported for jobs" do + config = <<EOT +job1: &JOBTMPL + script: execute-script-for-job + +job2: *JOBTMPL +EOT + + config_processor = GitlabCiYamlProcessor.new(config) + + expect(config_processor.builds_for_stage_and_ref("test", "master").size).to eq(2) + expect(config_processor.builds_for_stage_and_ref("test", "master").first).to eq({ + except: nil, + stage: "test", + stage_idx: 1, + name: :job1, + only: nil, + commands: "\nexecute-script-for-job", + tag_list: [], + options: {}, + when: "on_success", + allow_failure: false + }) + expect(config_processor.builds_for_stage_and_ref("test", "master").second).to eq({ + except: nil, + stage: "test", + stage_idx: 1, + name: :job2, + only: nil, + commands: "\nexecute-script-for-job", + tag_list: [], + options: {}, + when: "on_success", + allow_failure: false + }) + end + end + describe "Error handling" do it "fails to parse YAML" do expect{GitlabCiYamlProcessor.new("invalid: yaml: test")}.to raise_error(Psych::SyntaxError) @@ -590,6 +697,13 @@ module Ci end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: when parameter should be on_success, on_failure or always") end + it "returns errors if job artifacts:name is not an a string" do + config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", artifacts: { name: 1 } } }) + expect do + GitlabCiYamlProcessor.new(config) + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: artifacts:name parameter should be a string") + end + it "returns errors if job artifacts:untracked is not an array of strings" do config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", artifacts: { untracked: "string" } } }) expect do @@ -645,6 +759,13 @@ module Ci GitlabCiYamlProcessor.new(config) end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: cache:paths parameter should be an array of strings") end + + it "returns errors if job dependencies is not an array of strings" do + config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", dependencies: "string" } }) + expect do + GitlabCiYamlProcessor.new(config) + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: dependencies parameter should be an array of strings") + end end end end diff --git a/spec/lib/gitlab/bitbucket_import/importer_spec.rb b/spec/lib/gitlab/bitbucket_import/importer_spec.rb new file mode 100644 index 00000000000..c413132abe5 --- /dev/null +++ b/spec/lib/gitlab/bitbucket_import/importer_spec.rb @@ -0,0 +1,88 @@ +require 'spec_helper' + +describe Gitlab::BitbucketImport::Importer, lib: true do + before do + Gitlab.config.omniauth.providers << OpenStruct.new(app_id: "asd123", app_secret: "asd123", name: "bitbucket") + end + + let(:statuses) do + [ + "open", + "resolved", + "on hold", + "invalid", + "duplicate", + "wontfix", + "closed" # undocumented status + ] + end + let(:sample_issues_statuses) do + issues = [] + + statuses.map.with_index do |status, index| + issues << { + local_id: index, + status: status, + title: "Issue #{index}", + content: "Some content to issue #{index}" + } + end + + issues + end + + let(:project_identifier) { 'namespace/repo' } + let(:data) do + { + bb_session: { + bitbucket_access_token: "123456", + bitbucket_access_token_secret: "secret" + } + } + end + let(:project) do + create( + :project, + import_source: project_identifier, + import_data: ProjectImportData.new(data: data) + ) + end + let(:importer) { Gitlab::BitbucketImport::Importer.new(project) } + let(:issues_statuses_sample_data) do + { + count: sample_issues_statuses.count, + issues: sample_issues_statuses + } + end + + context 'issues statuses' do + before do + stub_request( + :get, + "https://bitbucket.org/api/1.0/repositories/#{project_identifier}" + ).to_return(status: 200, body: { has_issues: true }.to_json) + + stub_request( + :get, + "https://bitbucket.org/api/1.0/repositories/#{project_identifier}/issues?limit=50&sort=utc_created_on&start=0" + ).to_return(status: 200, body: issues_statuses_sample_data.to_json) + + sample_issues_statuses.each_with_index do |issue, index| + stub_request( + :get, + "https://bitbucket.org/api/1.0/repositories/#{project_identifier}/issues/#{issue[:local_id]}/comments" + ).to_return( + status: 200, + body: [{ author_info: { username: "username" }, utc_created_on: index }].to_json + ) + end + end + + it 'map statuses to open or closed' do + importer.execute + + expect(project.issues.where(state: "closed").size).to eq(5) + expect(project.issues.where(state: "opened").size).to eq(2) + end + end +end diff --git a/spec/lib/gitlab/exclusive_lease_spec.rb b/spec/lib/gitlab/exclusive_lease_spec.rb new file mode 100644 index 00000000000..fbdb7ea34ac --- /dev/null +++ b/spec/lib/gitlab/exclusive_lease_spec.rb @@ -0,0 +1,21 @@ +require 'spec_helper' + +describe Gitlab::ExclusiveLease do + it 'cannot obtain twice before the lease has expired' do + lease = Gitlab::ExclusiveLease.new(unique_key, timeout: 3600) + expect(lease.try_obtain).to eq(true) + expect(lease.try_obtain).to eq(false) + end + + it 'can obtain after the lease has expired' do + timeout = 1 + lease = Gitlab::ExclusiveLease.new(unique_key, timeout: timeout) + lease.try_obtain # start the lease + sleep(2 * timeout) # lease should have expired now + expect(lease.try_obtain).to eq(true) + end + + def unique_key + SecureRandom.hex(10) + end +end diff --git a/spec/lib/gitlab/github_import/pull_request_formatter_spec.rb b/spec/lib/gitlab/github_import/pull_request_formatter_spec.rb index 6cebcb5009a..e49dcb42342 100644 --- a/spec/lib/gitlab/github_import/pull_request_formatter_spec.rb +++ b/spec/lib/gitlab/github_import/pull_request_formatter_spec.rb @@ -127,34 +127,6 @@ describe Gitlab::GithubImport::PullRequestFormatter, lib: true do end end - describe '#cross_project?' do - context 'when source, and target repositories are the same' do - let(:raw_data) { OpenStruct.new(base_data) } - - it 'returns false' do - expect(pull_request.cross_project?).to eq false - end - end - - context 'when source repo is a fork' do - let(:source_repo) { OpenStruct.new(id: 2, fork: true) } - let(:raw_data) { OpenStruct.new(base_data) } - - it 'returns true' do - expect(pull_request.cross_project?).to eq true - end - end - - context 'when target repo is a fork' do - let(:target_repo) { OpenStruct.new(id: 2, fork: true) } - let(:raw_data) { OpenStruct.new(base_data) } - - it 'returns true' do - expect(pull_request.cross_project?).to eq true - end - end - end - describe '#number' do let(:raw_data) { OpenStruct.new(base_data.merge(number: 1347)) } @@ -166,24 +138,44 @@ describe Gitlab::GithubImport::PullRequestFormatter, lib: true do describe '#valid?' do let(:invalid_branch) { OpenStruct.new(ref: 'invalid-branch') } - context 'when source and target branches exists' do - let(:raw_data) { OpenStruct.new(base_data.merge(head: source_branch, base: target_branch)) } + context 'when source, and target repositories are the same' do + context 'and source and target branches exists' do + let(:raw_data) { OpenStruct.new(base_data.merge(head: source_branch, base: target_branch)) } - it 'returns true' do - expect(pull_request.valid?).to eq true + it 'returns true' do + expect(pull_request.valid?).to eq true + end + end + + context 'and source branch doesn not exists' do + let(:raw_data) { OpenStruct.new(base_data.merge(head: invalid_branch, base: target_branch)) } + + it 'returns false' do + expect(pull_request.valid?).to eq false + end + end + + context 'and target branch doesn not exists' do + let(:raw_data) { OpenStruct.new(base_data.merge(head: source_branch, base: invalid_branch)) } + + it 'returns false' do + expect(pull_request.valid?).to eq false + end end end - context 'when source branch doesn not exists' do - let(:raw_data) { OpenStruct.new(base_data.merge(head: invalid_branch, base: target_branch)) } + context 'when source repo is a fork' do + let(:source_repo) { OpenStruct.new(id: 2, fork: true) } + let(:raw_data) { OpenStruct.new(base_data) } it 'returns false' do expect(pull_request.valid?).to eq false end end - context 'when target branch doesn not exists' do - let(:raw_data) { OpenStruct.new(base_data.merge(head: source_branch, base: invalid_branch)) } + context 'when target repo is a fork' do + let(:target_repo) { OpenStruct.new(id: 2, fork: true) } + let(:raw_data) { OpenStruct.new(base_data) } it 'returns false' do expect(pull_request.valid?).to eq false diff --git a/spec/lib/gitlab/middleware/go_spec.rb b/spec/lib/gitlab/middleware/go_spec.rb new file mode 100644 index 00000000000..117a15264da --- /dev/null +++ b/spec/lib/gitlab/middleware/go_spec.rb @@ -0,0 +1,30 @@ +require 'spec_helper' + +describe Gitlab::Middleware::Go, lib: true do + let(:app) { double(:app) } + let(:middleware) { described_class.new(app) } + + describe '#call' do + describe 'when go-get=0' do + it 'skips go-import generation' do + env = { 'rack.input' => '', + 'QUERY_STRING' => 'go-get=0' } + expect(app).to receive(:call).with(env).and_return('no-go') + middleware.call(env) + end + end + + describe 'when go-get=1' do + it 'returns a document' do + env = { 'rack.input' => '', + 'QUERY_STRING' => 'go-get=1', + 'PATH_INFO' => '/group/project/path' } + resp = middleware.call(env) + expect(resp[0]).to eq(200) + expect(resp[1]['Content-Type']).to eq('text/html') + expected_body = "<!DOCTYPE html><html><head><meta content='localhost/group/project git http://localhost/group/project.git' name='go-import'></head></html>\n" + expect(resp[2].body).to eq([expected_body]) + end + end + end +end diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb index efc2e5f4ef1..09adbc07dcb 100644 --- a/spec/lib/gitlab/project_search_results_spec.rb +++ b/spec/lib/gitlab/project_search_results_spec.rb @@ -5,7 +5,7 @@ describe Gitlab::ProjectSearchResults, lib: true do let(:query) { 'hello world' } describe 'initialize with empty ref' do - let(:results) { Gitlab::ProjectSearchResults.new(project.id, query, '') } + let(:results) { Gitlab::ProjectSearchResults.new(project, query, '') } it { expect(results.project).to eq(project) } it { expect(results.repository_ref).to be_nil } @@ -14,7 +14,7 @@ describe Gitlab::ProjectSearchResults, lib: true do describe 'initialize with ref' do let(:ref) { 'refs/heads/test' } - let(:results) { Gitlab::ProjectSearchResults.new(project.id, query, ref) } + let(:results) { Gitlab::ProjectSearchResults.new(project, query, ref) } it { expect(results.project).to eq(project) } it { expect(results.repository_ref).to eq(ref) } diff --git a/spec/lib/gitlab/search_results_spec.rb b/spec/lib/gitlab/search_results_spec.rb new file mode 100644 index 00000000000..bb18f417858 --- /dev/null +++ b/spec/lib/gitlab/search_results_spec.rb @@ -0,0 +1,55 @@ +require 'spec_helper' + +describe Gitlab::SearchResults do + let!(:project) { create(:project, name: 'foo') } + let!(:issue) { create(:issue, project: project, title: 'foo') } + + let!(:merge_request) do + create(:merge_request, source_project: project, title: 'foo') + end + + let!(:milestone) { create(:milestone, project: project, title: 'foo') } + let(:results) { described_class.new(Project.all, 'foo') } + + describe '#total_count' do + it 'returns the total amount of search hits' do + expect(results.total_count).to eq(4) + end + end + + describe '#projects_count' do + it 'returns the total amount of projects' do + expect(results.projects_count).to eq(1) + end + end + + describe '#issues_count' do + it 'returns the total amount of issues' do + expect(results.issues_count).to eq(1) + end + end + + describe '#merge_requests_count' do + it 'returns the total amount of merge requests' do + expect(results.merge_requests_count).to eq(1) + end + end + + describe '#milestones_count' do + it 'returns the total amount of milestones' do + expect(results.milestones_count).to eq(1) + end + end + + describe '#empty?' do + it 'returns true when there are no search results' do + allow(results).to receive(:total_count).and_return(0) + + expect(results.empty?).to eq(true) + end + + it 'returns false when there are search results' do + expect(results.empty?).to eq(false) + end + end +end diff --git a/spec/lib/gitlab/snippet_search_results_spec.rb b/spec/lib/gitlab/snippet_search_results_spec.rb new file mode 100644 index 00000000000..e86b9ef6a63 --- /dev/null +++ b/spec/lib/gitlab/snippet_search_results_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +describe Gitlab::SnippetSearchResults do + let!(:snippet) { create(:snippet, content: 'foo', file_name: 'foo') } + + let(:results) { described_class.new(Snippet.all, 'foo') } + + describe '#total_count' do + it 'returns the total amount of search hits' do + expect(results.total_count).to eq(2) + end + end + + describe '#snippet_titles_count' do + it 'returns the amount of matched snippet titles' do + expect(results.snippet_titles_count).to eq(1) + end + end + + describe '#snippet_blobs_count' do + it 'returns the amount of matched snippet blobs' do + expect(results.snippet_blobs_count).to eq(1) + end + end +end diff --git a/spec/mailers/emails/profile_spec.rb b/spec/mailers/emails/profile_spec.rb index 5b575da34f3..c6758ccad39 100644 --- a/spec/mailers/emails/profile_spec.rb +++ b/spec/mailers/emails/profile_spec.rb @@ -11,7 +11,7 @@ describe Notify do let(:example_site_path) { root_path } let(:new_user) { create(:user, email: new_user_address, created_by_id: 1) } let(:token) { 'kETLwRaayvigPq_x3SNM' } - + subject { Notify.new_user_email(new_user.id, token) } it_behaves_like 'an email sent from GitLab' @@ -77,6 +77,10 @@ describe Notify do it 'includes a link to ssh keys page' do is_expected.to have_body_text /#{profile_keys_path}/ end + + context 'with SSH key that does not exist' do + it { expect { Notify.new_ssh_key_email('foo') }.not_to raise_error } + end end describe 'user added email' do diff --git a/spec/models/build_spec.rb b/spec/models/build_spec.rb index e3d3d453653..b7457808040 100644 --- a/spec/models/build_spec.rb +++ b/spec/models/build_spec.rb @@ -9,7 +9,7 @@ describe Ci::Build, models: true do it { is_expected.to respond_to :trace_html } - describe :first_pending do + describe '#first_pending' do let(:first) { FactoryGirl.create :ci_build, commit: commit, status: 'pending', created_at: Date.yesterday } let(:second) { FactoryGirl.create :ci_build, commit: commit, status: 'pending' } before { first; second } @@ -19,7 +19,7 @@ describe Ci::Build, models: true do it('returns with the first pending build') { is_expected.to eq(first) } end - describe :create_from do + describe '#create_from' do before do build.status = 'success' build.save @@ -33,7 +33,7 @@ describe Ci::Build, models: true do end end - describe :ignored? do + describe '#ignored?' do subject { build.ignored? } context 'if build is not allowed to fail' do @@ -69,7 +69,7 @@ describe Ci::Build, models: true do end end - describe :trace do + describe '#trace' do subject { build.trace_html } it { is_expected.to be_empty } @@ -101,7 +101,7 @@ describe Ci::Build, models: true do # it { is_expected.to eq(commit.project.timeout) } # end - describe :options do + describe '#options' do let(:options) do { image: "ruby:2.1", @@ -122,25 +122,25 @@ describe Ci::Build, models: true do # it { is_expected.to eq(project.allow_git_fetch) } # end - describe :project do + describe '#project' do subject { build.project } it { is_expected.to eq(commit.project) } end - describe :project_id do + describe '#project_id' do subject { build.project_id } it { is_expected.to eq(commit.project_id) } end - describe :project_name do + describe '#project_name' do subject { build.project_name } it { is_expected.to eq(project.name) } end - describe :extract_coverage do + describe '#extract_coverage' do context 'valid content & regex' do subject { build.extract_coverage('Coverage 1033 / 1051 LOC (98.29%) covered', '\(\d+.\d+\%\) covered') } @@ -172,7 +172,7 @@ describe Ci::Build, models: true do end end - describe :variables do + describe '#variables' do context 'returns variables' do subject { build.variables } @@ -242,7 +242,7 @@ describe Ci::Build, models: true do end end - describe :can_be_served? do + describe '#can_be_served?' do let(:runner) { FactoryGirl.create :ci_runner } before { build.project.runners << runner } @@ -277,7 +277,7 @@ describe Ci::Build, models: true do end end - describe :any_runners_online? do + describe '#any_runners_online?' do subject { build.any_runners_online? } context 'when no runners' do @@ -312,8 +312,8 @@ describe Ci::Build, models: true do end end - describe :show_warning? do - subject { build.show_warning? } + describe '#stuck?' do + subject { build.stuck? } %w(pending).each do |state| context "if commit_status.status is #{state}" do @@ -343,35 +343,7 @@ describe Ci::Build, models: true do end end - describe :artifacts_download_url do - subject { build.artifacts_download_url } - - context 'artifacts file does not exist' do - before { build.update_attributes(artifacts_file: nil) } - it { is_expected.to be_nil } - end - - context 'artifacts file exists' do - let(:build) { create(:ci_build, :artifacts) } - it { is_expected.to_not be_nil } - end - end - - describe :artifacts_browse_url do - subject { build.artifacts_browse_url } - - it "should be nil if artifacts browser is unsupported" do - allow(build).to receive(:artifacts_metadata?).and_return(false) - is_expected.to be_nil - end - - it 'should not be nil if artifacts browser is supported' do - allow(build).to receive(:artifacts_metadata?).and_return(true) - is_expected.to_not be_nil - end - end - - describe :artifacts? do + describe '#artifacts?' do subject { build.artifacts? } context 'artifacts archive does not exist' do @@ -386,7 +358,7 @@ describe Ci::Build, models: true do end - describe :artifacts_metadata? do + describe '#artifacts_metadata?' do subject { build.artifacts_metadata? } context 'artifacts metadata does not exist' do it { is_expected.to be_falsy } @@ -398,7 +370,7 @@ describe Ci::Build, models: true do end end - describe :repo_url do + describe '#repo_url' do let(:build) { FactoryGirl.create :ci_build } let(:project) { build.project } @@ -412,7 +384,7 @@ describe Ci::Build, models: true do it { is_expected.to include(project.web_url[7..-1]) } end - describe :depends_on_builds do + describe '#depends_on_builds' do let!(:build) { FactoryGirl.create :ci_build, commit: commit, name: 'build', stage_idx: 0, stage: 'build' } let!(:rspec_test) { FactoryGirl.create :ci_build, commit: commit, name: 'rspec', stage_idx: 1, stage: 'test' } let!(:rubocop_test) { FactoryGirl.create :ci_build, commit: commit, name: 'rubocop', stage_idx: 1, stage: 'test' } @@ -444,7 +416,7 @@ describe Ci::Build, models: true do created_at: created_at) end - describe :merge_request do + describe '#merge_request' do context 'when a MR has a reference to the commit' do before do @merge_request = create_mr(build, commit, factory: :merge_request) diff --git a/spec/models/ci/commit_spec.rb b/spec/models/ci/commit_spec.rb index 4dc309a4255..412842337ba 100644 --- a/spec/models/ci/commit_spec.rb +++ b/spec/models/ci/commit_spec.rb @@ -32,50 +32,6 @@ describe Ci::Commit, models: true do it { is_expected.to respond_to :git_author_email } it { is_expected.to respond_to :short_sha } - describe :ordered do - let(:project) { FactoryGirl.create :empty_project } - - it 'returns ordered list of commits' do - commit1 = FactoryGirl.create :ci_commit, committed_at: 1.hour.ago, project: project - commit2 = FactoryGirl.create :ci_commit, committed_at: 2.hours.ago, project: project - expect(project.ci_commits.ordered).to eq([commit2, commit1]) - end - - it 'returns commits ordered by committed_at and id, with nulls last' do - commit1 = FactoryGirl.create :ci_commit, committed_at: 1.hour.ago, project: project - commit2 = FactoryGirl.create :ci_commit, committed_at: nil, project: project - commit3 = FactoryGirl.create :ci_commit, committed_at: 2.hours.ago, project: project - commit4 = FactoryGirl.create :ci_commit, committed_at: nil, project: project - expect(project.ci_commits.ordered).to eq([commit2, commit4, commit3, commit1]) - end - end - - describe :last_build do - subject { commit.last_build } - before do - @first = FactoryGirl.create :ci_build, commit: commit, created_at: Date.yesterday - @second = FactoryGirl.create :ci_build, commit: commit - end - - it { is_expected.to be_a(Ci::Build) } - it('returns with the most recently created build') { is_expected.to eq(@second) } - end - - describe :retry do - before do - @first = FactoryGirl.create :ci_build, commit: commit, created_at: Date.yesterday - @second = FactoryGirl.create :ci_build, commit: commit - end - - it "creates only a new build" do - expect(commit.builds.count(:all)).to eq 2 - expect(commit.statuses.count(:all)).to eq 2 - commit.retry - expect(commit.builds.count(:all)).to eq 3 - expect(commit.statuses.count(:all)).to eq 3 - end - end - describe :valid_commit_sha do context 'commit.sha can not start with 00000000' do before do diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb index e891838672e..25e9e5eca48 100644 --- a/spec/models/ci/runner_spec.rb +++ b/spec/models/ci/runner_spec.rb @@ -132,4 +132,32 @@ describe Ci::Runner, models: true do expect(runner.belongs_to_one_project?).to be_truthy end end + + describe '#search' do + let(:runner) { create(:ci_runner, token: '123abc') } + + it 'returns runners with a matching token' do + expect(described_class.search(runner.token)).to eq([runner]) + end + + it 'returns runners with a partially matching token' do + expect(described_class.search(runner.token[0..2])).to eq([runner]) + end + + it 'returns runners with a matching token regardless of the casing' do + expect(described_class.search(runner.token.upcase)).to eq([runner]) + end + + it 'returns runners with a matching description' do + expect(described_class.search(runner.description)).to eq([runner]) + end + + it 'returns runners with a partially matching description' do + expect(described_class.search(runner.description[0..2])).to eq([runner]) + end + + it 'returns runners with a matching description regardless of the casing' do + expect(described_class.search(runner.description.upcase)).to eq([runner]) + end + end end diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index 600089802b2..aff384c2949 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -32,9 +32,54 @@ describe Issue, "Issuable" do describe ".search" do let!(:searchable_issue) { create(:issue, title: "Searchable issue") } - it "matches by title" do + it 'returns notes with a matching title' do + expect(described_class.search(searchable_issue.title)). + to eq([searchable_issue]) + end + + it 'returns notes with a partially matching title' do expect(described_class.search('able')).to eq([searchable_issue]) end + + it 'returns notes with a matching title regardless of the casing' do + expect(described_class.search(searchable_issue.title.upcase)). + to eq([searchable_issue]) + end + end + + describe ".full_search" do + let!(:searchable_issue) do + create(:issue, title: "Searchable issue", description: 'kittens') + end + + it 'returns notes with a matching title' do + expect(described_class.full_search(searchable_issue.title)). + to eq([searchable_issue]) + end + + it 'returns notes with a partially matching title' do + expect(described_class.full_search('able')).to eq([searchable_issue]) + end + + it 'returns notes with a matching title regardless of the casing' do + expect(described_class.full_search(searchable_issue.title.upcase)). + to eq([searchable_issue]) + end + + it 'returns notes with a matching description' do + expect(described_class.full_search(searchable_issue.description)). + to eq([searchable_issue]) + end + + it 'returns notes with a partially matching description' do + expect(described_class.full_search(searchable_issue.description)). + to eq([searchable_issue]) + end + + it 'returns notes with a matching description regardless of the casing' do + expect(described_class.full_search(searchable_issue.description.upcase)). + to eq([searchable_issue]) + end end describe "#today?" do diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 3c995053eec..c9245fc9535 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -103,4 +103,30 @@ describe Group, models: true do expect(group.avatar_type).to eq(["only images allowed"]) end end + + describe '.search' do + it 'returns groups with a matching name' do + expect(described_class.search(group.name)).to eq([group]) + end + + it 'returns groups with a partially matching name' do + expect(described_class.search(group.name[0..2])).to eq([group]) + end + + it 'returns groups with a matching name regardless of the casing' do + expect(described_class.search(group.name.upcase)).to eq([group]) + end + + it 'returns groups with a matching path' do + expect(described_class.search(group.path)).to eq([group]) + end + + it 'returns groups with a partially matching path' do + expect(described_class.search(group.path[0..2])).to eq([group]) + end + + it 'returns groups with a matching path regardless of the casing' do + expect(described_class.search(group.path.upcase)).to eq([group]) + end + end end diff --git a/spec/models/hooks/service_hook_spec.rb b/spec/models/hooks/service_hook_spec.rb index 1455661485b..f800f415bd2 100644 --- a/spec/models/hooks/service_hook_spec.rb +++ b/spec/models/hooks/service_hook_spec.rb @@ -31,7 +31,7 @@ describe ServiceHook, models: true do WebMock.stub_request(:post, @service_hook.url) end - it "POSTs to the web hook URL" do + it "POSTs to the webhook URL" do @service_hook.execute(@data) expect(WebMock).to have_requested(:post, @service_hook.url).with( headers: { 'Content-Type'=>'application/json', 'X-Gitlab-Event'=>'Service Hook' } diff --git a/spec/models/hooks/web_hook_spec.rb b/spec/models/hooks/web_hook_spec.rb index 6ea99952a8f..04bc2dcfb16 100644 --- a/spec/models/hooks/web_hook_spec.rb +++ b/spec/models/hooks/web_hook_spec.rb @@ -52,7 +52,7 @@ describe WebHook, models: true do WebMock.stub_request(:post, @project_hook.url) end - it "POSTs to the web hook URL" do + it "POSTs to the webhook URL" do @project_hook.execute(@data, 'push_hooks') expect(WebMock).to have_requested(:post, @project_hook.url).with( headers: { 'Content-Type'=>'application/json', 'X-Gitlab-Event'=>'Push Hook' } diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index 52271c7c8c6..7f44ca2f7db 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -105,6 +105,31 @@ describe Issue, models: true do end end + describe '#referenced_merge_requests' do + it 'returns the referenced merge requests' do + project = create(:project, :public) + + mr1 = create(:merge_request, + source_project: project, + source_branch: 'master', + target_branch: 'feature') + + mr2 = create(:merge_request, + source_project: project, + source_branch: 'feature', + target_branch: 'master') + + issue = create(:issue, description: mr1.to_reference, project: project) + + create(:note_on_issue, + noteable: issue, + note: mr2.to_reference, + project_id: project.id) + + expect(issue.referenced_merge_requests).to eq([mr1, mr2]) + end + end + it_behaves_like 'an editable mentionable' do subject { create(:issue) } diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 59c40922abb..8bf68013fd2 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -80,6 +80,12 @@ describe MergeRequest, models: true do it { is_expected.to respond_to(:merge_when_build_succeeds) } end + describe '.in_projects' do + it 'returns the merge requests for a set of projects' do + expect(described_class.in_projects(Project.all)).to eq([subject]) + end + end + describe '#to_reference' do it 'returns a String reference to the object' do expect(subject.to_reference).to eq "!#{subject.iid}" diff --git a/spec/models/milestone_spec.rb b/spec/models/milestone_spec.rb index 28f13100d15..de1757bf67a 100644 --- a/spec/models/milestone_spec.rb +++ b/spec/models/milestone_spec.rb @@ -181,4 +181,34 @@ describe Milestone, models: true do expect(issue4.position).to eq(42) end end + + describe '.search' do + let(:milestone) { create(:milestone, title: 'foo', description: 'bar') } + + it 'returns milestones with a matching title' do + expect(described_class.search(milestone.title)).to eq([milestone]) + end + + it 'returns milestones with a partially matching title' do + expect(described_class.search(milestone.title[0..2])).to eq([milestone]) + end + + it 'returns milestones with a matching title regardless of the casing' do + expect(described_class.search(milestone.title.upcase)).to eq([milestone]) + end + + it 'returns milestones with a matching description' do + expect(described_class.search(milestone.description)).to eq([milestone]) + end + + it 'returns milestones with a partially matching description' do + expect(described_class.search(milestone.description[0..2])). + to eq([milestone]) + end + + it 'returns milestones with a matching description regardless of the casing' do + expect(described_class.search(milestone.description.upcase)). + to eq([milestone]) + end + end end diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index e0b3290e416..3c3a580942a 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -41,13 +41,32 @@ describe Namespace, models: true do it { expect(namespace.human_name).to eq(namespace.owner_name) } end - describe :search do - before do - @namespace = create :namespace + describe '.search' do + let(:namespace) { create(:namespace) } + + it 'returns namespaces with a matching name' do + expect(described_class.search(namespace.name)).to eq([namespace]) + end + + it 'returns namespaces with a partially matching name' do + expect(described_class.search(namespace.name[0..2])).to eq([namespace]) + end + + it 'returns namespaces with a matching name regardless of the casing' do + expect(described_class.search(namespace.name.upcase)).to eq([namespace]) + end + + it 'returns namespaces with a matching path' do + expect(described_class.search(namespace.path)).to eq([namespace]) end - it { expect(Namespace.search(@namespace.path)).to eq([@namespace]) } - it { expect(Namespace.search('unknown')).to eq([]) } + it 'returns namespaces with a partially matching path' do + expect(described_class.search(namespace.path[0..2])).to eq([namespace]) + end + + it 'returns namespaces with a matching path regardless of the casing' do + expect(described_class.search(namespace.path.upcase)).to eq([namespace]) + end end describe :move_dir do diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb index 33085dac4ea..6b18936edb1 100644 --- a/spec/models/note_spec.rb +++ b/spec/models/note_spec.rb @@ -140,13 +140,19 @@ describe Note, models: true do end end - describe :search do - let!(:note) { create(:note, note: "WoW") } + describe '.search' do + let(:note) { create(:note, note: 'WoW') } - it { expect(Note.search('wow')).to include(note) } + it 'returns notes with matching content' do + expect(described_class.search(note.note)).to eq([note]) + end + + it 'returns notes with matching content regardless of the casing' do + expect(described_class.search('WOW')).to eq([note]) + end end - describe :grouped_awards do + describe '.grouped_awards' do before do create :note, note: "smile", is_award: true create :note, note: "smile", is_award: true @@ -163,6 +169,66 @@ describe Note, models: true do end end + describe '#active?' do + it 'is always true when the note has no associated diff' do + note = build(:note) + + expect(note).to receive(:diff).and_return(nil) + + expect(note).to be_active + end + + it 'is never true when the note has no noteable associated' do + note = build(:note) + + expect(note).to receive(:diff).and_return(double) + expect(note).to receive(:noteable).and_return(nil) + + expect(note).not_to be_active + end + + it 'returns the memoized value if defined' do + note = build(:note) + + expect(note).to receive(:diff).and_return(double) + expect(note).to receive(:noteable).and_return(double) + + note.instance_variable_set(:@active, 'foo') + expect(note).not_to receive(:find_noteable_diff) + + expect(note.active?).to eq 'foo' + end + + context 'for a merge request noteable' do + it 'is false when noteable has no matching diff' do + merge = build_stubbed(:merge_request, :simple) + note = build(:note, noteable: merge) + + allow(note).to receive(:diff).and_return(double) + expect(note).to receive(:find_noteable_diff).and_return(nil) + + expect(note).not_to be_active + end + + it 'is true when noteable has a matching diff' do + merge = create(:merge_request, :simple) + + # Generate a real line_code value so we know it will match. We use a + # random line from a random diff just for funsies. + diff = merge.diffs.to_a.sample + line = Gitlab::Diff::Parser.new.parse(diff.diff.each_line).to_a.sample + code = Gitlab::Diff::LineCode.generate(diff.new_path, line.new_pos, line.old_pos) + + # We're persisting in order to trigger the set_diff callback + note = create(:note, noteable: merge, line_code: code) + + # Make sure we don't get a false positive from a guard clause + expect(note).to receive(:find_noteable_diff).and_call_original + expect(note).to be_active + end + end + end + describe "editable?" do it "returns true" do note = build(:note) @@ -220,4 +286,12 @@ describe Note, models: true do expect(note.is_award?).to be_falsy end end + + describe 'clear_blank_line_code!' do + it 'clears a blank line code before validation' do + note = build(:note, line_code: ' ') + + expect { note.valid? }.to change(note, :line_code).to(nil) + end + end end diff --git a/spec/models/project_group_link_spec.rb b/spec/models/project_group_link_spec.rb new file mode 100644 index 00000000000..2fa6715fcaf --- /dev/null +++ b/spec/models/project_group_link_spec.rb @@ -0,0 +1,17 @@ +require 'spec_helper' + +describe ProjectGroupLink do + describe "Associations" do + it { should belong_to(:group) } + it { should belong_to(:project) } + end + + describe "Validation" do + let!(:project_group_link) { create(:project_group_link) } + + it { should validate_presence_of(:project_id) } + it { should validate_uniqueness_of(:group_id).scoped_to(:project_id).with_message(/already shared/) } + it { should validate_presence_of(:group_id) } + it { should validate_presence_of(:group_access) } + end +end diff --git a/spec/models/project_snippet_spec.rb b/spec/models/project_snippet_spec.rb index cc92eb0bd9f..e0feb606f78 100644 --- a/spec/models/project_snippet_spec.rb +++ b/spec/models/project_snippet_spec.rb @@ -10,7 +10,6 @@ # created_at :datetime # updated_at :datetime # file_name :string(255) -# expires_at :datetime # type :string(255) # visibility_level :integer default(0), not null # diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index f9842d23afa..59c5ffa6b9c 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -68,6 +68,7 @@ describe Project, models: true do it { is_expected.to have_many(:runners) } it { is_expected.to have_many(:variables) } it { is_expected.to have_many(:triggers) } + it { is_expected.to have_many(:todos).dependent(:destroy) } end describe 'modules' do @@ -561,7 +562,7 @@ describe Project, models: true do end describe '#visibility_level_allowed?' do - let(:project) { create :project, visibility_level: Gitlab::VisibilityLevel::INTERNAL } + let(:project) { create(:project, :internal) } context 'when checking on non-forked project' do it { expect(project.visibility_level_allowed?(Gitlab::VisibilityLevel::PRIVATE)).to be_truthy } @@ -581,7 +582,58 @@ describe Project, models: true do it { expect(forked_project.visibility_level_allowed?(Gitlab::VisibilityLevel::INTERNAL)).to be_truthy } it { expect(forked_project.visibility_level_allowed?(Gitlab::VisibilityLevel::PUBLIC)).to be_falsey } end + end + + describe '.search' do + let(:project) { create(:project, description: 'kitten mittens') } + it 'returns projects with a matching name' do + expect(described_class.search(project.name)).to eq([project]) + end + + it 'returns projects with a partially matching name' do + expect(described_class.search(project.name[0..2])).to eq([project]) + end + + it 'returns projects with a matching name regardless of the casing' do + expect(described_class.search(project.name.upcase)).to eq([project]) + end + + it 'returns projects with a matching description' do + expect(described_class.search(project.description)).to eq([project]) + end + + it 'returns projects with a partially matching description' do + expect(described_class.search('kitten')).to eq([project]) + end + + it 'returns projects with a matching description regardless of the casing' do + expect(described_class.search('KITTEN')).to eq([project]) + end + + it 'returns projects with a matching path' do + expect(described_class.search(project.path)).to eq([project]) + end + + it 'returns projects with a partially matching path' do + expect(described_class.search(project.path[0..2])).to eq([project]) + end + + it 'returns projects with a matching path regardless of the casing' do + expect(described_class.search(project.path.upcase)).to eq([project]) + end + + it 'returns projects with a matching namespace name' do + expect(described_class.search(project.namespace.name)).to eq([project]) + end + + it 'returns projects with a partially matching namespace name' do + expect(described_class.search(project.namespace.name[0..2])).to eq([project]) + end + + it 'returns projects with a matching namespace name regardless of the casing' do + expect(described_class.search(project.namespace.name.upcase)).to eq([project]) + end end describe '#rename_repo' do @@ -646,4 +698,20 @@ describe Project, models: true do project.expire_caches_before_rename('foo') end end + + describe '.search_by_title' do + let(:project) { create(:project, name: 'kittens') } + + it 'returns projects with a matching name' do + expect(described_class.search_by_title(project.name)).to eq([project]) + end + + it 'returns projects with a partially matching name' do + expect(described_class.search_by_title('kitten')).to eq([project]) + end + + it 'returns projects with a matching name regardless of the casing' do + expect(described_class.search_by_title('KITTENS')).to eq([project]) + end + end end diff --git a/spec/models/project_team_spec.rb b/spec/models/project_team_spec.rb index 7b63da005f0..bacb17a8883 100644 --- a/spec/models/project_team_spec.rb +++ b/spec/models/project_team_spec.rb @@ -67,6 +67,50 @@ describe ProjectTeam, models: true do end end + describe :max_invited_level do + let(:group) { create(:group) } + let(:project) { create(:empty_project) } + + before do + project.project_group_links.create( + group: group, + group_access: Gitlab::Access::DEVELOPER + ) + + group.add_user(master, Gitlab::Access::MASTER) + group.add_user(reporter, Gitlab::Access::REPORTER) + end + + it { expect(project.team.max_invited_level(master.id)).to eq(Gitlab::Access::DEVELOPER) } + it { expect(project.team.max_invited_level(reporter.id)).to eq(Gitlab::Access::REPORTER) } + it { expect(project.team.max_invited_level(nonmember.id)).to be_nil } + end + + describe :max_member_access do + let(:group) { create(:group) } + let(:project) { create(:empty_project) } + + before do + project.project_group_links.create( + group: group, + group_access: Gitlab::Access::DEVELOPER + ) + + group.add_user(master, Gitlab::Access::MASTER) + group.add_user(reporter, Gitlab::Access::REPORTER) + end + + it { expect(project.team.max_member_access(master.id)).to eq(Gitlab::Access::DEVELOPER) } + it { expect(project.team.max_member_access(reporter.id)).to eq(Gitlab::Access::REPORTER) } + it { expect(project.team.max_member_access(nonmember.id)).to be_nil } + + it "does not have an access" do + project.namespace.update(share_with_group_lock: true) + expect(project.team.max_member_access(master.id)).to be_nil + expect(project.team.max_member_access(reporter.id)).to be_nil + end + end + describe "#human_max_access" do it 'returns Master role' do user = create(:user) diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 150422ac349..34866be3395 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -148,6 +148,12 @@ describe Repository, models: true do expect(branch.name).to eq('new_feature') end + + it 'calls the after_create_branch hook' do + expect(repository).to receive(:after_create_branch) + + repository.add_branch(user, 'new_feature', 'master') + end end context 'when pre hooks failed' do @@ -405,7 +411,7 @@ describe Repository, models: true do end end - describe '#expire_branch_ache' do + describe '#expire_branch_cache' do # This method is private but we need it for testing purposes. Sadly there's # no other proper way of testing caching operations. let(:cache) { repository.send(:cache) } @@ -556,11 +562,12 @@ describe Repository, models: true do end end - describe '#before_create_tag' do + describe '#before_push_tag' do it 'flushes the cache' do expect(repository).to receive(:expire_cache) + expect(repository).to receive(:expire_tag_count_cache) - repository.before_create_tag + repository.before_push_tag end end @@ -607,4 +614,77 @@ describe Repository, models: true do expect(repository.main_language).to be_nil end end + + describe '#before_remove_tag' do + it 'flushes the tag cache' do + expect(repository).to receive(:expire_tag_count_cache) + + repository.before_remove_tag + end + end + + describe '#branch_count' do + it 'returns the number of branches' do + expect(repository.branch_count).to be_an_instance_of(Fixnum) + end + end + + describe '#tag_count' do + it 'returns the number of tags' do + expect(repository.tag_count).to be_an_instance_of(Fixnum) + end + end + + describe '#expire_branch_count_cache' do + let(:cache) { repository.send(:cache) } + + it 'expires the cache' do + expect(cache).to receive(:expire).with(:branch_count) + + repository.expire_branch_count_cache + end + end + + describe '#expire_tag_count_cache' do + let(:cache) { repository.send(:cache) } + + it 'expires the cache' do + expect(cache).to receive(:expire).with(:tag_count) + + repository.expire_tag_count_cache + end + end + + describe '#add_tag' do + it 'adds a tag' do + expect(repository).to receive(:before_push_tag) + + expect_any_instance_of(Gitlab::Shell).to receive(:add_tag). + with(repository.path_with_namespace, '8.5', 'master', 'foo') + + repository.add_tag('8.5', 'master', 'foo') + end + end + + describe '#rm_branch' do + let(:user) { create(:user) } + + it 'removes a branch' do + expect(repository).to receive(:before_remove_branch) + expect(repository).to receive(:after_remove_branch) + + repository.rm_branch(user, 'feature') + end + end + + describe '#rm_tag' do + it 'removes a tag' do + expect(repository).to receive(:before_remove_tag) + + expect_any_instance_of(Gitlab::Shell).to receive(:rm_tag). + with(repository.path_with_namespace, '8.5') + + repository.rm_tag('8.5') + end + end end diff --git a/spec/models/snippet_spec.rb b/spec/models/snippet_spec.rb index eb2dbbdc5a4..5077ac7b62b 100644 --- a/spec/models/snippet_spec.rb +++ b/spec/models/snippet_spec.rb @@ -10,7 +10,6 @@ # created_at :datetime # updated_at :datetime # file_name :string(255) -# expires_at :datetime # type :string(255) # visibility_level :integer default(0), not null # @@ -60,4 +59,48 @@ describe Snippet, models: true do expect(snippet.to_reference(cross)).to eq "#{project.to_reference}$#{snippet.id}" end end + + describe '.search' do + let(:snippet) { create(:snippet) } + + it 'returns snippets with a matching title' do + expect(described_class.search(snippet.title)).to eq([snippet]) + end + + it 'returns snippets with a partially matching title' do + expect(described_class.search(snippet.title[0..2])).to eq([snippet]) + end + + it 'returns snippets with a matching title regardless of the casing' do + expect(described_class.search(snippet.title.upcase)).to eq([snippet]) + end + + it 'returns snippets with a matching file name' do + expect(described_class.search(snippet.file_name)).to eq([snippet]) + end + + it 'returns snippets with a partially matching file name' do + expect(described_class.search(snippet.file_name[0..2])).to eq([snippet]) + end + + it 'returns snippets with a matching file name regardless of the casing' do + expect(described_class.search(snippet.file_name.upcase)).to eq([snippet]) + end + end + + describe '#search_code' do + let(:snippet) { create(:snippet, content: 'class Foo; end') } + + it 'returns snippets with matching content' do + expect(described_class.search_code(snippet.content)).to eq([snippet]) + end + + it 'returns snippets with partially matching content' do + expect(described_class.search_code('class')).to eq([snippet]) + end + + it 'returns snippets with matching content regardless of the casing' do + expect(described_class.search_code('FOO')).to eq([snippet]) + end + end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 412101ac9f9..909b6796591 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -463,17 +463,43 @@ describe User, models: true do end end - describe 'search' do - let(:user1) { create(:user, username: 'James', email: 'james@testing.com') } - let(:user2) { create(:user, username: 'jameson', email: 'jameson@example.com') } - - it "should be case insensitive" do - expect(User.search(user1.username.upcase).to_a).to eq([user1]) - expect(User.search(user1.username.downcase).to_a).to eq([user1]) - expect(User.search(user2.username.upcase).to_a).to eq([user2]) - expect(User.search(user2.username.downcase).to_a).to eq([user2]) - expect(User.search(user1.username.downcase).to_a.size).to eq(2) - expect(User.search(user2.username.downcase).to_a.size).to eq(1) + describe '.search' do + let(:user) { create(:user) } + + it 'returns users with a matching name' do + expect(described_class.search(user.name)).to eq([user]) + end + + it 'returns users with a partially matching name' do + expect(described_class.search(user.name[0..2])).to eq([user]) + end + + it 'returns users with a matching name regardless of the casing' do + expect(described_class.search(user.name.upcase)).to eq([user]) + end + + it 'returns users with a matching Email' do + expect(described_class.search(user.email)).to eq([user]) + end + + it 'returns users with a partially matching Email' do + expect(described_class.search(user.email[0..2])).to eq([user]) + end + + it 'returns users with a matching Email regardless of the casing' do + expect(described_class.search(user.email.upcase)).to eq([user]) + end + + it 'returns users with a matching username' do + expect(described_class.search(user.username)).to eq([user]) + end + + it 'returns users with a partially matching username' do + expect(described_class.search(user.username[0..2])).to eq([user]) + end + + it 'returns users with a matching username regardless of the casing' do + expect(described_class.search(user.username.upcase)).to eq([user]) end end diff --git a/spec/requests/api/project_snippets_spec.rb b/spec/requests/api/project_snippets_spec.rb new file mode 100644 index 00000000000..3722ddf5a33 --- /dev/null +++ b/spec/requests/api/project_snippets_spec.rb @@ -0,0 +1,18 @@ +require 'rails_helper' + +describe API::API, api: true do + include ApiHelpers + + describe 'GET /projects/:project_id/snippets/:id' do + # TODO (rspeicher): Deprecated; remove in 9.0 + it 'always exposes expires_at as nil' do + admin = create(:admin) + snippet = create(:project_snippet, author: admin) + + get api("/projects/#{snippet.project.id}/snippets/#{snippet.id}", admin) + + expect(json_response).to have_key('expires_at') + expect(json_response['expires_at']).to be_nil + end + end +end diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 9f2365a4832..a6699cdc81c 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -747,6 +747,42 @@ describe API::API, api: true do end end + describe "POST /projects/:id/share" do + let(:group) { create(:group) } + + it "should share project with group" do + expect do + post api("/projects/#{project.id}/share", user), group_id: group.id, group_access: Gitlab::Access::DEVELOPER + end.to change { ProjectGroupLink.count }.by(1) + + expect(response.status).to eq 201 + expect(json_response['group_id']).to eq group.id + expect(json_response['group_access']).to eq Gitlab::Access::DEVELOPER + end + + it "should return a 400 error when group id is not given" do + post api("/projects/#{project.id}/share", user), group_access: Gitlab::Access::DEVELOPER + expect(response.status).to eq 400 + end + + it "should return a 400 error when access level is not given" do + post api("/projects/#{project.id}/share", user), group_id: group.id + expect(response.status).to eq 400 + end + + it "should return a 400 error when sharing is disabled" do + project.namespace.update(share_with_group_lock: true) + post api("/projects/#{project.id}/share", user), group_id: group.id, group_access: Gitlab::Access::DEVELOPER + expect(response.status).to eq 400 + end + + it "should return a 409 error when wrong params passed" do + post api("/projects/#{project.id}/share", user), group_id: group.id, group_access: 1234 + expect(response.status).to eq 409 + expect(json_response['message']).to eq 'Group access is not included in the list' + end + end + describe 'GET /projects/search/:query' do let!(:query) { 'query'} let!(:search) { create(:empty_project, name: query, creator_id: user.id, namespace: user.namespace) } diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index b82c5c7685f..96e8c8c51f8 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -47,6 +47,8 @@ describe API::API, api: true do expect(json_response.first.keys).to include 'identities' expect(json_response.first.keys).to include 'can_create_project' expect(json_response.first.keys).to include 'two_factor_enabled' + expect(json_response.first.keys).to include 'last_sign_in_at' + expect(json_response.first.keys).to include 'confirmed_at' end end end diff --git a/spec/services/delete_tag_service_spec.rb b/spec/services/delete_tag_service_spec.rb new file mode 100644 index 00000000000..5b7ba521812 --- /dev/null +++ b/spec/services/delete_tag_service_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +describe DeleteTagService, services: true do + let(:project) { create(:project) } + let(:repository) { project.repository } + let(:user) { create(:user) } + let(:service) { described_class.new(project, user) } + + let(:tag) { double(:tag, name: '8.5', target: 'abc123') } + + describe '#execute' do + before do + allow(repository).to receive(:find_tag).and_return(tag) + end + + it 'removes the tag' do + expect_any_instance_of(Gitlab::Shell).to receive(:rm_tag). + and_return(true) + + expect(repository).to receive(:before_remove_tag) + expect(service).to receive(:success) + + service.execute('8.5') + end + end +end diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb index f5c51e46e8b..145bc937560 100644 --- a/spec/services/git_push_service_spec.rb +++ b/spec/services/git_push_service_spec.rb @@ -172,8 +172,8 @@ describe GitPushService, services: true do end - describe "Web Hooks" do - context "execute web hooks" do + describe "Webhooks" do + context "execute webhooks" do it "when pushing a branch for the first time" do expect(project).to receive(:execute_hooks) expect(project.default_branch).to eq("master") @@ -271,22 +271,24 @@ describe GitPushService, services: true do allow(project.repository).to receive(:commits_between). and_return([closing_commit]) + + project.team << [commit_author, :master] end context "to default branches" do it "closes issues" do - execute_service(project, user, @oldrev, @newrev, @ref ) + execute_service(project, commit_author, @oldrev, @newrev, @ref ) expect(Issue.find(issue.id)).to be_closed end it "adds a note indicating that the issue is now closed" do expect(SystemNoteService).to receive(:change_status).with(issue, project, commit_author, "closed", closing_commit) - execute_service(project, user, @oldrev, @newrev, @ref ) + execute_service(project, commit_author, @oldrev, @newrev, @ref ) end it "doesn't create additional cross-reference notes" do expect(SystemNoteService).not_to receive(:cross_reference) - execute_service(project, user, @oldrev, @newrev, @ref ) + execute_service(project, commit_author, @oldrev, @newrev, @ref ) end it "doesn't close issues when external issue tracker is in use" do @@ -294,7 +296,7 @@ describe GitPushService, services: true do # The push still shouldn't create cross-reference notes. expect do - execute_service(project, user, @oldrev, @newrev, 'refs/heads/hurf' ) + execute_service(project, commit_author, @oldrev, @newrev, 'refs/heads/hurf' ) end.not_to change { Note.where(project_id: project.id, system: true).count } end end @@ -316,7 +318,6 @@ describe GitPushService, services: true do end end - # EE-only tests context "for jira issue tracker" do include JiraServiceHelper @@ -366,7 +367,7 @@ describe GitPushService, services: true do } }.to_json - execute_service(project, user, @oldrev, @newrev, @ref ) + execute_service(project, commit_author, @oldrev, @newrev, @ref ) expect(WebMock).to have_requested(:post, jira_api_transition_url).with( body: transition_body ).once @@ -377,7 +378,7 @@ describe GitPushService, services: true do body: "Issue solved with [#{closing_commit.id}|http://localhost/#{project.path_with_namespace}/commit/#{closing_commit.id}]." }.to_json - execute_service(project, user, @oldrev, @newrev, @ref ) + execute_service(project, commit_author, @oldrev, @newrev, @ref ) expect(WebMock).to have_requested(:post, jira_api_comment_url).with( body: comment_body ).once @@ -400,6 +401,45 @@ describe GitPushService, services: true do end end + describe "housekeeping" do + let(:housekeeping) { Projects::HousekeepingService.new(project) } + + before do + allow(Projects::HousekeepingService).to receive(:new).and_return(housekeeping) + end + + it 'does not perform housekeeping when not needed' do + expect(housekeeping).not_to receive(:execute) + + execute_service(project, user, @oldrev, @newrev, @ref) + end + + context 'when housekeeping is needed' do + before do + allow(housekeeping).to receive(:needed?).and_return(true) + end + + it 'performs housekeeping' do + expect(housekeeping).to receive(:execute) + + execute_service(project, user, @oldrev, @newrev, @ref) + end + + it 'does not raise an exception' do + allow(housekeeping).to receive(:try_obtain_lease).and_return(false) + + execute_service(project, user, @oldrev, @newrev, @ref) + end + end + + + it 'increments the push counter' do + expect(housekeeping).to receive(:increment!) + + execute_service(project, user, @oldrev, @newrev, @ref) + end + end + def execute_service(project, user, oldrev, newrev, ref) service = described_class.new(project, user, oldrev: oldrev, newrev: newrev, ref: ref ) service.execute diff --git a/spec/services/git_tag_push_service_spec.rb b/spec/services/git_tag_push_service_spec.rb index b982274c529..cc780587e74 100644 --- a/spec/services/git_tag_push_service_spec.rb +++ b/spec/services/git_tag_push_service_spec.rb @@ -78,8 +78,8 @@ describe GitTagPushService, services: true do end end - describe "Web Hooks" do - context "execute web hooks" do + describe "Webhooks" do + context "execute webhooks" do it "when pushing tags" do expect(project).to receive(:execute_hooks) service.execute(project, user, 'oldrev', 'newrev', 'refs/tags/v1.0.0') diff --git a/spec/services/projects/housekeeping_service_spec.rb b/spec/services/projects/housekeeping_service_spec.rb new file mode 100644 index 00000000000..93bf1b81fbe --- /dev/null +++ b/spec/services/projects/housekeeping_service_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +describe Projects::HousekeepingService do + subject { Projects::HousekeepingService.new(project) } + let(:project) { create :project } + + describe 'execute' do + before do + project.pushes_since_gc = 3 + project.save! + end + + it 'enqueues a sidekiq job' do + expect(subject).to receive(:try_obtain_lease).and_return(true) + expect(GitlabShellWorker).to receive(:perform_async).with(:gc, project.path_with_namespace) + + subject.execute + expect(project.pushes_since_gc).to eq(0) + end + + it 'does not enqueue a job when no lease can be obtained' do + expect(subject).to receive(:try_obtain_lease).and_return(false) + expect(GitlabShellWorker).not_to receive(:perform_async) + + expect { subject.execute }.to raise_error(Projects::HousekeepingService::LeaseTaken) + expect(project.pushes_since_gc).to eq(0) + end + end + + describe 'needed?' do + it 'when the count is low enough' do + expect(subject.needed?).to eq(false) + end + + it 'when the count is high enough' do + allow(project).to receive(:pushes_since_gc).and_return(10) + expect(subject.needed?).to eq(true) + end + end + + describe 'increment!' do + it 'increments the pushes_since_gc counter' do + expect(project.pushes_since_gc).to eq(0) + subject.increment! + expect(project.pushes_since_gc).to eq(1) + end + end +end diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb index 3c06a890163..e8b9e6b9238 100644 --- a/spec/services/projects/update_service_spec.rb +++ b/spec/services/projects/update_service_spec.rb @@ -102,8 +102,8 @@ describe Projects::UpdateService, services: true do describe :visibility_level do let(:user) { create :user, admin: true } - let(:project) { create :project, visibility_level: Gitlab::VisibilityLevel::INTERNAL } - let(:forked_project) { create :forked_project_with_submodules, visibility_level: Gitlab::VisibilityLevel::INTERNAL } + let(:project) { create(:project, :internal) } + let(:forked_project) { create(:forked_project_with_submodules, :internal) } let(:opts) { {} } before do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 8f381f46e57..7d939ca7509 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -14,7 +14,7 @@ require File.expand_path("../../config/environment", __FILE__) require 'rspec/rails' require 'shoulda/matchers' require 'sidekiq/testing/inline' -require 'benchmark/ips' +require 'rspec/retry' # Requires supporting ruby files with custom matchers and macros, etc, # in spec/support/ and its subdirectories. @@ -25,6 +25,9 @@ RSpec.configure do |config| config.use_instantiated_fixtures = false config.mock_with :rspec + config.verbose_retry = true + config.display_try_failure_messages = true + config.include Devise::TestHelpers, type: :controller config.include LoginHelpers, type: :feature config.include LoginHelpers, type: :request @@ -34,7 +37,6 @@ RSpec.configure do |config| config.include ActiveJob::TestHelper config.include StubGitlabCalls config.include StubGitlabData - config.include BenchmarkMatchers, benchmark: true config.infer_spec_type_from_file_location! config.raise_errors_for_deprecations! diff --git a/spec/support/matchers/benchmark_matchers.rb b/spec/support/matchers/benchmark_matchers.rb deleted file mode 100644 index 84f655c2119..00000000000 --- a/spec/support/matchers/benchmark_matchers.rb +++ /dev/null @@ -1,61 +0,0 @@ -module BenchmarkMatchers - extend RSpec::Matchers::DSL - - def self.included(into) - into.extend(ClassMethods) - end - - matcher :iterate_per_second do |min_iterations| - supports_block_expectations - - match do |block| - @max_stddev ||= 30 - - @entry = benchmark(&block) - - expect(@entry.ips).to be >= min_iterations - expect(@entry.stddev_percentage).to be <= @max_stddev - end - - chain :with_maximum_stddev do |value| - @max_stddev = value - end - - description do - "run at least #{min_iterations} iterations per second" - end - - failure_message do - ips = @entry.ips.round(2) - stddev = @entry.stddev_percentage.round(2) - - "expected at least #{min_iterations} iterations per second " \ - "with a maximum stddev of #{@max_stddev}%, instead of " \ - "#{ips} iterations per second with a stddev of #{stddev}%" - end - end - - # Benchmarks the given block and returns a Benchmark::IPS::Report::Entry. - def benchmark(&block) - report = Benchmark.ips(quiet: true) do |bench| - bench.report do - instance_eval(&block) - end - end - - report.entries[0] - end - - module ClassMethods - # Wraps around rspec's subject method so you can write: - # - # benchmark_subject { SomeClass.some_method } - # - # instead of: - # - # subject { -> { SomeClass.some_method } } - def benchmark_subject(&block) - subject { block } - end - end -end diff --git a/spec/workers/post_receive_spec.rb b/spec/workers/post_receive_spec.rb index e4151b9bb6a..0265dbe9c66 100644 --- a/spec/workers/post_receive_spec.rb +++ b/spec/workers/post_receive_spec.rb @@ -11,7 +11,7 @@ describe PostReceive do end end - context "web hook" do + context "webhook" do let(:project) { create(:project) } let(:key) { create(:key, user: project.owner) } let(:key_id) { key.shell_id } |