diff options
author | Grzegorz Bizon <grzesiek.bizon@gmail.com> | 2016-06-15 21:14:25 +0300 |
---|---|---|
committer | Grzegorz Bizon <grzesiek.bizon@gmail.com> | 2016-06-15 21:14:25 +0300 |
commit | d8670e114af1e21c48878afe8af16cc5628861fa (patch) | |
tree | c84b3fb5398b9629491ab30549a9cbd89b3495c3 | |
parent | 2d495fce529cc3ac15f7096ddf9962db0fbd1e23 (diff) | |
parent | 32a5ff70d771e7bff4e5c7b42fe95a966fa47a96 (diff) |
Merge branch 'master' into fix/status-of-pipeline-without-builds
* master: (198 commits)
Set inverse_of for Project/Services relation
Fix admin hooks spec
Prevent default disabled buttons and links.
Add index on `requested_at` to the `members` table
Rearrange order of tabs
Fix admin active tab tests
Show created_at in table column
Nest li elements directly under ul
Move builds tab to admin overview
Add monitoring link with subtabs
Add sub links to overview
Add counter for abuse reports
Remove admin layout-nav counters
Move admin nav to horizontal layout nav
Eager load project relations in IssueParser
Use validate and required for environment and project
Award Emoji can't be awarded on system notes backend
Get rid of Gitlab::ShellEnv
Update CHANGELOG.
Fix project star tooltip on the fly.
...
Conflicts:
app/services/ci/create_builds_service.rb
290 files changed, 6397 insertions, 2246 deletions
diff --git a/CHANGELOG b/CHANGELOG index 4defd85ef10..66356b08e14 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -10,6 +10,8 @@ v 8.9.0 (unreleased) - Allow enabling wiki page events from Webhook management UI - Bump rouge to 1.11.0 - Fix issue with arrow keys not working in search autocomplete dropdown + - Fix an issue where note polling stopped working if a window was in the + background during a refresh. - Make EmailsOnPushWorker use Sidekiq mailers queue - Fix wiki page events' webhook to point to the wiki repository - Don't show tags for revert and cherry-pick operations @@ -23,9 +25,14 @@ v 8.9.0 (unreleased) - Reduce number of fog gem dependencies - Remove project notification settings associated with deleted projects - Fix 404 page when viewing TODOs that contain milestones or labels in different projects + - Add a metric for the number of new Redis connections created by a transaction + - Fix Error 500 when viewing a blob with binary characters after the 1024-byte mark - Redesign navigation for project pages - Fix groups API to list only user's accessible projects + - Add Environments and Deployments - Redesign account and email confirmation emails + - Don't fail builds for projects that are deleted + - Support Docker Registry manifest v1 - `git clone https://host/namespace/project` now works, in addition to using the `.git` suffix - Bump nokogiri to 1.6.8 - Use gitlab-shell v3.0.0 @@ -58,6 +65,7 @@ v 8.9.0 (unreleased) - Use Knapsack only in CI environment - Cache project build count in sidebar nav - Add milestone expire date to the right sidebar + - Manually mark a issue or merge request as a todo - Fix markdown_spec to use before instead of before(:all) to properly cleanup database after testing - Reduce number of queries needed to render issue labels in the sidebar - Improve error handling importing projects @@ -70,6 +78,7 @@ v 8.9.0 (unreleased) - RepositoryCheck::SingleRepositoryWorker public and private methods are now instrumented - Improve issuables APIs performance when accessing notes !4471 - External links now open in a new tab + - Prevent default actions of disabled buttons and links - Markdown editor now correctly resets the input value on edit cancellation !4175 - Toggling a task list item in a issue/mr description does not creates a Todo for mentions - Improved UX of date pickers on issue & milestone forms @@ -78,6 +87,20 @@ v 8.9.0 (unreleased) - All classes in the Banzai::ReferenceParser namespace are now instrumented - Remove deprecated issues_tracker and issues_tracker_id from project model - Allow users to create confidential issues in private projects + - Measure CPU time for instrumented methods + - Instrument private methods and private instance methods by default instead just public methods + - Only show notes through JSON on confidential issues that the user has access to + - Updated the allocations Gem to version 1.0.5 + - The background sampler now ignores classes without names + - Update design for `Close` buttons + - New custom icons for navigation + - Horizontally scrolling navigation on project, group, and profile settings pages + - Hide global side navigation by default + - Fix project Star/Unstar project button tooltip + - Remove tanuki logo from side navigation; center on top nav + - Include user relationships when retrieving award_emoji + - Various associations are now eager loaded when parsing issue references to reduce the number of queries executed + - Set inverse_of for Project/Service association to reduce the number of queries v 8.8.5 (unreleased) - Ensure branch cleanup regardless of whether the GitHub import process succeeds @@ -210,6 +210,9 @@ gem 'mousetrap-rails', '~> 1.4.6' # Detect and convert string character encoding gem 'charlock_holmes', '~> 0.7.3' +# Parse duration +gem 'chronic_duration', '~> 0.10.6' + gem "sass-rails", '~> 5.0.0' gem "coffee-rails", '~> 4.1.0' gem "uglifier", '~> 2.7.2' diff --git a/Gemfile.lock b/Gemfile.lock index 2ba2676efa1..c1c8c175b1d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -50,7 +50,7 @@ GEM after_commit_queue (1.3.0) activerecord (>= 3.0) akismet (2.0.0) - allocations (1.0.4) + allocations (1.0.5) arel (6.0.3) asana (0.4.0) faraday (~> 0.9) @@ -124,6 +124,8 @@ GEM mime-types (>= 1.16) cause (0.1) charlock_holmes (0.7.3) + chronic_duration (0.10.6) + numerizer (~> 0.1.1) chunky_png (1.3.5) cliver (0.3.2) coderay (1.1.0) @@ -275,7 +277,7 @@ GEM posix-spawn (~> 0.3) gitlab_emoji (0.3.1) gemojione (~> 2.2, >= 2.2.1) - gitlab_git (10.1.0) + gitlab_git (10.1.3) activesupport (~> 4.0) charlock_holmes (~> 0.7.3) github-linguist (~> 4.7.0) @@ -398,7 +400,7 @@ GEM mime-types (>= 1.16, < 4) mail_room (0.7.0) method_source (0.8.2) - mime-types (2.99.1) + mime-types (2.99.2) mimemagic (0.3.0) mini_portile2 (2.1.0) minitest (5.7.0) @@ -414,6 +416,7 @@ GEM nokogiri (1.6.8) mini_portile2 (~> 2.1.0) pkg-config (~> 1.1.7) + numerizer (0.1.1) oauth (0.4.7) oauth2 (1.0.0) faraday (>= 0.8, < 0.10) @@ -839,6 +842,7 @@ DEPENDENCIES capybara-screenshot (~> 1.0.0) carrierwave (~> 0.10.0) charlock_holmes (~> 0.7.3) + chronic_duration (~> 0.10.6) coffee-rails (~> 4.1.0) connection_pool (~> 2.0) coveralls (~> 0.8.2) diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index 69d4c4f5dd3..6c16f89cef6 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -125,6 +125,7 @@ window.onload = -> setTimeout shiftWindow, 100 $ -> + gl.utils.preventDisabledButtons() bootstrapBreakpoint = bp.getBreakpointSize() $(".nicescroll").niceScroll(cursoropacitymax: '0.4', cursorcolor: '#FFF', cursorborder: "1px solid #FFF") diff --git a/app/assets/javascripts/ci/build.coffee b/app/assets/javascripts/ci/build.coffee index f763ba96e33..2d515d7efa2 100644 --- a/app/assets/javascripts/ci/build.coffee +++ b/app/assets/javascripts/ci/build.coffee @@ -17,6 +17,8 @@ class @CiBuild .off 'resize.build' .on 'resize.build', @hideSidebar + @updateArtifactRemoveDate() + if $('#build-trace').length @getInitialBuildTrace() @initScrollButtonAffix() @@ -103,3 +105,10 @@ class @CiBuild $('.js-build-sidebar') .removeClass 'right-sidebar-collapsed' .addClass 'right-sidebar-expanded' + + updateArtifactRemoveDate: -> + $date = $('.js-artifacts-remove') + + if $date.length + date = $date.text() + $date.text $.timefor(new Date(date), ' ') diff --git a/app/assets/javascripts/dispatcher.js.coffee b/app/assets/javascripts/dispatcher.js.coffee index 29ac0f70b30..8b39e6b090c 100644 --- a/app/assets/javascripts/dispatcher.js.coffee +++ b/app/assets/javascripts/dispatcher.js.coffee @@ -53,9 +53,13 @@ class Dispatcher new Diff() shortcut_handler = new ShortcutsIssuable(true) new ZenMode() + new MergedButtons() + when 'projects:merge_requests:commits', 'projects:merge_requests:builds' + new MergedButtons() when "projects:merge_requests:diffs" new Diff() new ZenMode() + new MergedButtons() when 'projects:merge_requests:index' shortcut_handler = new ShortcutsNavigation() Issuable.init() diff --git a/app/assets/javascripts/issuable.js.coffee b/app/assets/javascripts/issuable.js.coffee index c2447120033..d0901be1509 100644 --- a/app/assets/javascripts/issuable.js.coffee +++ b/app/assets/javascripts/issuable.js.coffee @@ -56,13 +56,6 @@ issuable_created = false Issuable.filterResults $('.filter-form') $('.js-label-select').trigger('update.label') - toggleLabelFilters: -> - $filteredLabels = $('.filtered-labels') - if $filteredLabels.find('.label-row').length > 0 - $filteredLabels.removeClass('hidden') - else - $filteredLabels.addClass('hidden') - filterResults: (form) => formData = form.serialize() @@ -71,58 +64,16 @@ issuable_created = false issuesUrl = formAction issuesUrl += ("#{if formAction.indexOf('?') < 0 then '?' else '&'}") issuesUrl += formData - $.ajax - type: 'GET' - url: formAction - data: formData - complete: -> - $('.issues-holder, .merge-requests-holder').css('opacity', '1.0') - success: (data) -> - $('.issues-holder, .merge-requests-holder').html(data.html) - # Change url so if user reload a page - search results are saved - history.replaceState {page: issuesUrl}, document.title, issuesUrl - Issuable.reload() - Issuable.updateStateFilters() - $filteredLabels = $('.filtered-labels') - - if typeof Issuable.labelRow is 'function' - $filteredLabels.html(Issuable.labelRow(data)) - Issuable.toggleLabelFilters() - - dataType: "json" - - reload: -> - if Issuable.created - Issuable.initChecks() - - $('#filter_issue_search').val($('#issue_search').val()) + Turbolinks.visit(issuesUrl); initChecks: -> - $('.check_all_issues').on 'click', -> + $('.check_all_issues').off('click').on('click', -> $('.selected_issue').prop('checked', @checked) Issuable.checkChanged() + ) - $('.selected_issue').on 'change', Issuable.checkChanged - - updateStateFilters: -> - stateFilters = $('.issues-state-filters, .dropdown-menu-sort') - newParams = {} - paramKeys = ['author_id', 'milestone_title', 'assignee_id', 'issue_search', 'issue_search'] - - for paramKey in paramKeys - newParams[paramKey] = gl.utils.getParameterValues(paramKey)[0] or '' - - if stateFilters.length - stateFilters.find('a').each -> - initialUrl = gl.utils.removeParamQueryString($(this).attr('href'), 'label_name[]') - labelNameValues = gl.utils.getParameterValues('label_name[]') - if labelNameValues - labelNameQueryString = ("label_name[]=#{value}" for value in labelNameValues).join('&') - newUrl = "#{gl.utils.mergeUrlParams(newParams, initialUrl)}&#{labelNameQueryString}" - else - newUrl = gl.utils.mergeUrlParams(newParams, initialUrl) - $(this).attr 'href', newUrl + $('.selected_issue').off('change').on('change', Issuable.checkChanged) checkChanged: -> checked_issues = $('.selected_issue:checked') diff --git a/app/assets/javascripts/issuable_form.js.coffee b/app/assets/javascripts/issuable_form.js.coffee index 898506fde32..5b7a4831dfc 100644 --- a/app/assets/javascripts/issuable_form.js.coffee +++ b/app/assets/javascripts/issuable_form.js.coffee @@ -102,6 +102,10 @@ class @IssuableForm return { results: data } + data: (query) -> + { + search: query + } formatResult: (project) -> project.name_with_namespace formatSelection: (project) -> diff --git a/app/assets/javascripts/issues-bulk-assignment.js.coffee b/app/assets/javascripts/issues-bulk-assignment.js.coffee index 9dc3529a17f..b454f9389dd 100644 --- a/app/assets/javascripts/issues-bulk-assignment.js.coffee +++ b/app/assets/javascripts/issues-bulk-assignment.js.coffee @@ -9,6 +9,9 @@ class @IssuableBulkActions @bindEvents() + # Fixes bulk-assign not working when navigating through pages + Issuable.initChecks(); + getElement: (selector) -> @container.find selector diff --git a/app/assets/javascripts/layout_nav.js.coffee b/app/assets/javascripts/layout_nav.js.coffee index 6adac6dac97..f8f0aea427e 100644 --- a/app/assets/javascripts/layout_nav.js.coffee +++ b/app/assets/javascripts/layout_nav.js.coffee @@ -1,14 +1,25 @@ -class @LayoutNav - $ -> - $('.fade-left').addClass('end-scroll') - $('.scrolling-tabs').on 'scroll', (event) -> - $this = $(this) - $el = $(event.target) - currentPosition = $this.scrollLeft() - size = bp.getBreakpointSize() - controlBtnWidth = $('.controls').width() - maxPosition = $this.get(0).scrollWidth - $this.parent().width() - maxPosition += controlBtnWidth if size isnt 'xs' and $('.nav-control').length - - $el.find('.fade-left').toggleClass('end-scroll', currentPosition is 0) - $el.find('.fade-right').toggleClass('end-scroll', currentPosition is maxPosition) +hideEndFade = ($scrollingTabs) -> + $scrollingTabs.each -> + $this = $(@) + + $this + .find('.fade-right') + .toggleClass('end-scroll', $this.width() is $this.prop('scrollWidth')) + +$ -> + $('.fade-left').addClass('end-scroll') + + hideEndFade($('.scrolling-tabs')) + + $(window) + .off 'resize.nav' + .on 'resize.nav', -> + hideEndFade($('.scrolling-tabs')) + + $('.scrolling-tabs').on 'scroll', (event) -> + $this = $(this) + currentPosition = $this.scrollLeft() + maxPosition = $this.prop('scrollWidth') - $this.outerWidth() + + $this.find('.fade-left').toggleClass('end-scroll', currentPosition is 0) + $this.find('.fade-right').toggleClass('end-scroll', currentPosition is maxPosition) diff --git a/app/assets/javascripts/lib/common_utils.js.coffee b/app/assets/javascripts/lib/common_utils.js.coffee index 0000e99a650..4f1779b8483 100644 --- a/app/assets/javascripts/lib/common_utils.js.coffee +++ b/app/assets/javascripts/lib/common_utils.js.coffee @@ -1,5 +1,8 @@ ((w) -> + window.gl or= {} + window.gl.utils or= {} + jQuery.timefor = (time, suffix, expiredLabel) -> return '' unless time @@ -21,4 +24,20 @@ return timefor + + gl.utils.updateTooltipTitle = ($tooltipEl, newTitle) -> + + $tooltipEl + .tooltip 'destroy' + .attr 'title', newTitle + .tooltip 'fixTitle' + + gl.utils.preventDisabledButtons = -> + + $('.btn').click (e) -> + if $(this).hasClass 'disabled' + e.preventDefault() + e.stopImmediatePropagation() + return false + ) window diff --git a/app/assets/javascripts/logo.js.coffee b/app/assets/javascripts/logo.js.coffee index 9fdc27a9787..dc2590a0355 100644 --- a/app/assets/javascripts/logo.js.coffee +++ b/app/assets/javascripts/logo.js.coffee @@ -42,9 +42,3 @@ work = -> $(document).on('page:fetch', start) $(document).on('page:change', stop) - -$ -> - # Make logo clickable as part of a workaround for Safari visited - # link behaviour (See !2690). - $('#logo').on 'click', -> - Turbolinks.visit('/') diff --git a/app/assets/javascripts/merged_buttons.js.coffee b/app/assets/javascripts/merged_buttons.js.coffee new file mode 100644 index 00000000000..4929295c10b --- /dev/null +++ b/app/assets/javascripts/merged_buttons.js.coffee @@ -0,0 +1,30 @@ +class @MergedButtons + constructor: -> + @$removeBranchWidget = $('.remove_source_branch_widget') + @$removeBranchProgress = $('.remove_source_branch_in_progress') + @$removeBranchFailed = $('.remove_source_branch_widget.failed') + + @cleanEventListeners() + @initEventListeners() + + cleanEventListeners: -> + $(document).off 'click', '.remove_source_branch' + $(document).off 'ajax:success', '.remove_source_branch' + $(document).off 'ajax:error', '.remove_source_branch' + + initEventListeners: -> + $(document).on 'click', '.remove_source_branch', @removeSourceBranch + $(document).on 'ajax:success', '.remove_source_branch', @removeBranchSuccess + $(document).on 'ajax:error', '.remove_source_branch', @removeBranchError + + removeSourceBranch: => + @$removeBranchWidget.hide() + @$removeBranchProgress.show() + + removeBranchSuccess: -> + location.reload() + + removeBranchError: -> + @$removeBranchWidget.hide() + @$removeBranchProgress.hide() + @$removeBranchFailed.show() diff --git a/app/assets/javascripts/milestone_select.js.coffee b/app/assets/javascripts/milestone_select.js.coffee index 648e1f3bde0..b108f747bd6 100644 --- a/app/assets/javascripts/milestone_select.js.coffee +++ b/app/assets/javascripts/milestone_select.js.coffee @@ -116,7 +116,7 @@ class @MilestoneSelect .val() data = {} data[abilityName] = {} - data[abilityName].milestone_id = selected + data[abilityName].milestone_id = if selected? then selected else null $loading .fadeIn() $dropdown.trigger('loading.gl.dropdown') diff --git a/app/assets/javascripts/notes.js.coffee b/app/assets/javascripts/notes.js.coffee index ad216910c8d..e2d3241437b 100644 --- a/app/assets/javascripts/notes.js.coffee +++ b/app/assets/javascripts/notes.js.coffee @@ -115,12 +115,14 @@ class @Notes , @pollingInterval refresh: => - return if @refreshing is true - @refreshing = true if not document.hidden and document.URL.indexOf(@noteable_url) is 0 @getContent() getContent: -> + return if @refreshing + + @refreshing = true + $.ajax url: @notes_url data: "last_fetched_at=" + @last_fetched_at diff --git a/app/assets/javascripts/right_sidebar.js.coffee b/app/assets/javascripts/right_sidebar.js.coffee index c9cb0f4bb32..8eb005b0a22 100644 --- a/app/assets/javascripts/right_sidebar.js.coffee +++ b/app/assets/javascripts/right_sidebar.js.coffee @@ -43,6 +43,55 @@ class @Sidebar $('.right-sidebar') .hasClass('right-sidebar-collapsed'), { path: '/' }) + $(document) + .off 'click', '.js-issuable-todo' + .on 'click', '.js-issuable-todo', @toggleTodo + + toggleTodo: (e) => + $this = $(e.currentTarget) + $todoLoading = $('.js-issuable-todo-loading') + $btnText = $('.js-issuable-todo-text', $this) + ajaxType = if $this.attr('data-id') then 'PATCH' else 'POST' + ajaxUrlExtra = if $this.attr('data-id') then "/#{$this.attr('data-id')}" else '' + + $.ajax( + url: "#{$this.data('url')}#{ajaxUrlExtra}" + type: ajaxType + dataType: 'json' + data: + issuable_id: $this.data('issuable') + issuable_type: $this.data('issuable-type') + beforeSend: => + @beforeTodoSend($this, $todoLoading) + ).done (data) => + @todoUpdateDone(data, $this, $btnText, $todoLoading) + + beforeTodoSend: ($btn, $todoLoading) -> + $btn.disable() + $todoLoading.removeClass 'hidden' + + todoUpdateDone: (data, $btn, $btnText, $todoLoading) -> + $todoPendingCount = $('.todos-pending-count') + $todoPendingCount.text data.count + + $btn.enable() + $todoLoading.addClass 'hidden' + + if data.count is 0 + $todoPendingCount.addClass 'hidden' + else + $todoPendingCount.removeClass 'hidden' + + if data.todo? + $btn + .attr 'aria-label', $btn.data('mark-text') + .attr 'data-id', data.todo.id + $btnText.text $btn.data('mark-text') + else + $btn + .attr 'aria-label', $btn.data('todo-text') + .removeAttr 'data-id' + $btnText.text $btn.data('todo-text') sidebarDropdownLoading: (e) -> $sidebarCollapsedIcon = $(@).closest('.block').find('.sidebar-collapsed-icon') @@ -117,5 +166,3 @@ class @Sidebar getBlock: (name) -> @sidebar.find(".block.#{name}") - - diff --git a/app/assets/javascripts/star.js.coffee b/app/assets/javascripts/star.js.coffee index f27780dda93..01b28171f72 100644 --- a/app/assets/javascripts/star.js.coffee +++ b/app/assets/javascripts/star.js.coffee @@ -9,9 +9,11 @@ class @Star $this.parent().find('.star-count').text data.star_count if isStarred $starSpan.removeClass('starred').text 'Star' + gl.utils.updateTooltipTitle $this, 'Star project' $starIcon.removeClass('fa-star').addClass 'fa-star-o' else $starSpan.addClass('starred').text 'Unstar' + gl.utils.updateTooltipTitle $this, 'Unstar project' $starIcon.removeClass('fa-star-o').addClass 'fa-star' return diff --git a/app/assets/javascripts/users_select.js.coffee b/app/assets/javascripts/users_select.js.coffee index 88246b0feb8..3dbc1d7f14f 100644 --- a/app/assets/javascripts/users_select.js.coffee +++ b/app/assets/javascripts/users_select.js.coffee @@ -31,7 +31,7 @@ class @UsersSelect assignTo = (selected) -> data = {} data[abilityName] = {} - data[abilityName].assignee_id = selected + data[abilityName].assignee_id = if selected? then selected else null $loading .fadeIn() $dropdown.trigger('loading.gl.dropdown') diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss index 4de89daeb36..829222509f0 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -74,6 +74,7 @@ .container-fluid { background-color: $background-color; + margin-bottom: 0; } li { @@ -280,11 +281,10 @@ } .dropdown { - margin-left: 7px; - - @media (max-width: $screen-xs-min) { - margin-left: 0; - } + position: absolute; + top: 7px; + right: 15px; + z-index: 2; li.active { font-weight: bold; diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index b7ec3f70bfb..4668e7e911b 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -83,6 +83,12 @@ margin-top: 10px; } + .icon-container { + width: 34px; + display: inline-block; + text-align: center; + } + a { width: $sidebar_width; padding: 7px 15px 7px 23px; diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss new file mode 100644 index 00000000000..e160d676e35 --- /dev/null +++ b/app/assets/stylesheets/pages/environments.scss @@ -0,0 +1,5 @@ +.environments { + .commit-title { + margin: 0; + } +} diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index ec6c099df5b..ac7721cbe15 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -39,3 +39,20 @@ } } } + +.groups-cover-block { + + .container-fluid { + position: relative; + } + + .access-request-button { + @include btn-gray; + position: absolute; + right: 16px; + bottom: 32px; + padding: 3px 10px; + text-transform: none; + background-color: $background-color; + } +} diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index ea453ce356a..f57845ad9c9 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -34,6 +34,10 @@ color: inherit; } + .issuable-header-text { + margin-top: 7px; + } + .block { @include clearfix; padding: $gl-padding 0; @@ -60,10 +64,6 @@ margin-top: 0; } - .issuable-count { - margin-top: 7px; - } - .gutter-toggle { margin-left: 20px; padding-left: 10px; @@ -250,7 +250,7 @@ } } - .issuable-pager { + .issuable-header-btn { background: $gray-normal; border: 1px solid $border-gray-normal; &:hover { @@ -263,7 +263,7 @@ } } - a:not(.issuable-pager) { + a { &:hover { color: $md-link-color; text-decoration: none; diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index a47f2580aa3..53bff508c72 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -313,3 +313,13 @@ } } } + +.merged-buttons { + .btn { + float: left; + + &:not(:last-child) { + margin-right: 10px; + } + } +} diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index bb250904255..0e4cefc55c2 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -229,13 +229,20 @@ right: 16px; bottom: 0; - .btn { - padding: 3px 10px; - background-color: $background-color; + @media (max-width: $screen-lg-min) { + top: 0; } - @media (max-width: 1304px) { - top: 0; + .access-request-button { + position: absolute; + right: 0; + bottom: 61px; + + @media (max-width: $screen-lg-min) { + position: relative; + bottom: 0; + margin-right: 10px; + } } } @@ -286,10 +293,6 @@ color: #555; } -.project_member_row form { - margin: 0; -} - .transfer-project .select2-container { min-width: 200px; } diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index 3865b2d61fd..c89678cf2d8 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -35,6 +35,7 @@ class AutocompleteController < ApplicationController project = Project.find_by_id(params[:project_id]) projects = current_user.authorized_projects + projects = projects.search(params[:search]) if params[:search].present? projects = projects.select do |project| current_user.can?(:admin_issue, project) end diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb new file mode 100644 index 00000000000..a24273fad0b --- /dev/null +++ b/app/controllers/concerns/membership_actions.rb @@ -0,0 +1,58 @@ +module MembershipActions + extend ActiveSupport::Concern + include MembersHelper + + def request_access + membershipable.request_access(current_user) + + redirect_to polymorphic_path(membershipable), + notice: 'Your request for access has been queued for review.' + end + + def approve_access_request + @member = membershipable.members.request.find(params[:id]) + + return render_403 unless can?(current_user, action_member_permission(:update, @member), @member) + + @member.accept_request + + redirect_to polymorphic_url([membershipable, :members]) + end + + def leave + @member = membershipable.members.find_by(user_id: current_user) + return render_403 unless @member + + source_type = @member.real_source_type.humanize(capitalize: false) + + if can?(current_user, action_member_permission(:destroy, @member), @member) + notice = + if @member.request? + "Your access request to the #{source_type} has been withdrawn." + else + "You left the \"#{@member.source.human_name}\" #{source_type}." + end + @member.destroy + + redirect_to [:dashboard, @member.real_source_type.tableize], notice: notice + else + if cannot_leave? + alert = "You can not leave the \"#{@member.source.human_name}\" #{source_type}." + alert << " Transfer or delete the #{source_type}." + redirect_to polymorphic_url(membershipable), alert: alert + else + render_403 + end + end + end + + protected + + def membershipable + raise NotImplementedError + end + + def cannot_leave? + raise NotImplementedError + end +end diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 48dbf656e84..d0f2e2949f0 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -1,11 +1,13 @@ class Groups::GroupMembersController < Groups::ApplicationController + include MembershipActions + # Authorize - before_action :authorize_admin_group_member!, except: [:index, :leave] + before_action :authorize_admin_group_member!, except: [:index, :leave, :request_access] def index @project = @group.projects.find(params[:project_id]) if params[:project_id] @members = @group.group_members - @members = @members.non_invite unless can?(current_user, :admin_group, @group) + @members = @members.non_pending unless can?(current_user, :admin_group, @group) if params[:search].present? users = @group.users.search(params[:search]).to_a @@ -58,25 +60,16 @@ class Groups::GroupMembersController < Groups::ApplicationController end end - def leave - @group_member = @group.group_members.find_by(user_id: current_user) - - if can?(current_user, :destroy_group_member, @group_member) - @group_member.destroy - - redirect_to(dashboard_groups_path, notice: "You left #{group.name} group.") - else - if @group.last_owner?(current_user) - redirect_to(dashboard_groups_path, alert: "You can not leave #{group.name} group because you're the last owner. Transfer or delete the group.") - else - return render_403 - end - end - end - protected def member_params params.require(:group_member).permit(:access_level, :user_id) end + + # MembershipActions concern + alias_method :membershipable, :group + + def cannot_leave? + @group.last_owner?(current_user) + end end diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index 832d7deb57d..f11c8321464 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -1,22 +1,18 @@ class Projects::ArtifactsController < Projects::ApplicationController layout 'project' before_action :authorize_read_build! + before_action :authorize_update_build!, only: [:keep] + before_action :validate_artifacts! def download unless artifacts_file.file_storage? return redirect_to artifacts_file.url end - unless artifacts_file.exists? - return render_404 - end - send_file artifacts_file.path, disposition: 'attachment' end def browse - return render_404 unless build.artifacts? - directory = params[:path] ? "#{params[:path]}/" : '' @entry = build.artifacts_metadata_entry(directory) @@ -34,8 +30,17 @@ class Projects::ArtifactsController < Projects::ApplicationController end end + def keep + build.keep_artifacts! + redirect_to namespace_project_build_path(project.namespace, project, build) + end + private + def validate_artifacts! + render_404 unless build.artifacts? + end + def build @build ||= project.builds.find_by!(id: params[:build_id]) end diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb index 14c82826342..ef3051d7519 100644 --- a/app/controllers/projects/builds_controller.rb +++ b/app/controllers/projects/builds_controller.rb @@ -51,7 +51,7 @@ class Projects::BuildsController < Projects::ApplicationController return render_404 end - build = Ci::Build.retry(@build) + build = Ci::Build.retry(@build, current_user) redirect_to build_path(build) end diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index 20637fa46fe..6751737d15e 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -46,7 +46,7 @@ class Projects::CommitController < Projects::ApplicationController def retry_builds ci_builds.latest.failed.each do |build| if build.retryable? - Ci::Build.retry(build) + Ci::Build.retry(build, current_user) end end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb new file mode 100644 index 00000000000..4b433796161 --- /dev/null +++ b/app/controllers/projects/environments_controller.rb @@ -0,0 +1,49 @@ +class Projects::EnvironmentsController < Projects::ApplicationController + layout 'project' + before_action :authorize_read_environment! + before_action :authorize_create_environment!, only: [:new, :create] + before_action :authorize_update_environment!, only: [:destroy] + before_action :environment, only: [:show, :destroy] + + def index + @environments = project.environments + end + + def show + @deployments = environment.deployments.order(id: :desc).page(params[:page]) + end + + def new + @environment = project.environments.new + end + + def create + @environment = project.environments.create(create_params) + + if @environment.persisted? + redirect_to namespace_project_environment_path(project.namespace, project, @environment) + else + render 'new' + end + end + + def destroy + if @environment.destroy + flash[:notice] = 'Environment was successfully removed.' + else + flash[:alert] = 'Failed to remove environment.' + end + + redirect_to namespace_project_environments_path(project.namespace, project) + end + + private + + def create_params + params.require(:environment).permit(:name) + end + + def environment + @environment ||= project.environments.find(params[:id]) + end +end diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index cac440ae53e..127bd1a4318 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -32,7 +32,7 @@ class Projects::PipelinesController < Projects::ApplicationController end def retry - pipeline.retry_failed + pipeline.retry_failed(current_user) redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project) end diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index cdea5f0b776..35d067cd029 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -1,10 +1,12 @@ class Projects::ProjectMembersController < Projects::ApplicationController + include MembershipActions + # Authorize - before_action :authorize_admin_project_member!, except: [:leave, :index] + before_action :authorize_admin_project_member!, except: [:index, :leave, :request_access] def index @project_members = @project.project_members - @project_members = @project_members.non_invite unless can?(current_user, :admin_project, @project) + @project_members = @project_members.non_pending unless can?(current_user, :admin_project, @project) if params[:search].present? users = @project.users.search(params[:search]).to_a @@ -14,9 +16,10 @@ class Projects::ProjectMembersController < Projects::ApplicationController @project_members = @project_members.order('access_level DESC') @group = @project.group + if @group @group_members = @group.group_members - @group_members = @group_members.non_invite unless can?(current_user, :admin_group, @group) + @group_members = @group_members.non_pending unless can?(current_user, :admin_group, @group) if params[:search].present? users = @group.users.search(params[:search]).to_a @@ -73,26 +76,6 @@ class Projects::ProjectMembersController < Projects::ApplicationController end end - def leave - @project_member = @project.project_members.find_by(user_id: current_user) - - if can?(current_user, :destroy_project_member, @project_member) - @project_member.destroy - - respond_to do |format| - format.html { redirect_to dashboard_projects_path, notice: "You left the project." } - format.js { head :ok } - end - else - if current_user == @project.owner - message = 'You can not leave your own project. Transfer or delete the project.' - redirect_back_or_default(default: { action: 'index' }, options: { alert: message }) - else - render_403 - end - end - end - def apply_import source_project = Project.find(params[:source_project_id]) @@ -112,4 +95,11 @@ class Projects::ProjectMembersController < Projects::ApplicationController def member_params params.require(:project_member).permit(:user_id, :access_level) end + + # MembershipActions concern + alias_method :membershipable, :project + + def cannot_leave? + current_user == @project.owner + end end diff --git a/app/controllers/projects/todos_controller.rb b/app/controllers/projects/todos_controller.rb new file mode 100644 index 00000000000..a51bd5e2b49 --- /dev/null +++ b/app/controllers/projects/todos_controller.rb @@ -0,0 +1,31 @@ +class Projects::TodosController < Projects::ApplicationController + def create + todos = TodoService.new.mark_todo(issuable, current_user) + + render json: { + todo: todos, + count: current_user.todos.pending.count, + } + end + + def update + current_user.todos.find_by_id(params[:id]).update(state: :done) + + render json: { + count: current_user.todos.pending.count, + } + end + + private + + def issuable + @issuable ||= begin + case params[:issuable_type] + when "issue" + @project.issues.find(params[:issuable_id]) + when "merge_request" + @project.merge_requests.find(params[:issuable_id]) + end + end + end +end diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index 2aa6bed0724..7ec1e73b3be 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -16,6 +16,9 @@ class Projects::WikisController < Projects::ApplicationController if @page render 'show' elsif file = @project_wiki.find_file(params[:id], params[:version_id]) + response.headers['Content-Security-Policy'] = "default-src 'none'" + response.headers['X-Content-Security-Policy'] = "default-src 'none'" + if file.on_disk? send_file file.on_disk_path, disposition: 'inline' else diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index dae8f7b1447..17aed816cbd 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -40,7 +40,7 @@ class SessionsController < Devise::SessionsController # Handle an "initial setup" state, where there's only one user, it's an admin, # and they require a password change. def check_initial_setup - return unless User.count == 1 + return unless User.limit(2).count == 1 # Count as much 2 to know if we have exactly one user = User.admins.last diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb index ee14ac60fb4..0b7832e6583 100644 --- a/app/finders/notes_finder.rb +++ b/app/finders/notes_finder.rb @@ -12,7 +12,7 @@ class NotesFinder when "commit" project.notes.for_commit_id(target_id).non_diff_notes when "issue" - project.issues.find(target_id).notes.inc_author + project.issues.visible_to_user(current_user).find(target_id).notes.inc_author when "merge_request" project.merge_requests.find(target_id).mr_and_commit_notes.inc_author when "snippet", "project_snippet" diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index 1d88116d7d2..aa47c6c157e 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -36,7 +36,7 @@ class TodosFinder private def action_id? - action_id.present? && [Todo::ASSIGNED, Todo::MENTIONED, Todo::BUILD_FAILED].include?(action_id.to_i) + action_id.present? && [Todo::ASSIGNED, Todo::MENTIONED, Todo::BUILD_FAILED, Todo::MARKED].include?(action_id.to_i) end def action_id diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index cec2dc753fe..85559fbc5f5 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -116,7 +116,7 @@ module BlobHelper end def blob_text_viewable?(blob) - blob && blob.text? && !blob.lfs_pointer? + blob && blob.text? && !blob.lfs_pointer? && !blob.only_display_raw? end def blob_size(blob) diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index d328f56c80c..493505e0c95 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -129,7 +129,7 @@ module CommitsHelper tooltip = "Revert this #{commit.change_type_title} in a new merge request" if has_tooltip if can_collaborate_with_project? - btn_class = "btn btn-grouped btn-close btn-#{btn_class}" unless btn_class.nil? + btn_class = "btn btn-warning btn-#{btn_class}" unless btn_class.nil? link_to 'Revert', '#modal-revert-commit', 'data-toggle' => 'modal', 'data-container' => 'body', title: (tooltip if has_tooltip), class: "#{btn_class} #{'has-tooltip' if has_tooltip}" elsif can?(current_user, :fork_project, @project) continue_params = { @@ -141,7 +141,7 @@ module CommitsHelper namespace_key: current_user.namespace.id, continue: continue_params) - btn_class = "btn btn-grouped btn-close" unless btn_class.nil? + btn_class = "btn btn-grouped btn-warning" unless btn_class.nil? link_to 'Revert', fork_path, class: btn_class, method: :post, 'data-toggle' => 'tooltip', 'data-container' => 'body', title: (tooltip if has_tooltip) end @@ -153,7 +153,7 @@ module CommitsHelper tooltip = "Cherry-pick this #{commit.change_type_title} in a new merge request" if can_collaborate_with_project? - btn_class = "btn btn-default btn-grouped btn-#{btn_class}" unless btn_class.nil? + btn_class = "btn btn-default btn-#{btn_class}" unless btn_class.nil? link_to 'Cherry-pick', '#modal-cherry-pick-commit', 'data-toggle' => 'modal', 'data-container' => 'body', title: (tooltip if has_tooltip), class: "#{btn_class} #{'has-tooltip' if has_tooltip}" elsif can?(current_user, :fork_project, @project) continue_params = { diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index 2ce2d4e694f..5386ddadc62 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -13,10 +13,23 @@ # merge_request_path(merge_request) # module GitlabRoutingHelper + # Project def project_path(project, *args) namespace_project_path(project.namespace, project, *args) end + def project_url(project, *args) + namespace_project_url(project.namespace, project, *args) + end + + def edit_project_path(project, *args) + edit_namespace_project_path(project.namespace, project, *args) + end + + def edit_project_url(project, *args) + edit_namespace_project_url(project.namespace, project, *args) + end + def project_files_path(project, *args) namespace_project_tree_path(project.namespace, project, @ref || project.repository.root_ref) end @@ -29,6 +42,10 @@ module GitlabRoutingHelper namespace_project_pipelines_path(project.namespace, project, *args) end + def project_environments_path(project, *args) + namespace_project_environments_path(project.namespace, project, *args) + end + def project_builds_path(project, *args) namespace_project_builds_path(project.namespace, project, *args) end @@ -41,10 +58,6 @@ module GitlabRoutingHelper activity_namespace_project_path(project.namespace, project, *args) end - def edit_project_path(project, *args) - edit_namespace_project_path(project.namespace, project, *args) - end - def runners_path(project, *args) namespace_project_runners_path(project.namespace, project, *args) end @@ -65,14 +78,6 @@ module GitlabRoutingHelper namespace_project_milestone_path(entity.project.namespace, entity.project, entity, *args) end - def project_url(project, *args) - namespace_project_url(project.namespace, project, *args) - end - - def edit_project_url(project, *args) - edit_namespace_project_url(project.namespace, project, *args) - end - def issue_url(entity, *args) namespace_project_issue_url(entity.project.namespace, entity.project, entity, *args) end @@ -92,4 +97,56 @@ module GitlabRoutingHelper toggle_subscription_namespace_project_merge_request_path(entity.project.namespace, entity.project, entity) end end + + ## Members + def project_members_url(project, *args) + namespace_project_project_members_url(project.namespace, project) + end + + def project_member_path(project_member, *args) + namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) + end + + def request_access_project_members_path(project, *args) + request_access_namespace_project_project_members_path(project.namespace, project) + end + + def leave_project_members_path(project, *args) + leave_namespace_project_project_members_path(project.namespace, project) + end + + def approve_access_request_project_member_path(project_member, *args) + approve_access_request_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) + end + + def resend_invite_project_member_path(project_member, *args) + resend_invite_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) + end + + # Groups + + ## Members + def group_members_url(group, *args) + group_group_members_url(group, *args) + end + + def group_member_path(group_member, *args) + group_group_member_path(group_member.source, group_member) + end + + def request_access_group_members_path(group, *args) + request_access_group_group_members_path(group) + end + + def leave_group_members_path(group, *args) + leave_group_group_members_path(group) + end + + def approve_access_request_group_member_path(group_member, *args) + approve_access_request_group_group_member_path(group_member.source, group_member) + end + + def resend_invite_group_member_path(group_member, *args) + resend_invite_group_group_member_path(group_member.source, group_member) + end end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 4cac69c6795..b9211e88473 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -1,24 +1,4 @@ module GroupsHelper - def remove_user_from_group_message(group, member) - if member.user - "Are you sure you want to remove \"#{member.user.name}\" from \"#{group.name}\"?" - else - "Are you sure you want to revoke the invitation for \"#{member.invite_email}\" to join \"#{group.name}\"?" - end - end - - def leave_group_message(group) - "Are you sure you want to leave \"#{group}\" group?" - end - - def should_user_see_group_roles?(user, group) - if user - user.is_admin? || group.members.exists?(user_id: user.id) - else - false - end - end - def can_change_group_visibility_level?(group) can?(current_user, :change_visibility_level, group) end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 40d8ce8a1d3..8dbc51a689f 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -67,6 +67,12 @@ module IssuablesHelper end end + def has_todo(issuable) + unless current_user.nil? + current_user.todos.find_by(target_id: issuable.id, state: :pending) + end + end + private def sidebar_gutter_collapsed? diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb new file mode 100644 index 00000000000..a53828ef4e7 --- /dev/null +++ b/app/helpers/members_helper.rb @@ -0,0 +1,45 @@ +module MembersHelper + # Returns a `<action>_<source>_member` association, e.g.: + # - admin_project_member, update_project_member, destroy_project_member + # - admin_group_member, update_group_member, destroy_group_member + def action_member_permission(action, member) + "#{action}_#{member.type.underscore}".to_sym + end + + def can_see_member_roles?(source:, user: nil) + return false unless user + + user.is_admin? || source.members.exists?(user_id: user.id) + end + + def remove_member_message(member, user: nil) + user = current_user if defined?(current_user) + + text = 'Are you sure you want to ' + action = + if member.request? + if member.user == user + 'withdraw your access request for' + else + "deny #{member.user.name}'s request to join" + end + elsif member.invite? + "revoke the invitation for #{member.invite_email} to join" + else + "remove #{member.user.name} from" + end + + text << action << " the #{member.source.human_name} #{member.real_source_type.humanize(capitalize: false)}?" + end + + def remove_member_title(member) + text = " from #{member.real_source_type.humanize(capitalize: false)}" + + text.prepend(member.request? ? 'Deny access request' : 'Remove user') + end + + def leave_confirmation_message(member_source) + "Are you sure you want to leave the " \ + "\"#{member_source.human_name}\" #{member_source.class.to_s.humanize(capitalize: false)}?" + end +end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 5e5d170a9f3..3b4e431a491 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -1,12 +1,4 @@ module ProjectsHelper - def remove_from_project_team_message(project, member) - if member.user - "You are going to remove #{member.user.name} from #{project.name} project team. Are you sure?" - else - "You are going to revoke the invitation for #{member.invite_email} to join #{project.name} project team. Are you sure?" - end - end - def link_to_project(project) link_to [project.namespace.becomes(Namespace), project], title: h(project.name) do title = content_tag(:span, project.name, class: 'project-name') @@ -115,14 +107,6 @@ module ProjectsHelper end end - def user_max_access_in_project(user_id, project) - level = project.team.max_member_access(user_id) - - if level - Gitlab::Access.options_with_owner.key(level) - end - end - def license_short_name(project) return 'LICENSE' if project.repository.license_key.nil? @@ -156,6 +140,10 @@ module ProjectsHelper nav_tabs << :container_registry end + if can?(current_user, :read_environment, project) + nav_tabs << :environments + end + if can?(current_user, :admin_project, project) nav_tabs << :settings end @@ -286,10 +274,6 @@ module ProjectsHelper end end - def leave_project_message(project) - "Are you sure you want to leave \"#{project.name}\" project?" - end - def new_readme_path ref = @repository.root_ref if @repository ref ||= 'master' diff --git a/app/helpers/time_helper.rb b/app/helpers/time_helper.rb index 8142f733e76..b04b0a5114c 100644 --- a/app/helpers/time_helper.rb +++ b/app/helpers/time_helper.rb @@ -20,7 +20,6 @@ module TimeHelper end end - def date_from_to(from, to) "#{from.to_s(:short)} - #{to.to_s(:short)}" end diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index b4923fbb138..9adf5ef29f7 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -12,6 +12,7 @@ module TodosHelper when Todo::ASSIGNED then 'assigned you' when Todo::MENTIONED then 'mentioned you on' when Todo::BUILD_FAILED then 'The build failed for your' + when Todo::MARKED then 'marked this as a Todo for' end end diff --git a/app/mailers/emails/groups.rb b/app/mailers/emails/groups.rb deleted file mode 100644 index 1c43f95dc8c..00000000000 --- a/app/mailers/emails/groups.rb +++ /dev/null @@ -1,52 +0,0 @@ -module Emails - module Groups - def group_access_granted_email(group_member_id) - @group_member = GroupMember.find(group_member_id) - @group = @group_member.group - - @target_url = group_url(@group) - @current_user = @group_member.user - - mail(to: @group_member.user.notification_email, - subject: subject("Access to group was granted")) - end - - def group_member_invited_email(group_member_id, token) - @group_member = GroupMember.find group_member_id - @group = @group_member.group - @token = token - - @target_url = group_url(@group) - @current_user = @group_member.user - - mail(to: @group_member.invite_email, - subject: "Invitation to join group #{@group.name}") - end - - def group_invite_accepted_email(group_member_id) - @group_member = GroupMember.find group_member_id - return if @group_member.created_by.nil? - - @group = @group_member.group - - @target_url = group_url(@group) - @current_user = @group_member.created_by - - mail(to: @group_member.created_by.notification_email, - subject: subject("Invitation accepted")) - end - - def group_invite_declined_email(group_id, invite_email, access_level, created_by_id) - return if created_by_id.nil? - - @group = Group.find(group_id) - @current_user = @created_by = User.find(created_by_id) - @access_level = access_level - @invite_email = invite_email - - @target_url = group_url(@group) - mail(to: @created_by.notification_email, - subject: subject("Invitation declined")) - end - end -end diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb new file mode 100644 index 00000000000..6dde2e9847d --- /dev/null +++ b/app/mailers/emails/members.rb @@ -0,0 +1,81 @@ +module Emails + module Members + extend ActiveSupport::Concern + include MembersHelper + + included do + helper_method :member_source, :member + end + + def member_access_requested_email(member_source_type, member_id) + @member_source_type = member_source_type + @member_id = member_id + + admins = member_source.members.owners_and_masters.includes(:user).pluck(:notification_email) + + mail(to: admins, + subject: subject("Request to join the #{member_source.human_name} #{member_source.model_name.singular}")) + end + + def member_access_granted_email(member_source_type, member_id) + @member_source_type = member_source_type + @member_id = member_id + + mail(to: member.user.notification_email, + subject: subject("Access to the #{member_source.human_name} #{member_source.model_name.singular} was granted")) + end + + def member_access_denied_email(member_source_type, source_id, user_id) + @member_source_type = member_source_type + @member_source = member_source_class.find(source_id) + requester = User.find(user_id) + + mail(to: requester.notification_email, + subject: subject("Access to the #{member_source.human_name} #{member_source.model_name.singular} was denied")) + end + + def member_invited_email(member_source_type, member_id, token) + @member_source_type = member_source_type + @member_id = member_id + @token = token + + mail(to: member.invite_email, + subject: "Invitation to join the #{member_source.human_name} #{member_source.model_name.singular}") + end + + def member_invite_accepted_email(member_source_type, member_id) + @member_source_type = member_source_type + @member_id = member_id + return unless member.created_by + + mail(to: member.created_by.notification_email, + subject: subject('Invitation accepted')) + end + + def member_invite_declined_email(member_source_type, source_id, invite_email, created_by_id) + return unless created_by_id + + @member_source_type = member_source_type + @member_source = member_source_class.find(source_id) + @invite_email = invite_email + inviter = User.find(created_by_id) + + mail(to: inviter.notification_email, + subject: subject('Invitation declined')) + end + + def member + @member ||= Member.find(@member_id) + end + + def member_source + @member_source ||= member.source + end + + private + + def member_source_class + @member_source_type.classify.constantize + end + end +end diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index fdf1e9f5afc..689fb3e0ffb 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -1,55 +1,5 @@ module Emails module Projects - def project_access_granted_email(project_member_id) - @project_member = ProjectMember.find project_member_id - @project = @project_member.project - - @target_url = namespace_project_url(@project.namespace, @project) - @current_user = @project_member.user - - mail(to: @project_member.user.notification_email, - subject: subject("Access to project was granted")) - end - - def project_member_invited_email(project_member_id, token) - @project_member = ProjectMember.find project_member_id - @project = @project_member.project - @token = token - - @target_url = namespace_project_url(@project.namespace, @project) - @current_user = @project_member.user - - mail(to: @project_member.invite_email, - subject: "Invitation to join project #{@project.name_with_namespace}") - end - - def project_invite_accepted_email(project_member_id) - @project_member = ProjectMember.find project_member_id - return if @project_member.created_by.nil? - - @project = @project_member.project - - @target_url = namespace_project_url(@project.namespace, @project) - @current_user = @project_member.created_by - - mail(to: @project_member.created_by.notification_email, - subject: subject("Invitation accepted")) - end - - def project_invite_declined_email(project_id, invite_email, access_level, created_by_id) - return if created_by_id.nil? - - @project = Project.find(project_id) - @current_user = @created_by = User.find(created_by_id) - @access_level = access_level - @invite_email = invite_email - - @target_url = namespace_project_url(@project.namespace, @project) - - mail(to: @created_by.notification_email, - subject: subject("Invitation declined")) - end - def project_was_moved_email(project_id, user_id, old_path_with_namespace) @current_user = @user = User.find user_id @project = Project.find project_id diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index 1c663bdd521..0cc709f68e4 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -6,13 +6,15 @@ class Notify < BaseMailer include Emails::Notes include Emails::Projects include Emails::Profile - include Emails::Groups include Emails::Builds + include Emails::Members add_template_helper MergeRequestsHelper add_template_helper DiffHelper add_template_helper BlobHelper add_template_helper EmailsHelper + add_template_helper MembersHelper + add_template_helper GitlabRoutingHelper def test_email(recipient_email, subject, body) mail(to: recipient_email, diff --git a/app/models/ability.rb b/app/models/ability.rb index aea946f9224..9c58b956007 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -9,7 +9,6 @@ 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) @@ -19,6 +18,7 @@ class Ability when GroupMember then group_member_abilities(user, subject) when ProjectMember then project_member_abilities(user, subject) when User then user_abilities + when ExternalIssue, Deployment, Environment then project_abilities(user, subject.project) else [] end.concat(global_abilities(user)) end @@ -187,6 +187,8 @@ class Ability project_report_rules elsif team.guest?(user) project_guest_rules + else + [] end end @@ -228,6 +230,8 @@ class Ability :read_build, :read_container_image, :read_pipeline, + :read_environment, + :read_deployment ] end @@ -246,6 +250,8 @@ class Ability :push_code, :create_container_image, :update_container_image, + :create_environment, + :create_deployment ] end @@ -263,6 +269,8 @@ class Ability @project_master_rules ||= project_dev_rules + [ :push_code_to_protected_branches, :update_project_snippet, + :update_environment, + :update_deployment, :admin_milestone, :admin_project_snippet, :admin_project_member, @@ -273,7 +281,9 @@ class Ability :admin_commit_status, :admin_build, :admin_container_image, - :admin_pipeline + :admin_pipeline, + :admin_environment, + :admin_deployment ] end @@ -317,6 +327,8 @@ class Ability unless project.builds_enabled rules += named_abilities('build') rules += named_abilities('pipeline') + rules += named_abilities('environment') + rules += named_abilities('deployment') end unless project.container_registry_enabled @@ -511,10 +523,6 @@ class Ability end end - def external_issue_abilities(user, subject) - project_abilities(user, subject.project) - end - private def restricted_public_level? diff --git a/app/models/blob.rb b/app/models/blob.rb index 0fea6b7f576..4279ea2ce57 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -24,7 +24,7 @@ class Blob < SimpleDelegator end def only_display_raw? - size && size > 5.megabytes + size && truncated? end def svg? diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 6a64ca451f7..764d8e4e136 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -11,6 +11,8 @@ module Ci scope :unstarted, ->() { where(runner_id: nil) } scope :ignore_failures, ->() { where(allow_failure: false) } + scope :with_artifacts, ->() { where.not(artifacts_file: nil) } + scope :with_expired_artifacts, ->() { with_artifacts.where('artifacts_expire_at < ?', Time.now) } mount_uploader :artifacts_file, ArtifactUploader mount_uploader :artifacts_metadata, ArtifactUploader @@ -38,7 +40,7 @@ module Ci new_build.save end - def retry(build) + def retry(build, user = nil) new_build = Ci::Build.new(status: 'pending') new_build.ref = build.ref new_build.tag = build.tag @@ -52,6 +54,7 @@ module Ci new_build.stage = build.stage new_build.stage_idx = build.stage_idx new_build.trigger_request = build.trigger_request + new_build.user = user new_build.save MergeRequests::AddTodoWhenBuildFailsService.new(build.project, nil).close(new_build) new_build @@ -73,6 +76,17 @@ module Ci build.update_coverage build.execute_hooks end + + after_transition any => [:success] do |build| + if build.environment.present? + service = CreateDeploymentService.new(build.project, build.user, + environment: build.environment, + sha: build.sha, + ref: build.ref, + tag: build.tag) + service.execute(build) + end + end end def retryable? @@ -83,10 +97,6 @@ module Ci !self.pipeline.statuses.latest.include?(self) end - def retry - Ci::Build.retry(self) - end - def depends_on_builds # Get builds of the same type latest_builds = self.pipeline.builds.latest @@ -317,7 +327,7 @@ module Ci end def artifacts? - artifacts_file.exists? + !artifacts_expired? && artifacts_file.exists? end def artifacts_metadata? @@ -328,11 +338,15 @@ module Ci Gitlab::Ci::Build::Artifacts::Metadata.new(artifacts_metadata.path, path, **options).to_entry end + def erase_artifacts! + remove_artifacts_file! + remove_artifacts_metadata! + end + def erase(opts = {}) return false unless erasable? - remove_artifacts_file! - remove_artifacts_metadata! + erase_artifacts! erase_trace! update_erased!(opts[:erased_by]) end @@ -345,6 +359,25 @@ module Ci !self.erased_at.nil? end + def artifacts_expired? + artifacts_expire_at && artifacts_expire_at < Time.now + end + + def artifacts_expire_in + artifacts_expire_at - Time.now if artifacts_expire_at + end + + def artifacts_expire_in=(value) + self.artifacts_expire_at = + if value + Time.now + ChronicDuration.parse(value) + end + end + + def keep_artifacts! + self.update(artifacts_expire_at: nil) + end + private def erase_trace! @@ -352,7 +385,7 @@ module Ci end def update_erased!(user = nil) - self.update(erased_by: user, erased_at: Time.now) + self.update(erased_by: user, erased_at: Time.now, artifacts_expire_at: nil) end def yaml_variables diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index a26cb7dd7ee..a5f2ac59001 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -76,8 +76,10 @@ module Ci builds.running_or_pending.each(&:cancel) end - def retry_failed - builds.latest.failed.select(&:retryable?).each(&:retry) + def retry_failed(user) + builds.latest.failed.select(&:retryable?).each do |build| + Ci::Build.retry(build, user) + end end def latest? @@ -164,6 +166,10 @@ module Ci git_commit_message =~ /(\[ci skip\])/ if git_commit_message end + def environments + builds.where.not(environment: nil).success.pluck(:environment).uniq + end + private def build_builds_for_stages(stages, user, status, trigger_request) diff --git a/app/models/concerns/access_requestable.rb b/app/models/concerns/access_requestable.rb new file mode 100644 index 00000000000..eedd32a729f --- /dev/null +++ b/app/models/concerns/access_requestable.rb @@ -0,0 +1,16 @@ +# == AccessRequestable concern +# +# Contains functionality related to objects that can receive request for access. +# +# Used by Project, and Group. +# +module AccessRequestable + extend ActiveSupport::Concern + + def request_access(user) + members.create( + access_level: Gitlab::Access::DEVELOPER, + user: user, + requested_at: Time.now.utc) + end +end diff --git a/app/models/concerns/awardable.rb b/app/models/concerns/awardable.rb index aa4b4201250..539c7c31e30 100644 --- a/app/models/concerns/awardable.rb +++ b/app/models/concerns/awardable.rb @@ -5,7 +5,7 @@ module Awardable has_many :award_emoji, as: :awardable, dependent: :destroy if self < Participable - participant :award_emoji + participant :award_emoji_with_associations end end @@ -34,8 +34,12 @@ module Awardable end end + def award_emoji_with_associations + award_emoji.includes(:user) + end + def grouped_awards(with_thumbs: true) - awards = award_emoji.group_by(&:name) + awards = award_emoji_with_associations.group_by(&:name) if with_thumbs awards[AwardEmoji::UPVOTE_NAME] ||= [] diff --git a/app/models/deployment.rb b/app/models/deployment.rb new file mode 100644 index 00000000000..e498ca96e3c --- /dev/null +++ b/app/models/deployment.rb @@ -0,0 +1,29 @@ +class Deployment < ActiveRecord::Base + include InternalId + + belongs_to :project, required: true, validate: true + belongs_to :environment, required: true, validate: true + belongs_to :user + belongs_to :deployable, polymorphic: true + + validates :sha, presence: true + validates :ref, presence: true + + delegate :name, to: :environment, prefix: true + + def commit + project.commit(sha) + end + + def commit_title + commit.try(:title) + end + + def short_sha + Commit.truncate_sha(sha) + end + + def last? + self == environment.last_deployment + end +end diff --git a/app/models/environment.rb b/app/models/environment.rb new file mode 100644 index 00000000000..ac3a571a1f3 --- /dev/null +++ b/app/models/environment.rb @@ -0,0 +1,16 @@ +class Environment < ActiveRecord::Base + belongs_to :project, required: true, validate: true + + has_many :deployments + + validates :name, + presence: true, + uniqueness: { scope: :project_id }, + length: { within: 0..255 }, + format: { with: Gitlab::Regex.environment_name_regex, + message: Gitlab::Regex.environment_name_regex_message } + + def last_deployment + deployments.last + end +end diff --git a/app/models/group.rb b/app/models/group.rb index aec92e335e6..b8dffe9f5b9 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -3,11 +3,12 @@ require 'carrierwave/orm/activerecord' class Group < Namespace include Gitlab::ConfigHelper include Gitlab::VisibilityLevel + include AccessRequestable include Referable has_many :group_members, dependent: :destroy, as: :source, class_name: 'GroupMember' alias_method :members, :group_members - has_many :users, through: :group_members + has_many :users, -> { where(members: { requested_at: nil }) }, through: :group_members has_many :project_group_links, dependent: :destroy has_many :shared_projects, through: :project_group_links, source: :project has_many :notification_settings, dependent: :destroy, as: :source @@ -58,6 +59,10 @@ class Group < Namespace "#{self.class.reference_prefix}#{name}" end + def web_url + Gitlab::Routing.url_helpers.group_url(self) + end + def human_name name end diff --git a/app/models/member.rb b/app/models/member.rb index d3060f07fc0..cea6d259760 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -26,20 +26,28 @@ class Member < ActiveRecord::Base allow_nil: true } - scope :invite, -> { where(user_id: nil) } - scope :non_invite, -> { where("user_id IS NOT NULL") } + scope :invite, -> { where.not(invite_token: nil) } + scope :non_invite, -> { where(invite_token: nil) } + scope :request, -> { where.not(requested_at: nil) } + scope :non_request, -> { where(requested_at: nil) } + scope :non_pending, -> { non_request.non_invite } + scope :guests, -> { where(access_level: GUEST) } scope :reporters, -> { where(access_level: REPORTER) } scope :developers, -> { where(access_level: DEVELOPER) } scope :masters, -> { where(access_level: MASTER) } scope :owners, -> { where(access_level: OWNER) } + scope :owners_and_masters, -> { where(access_level: [OWNER, MASTER]) } before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? } + after_create :send_invite, if: :invite? - after_create :create_notification_setting, unless: :invite? - after_create :post_create_hook, unless: :invite? - after_update :post_update_hook, unless: :invite? - after_destroy :post_destroy_hook, unless: :invite? + after_create :send_request, if: :request? + after_create :create_notification_setting, unless: :pending? + after_create :post_create_hook, unless: :pending? + after_update :post_update_hook, unless: :pending? + after_destroy :post_destroy_hook, unless: :pending? + after_destroy :post_decline_request, if: :request? delegate :name, :username, :email, to: :user, prefix: true @@ -96,10 +104,31 @@ class Member < ActiveRecord::Base end end + def real_source_type + source_type + end + def invite? self.invite_token.present? end + def request? + requested_at.present? + end + + def pending? + invite? || request? + end + + def accept_request + return false unless request? + + updated = self.update(requested_at: nil) + after_accept_request if updated + + updated + end + def accept_invite!(new_user) return false unless invite? @@ -157,6 +186,10 @@ class Member < ActiveRecord::Base # override in subclass end + def send_request + # override in subclass + end + def post_create_hook system_hook_service.execute_hooks_for(self, :create) end @@ -177,6 +210,14 @@ class Member < ActiveRecord::Base # override in subclass end + def after_accept_request + post_create_hook + end + + def post_decline_request + # override in subclass + end + def system_hook_service SystemHooksService.new end diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index f63a0debf1a..363db877968 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -8,9 +8,6 @@ class GroupMember < Member validates_format_of :source_type, with: /\ANamespace\z/ default_scope { where(source_type: SOURCE_TYPE) } - scope :with_group, ->(group) { where(source_id: group.id) } - scope :with_user, ->(user) { where(user_id: user.id) } - def self.access_level_roles Gitlab::Access.options_with_owner end @@ -23,6 +20,11 @@ class GroupMember < Member access_level end + # Because source_type is `Namespace`... + def real_source_type + 'Group' + end + private def send_invite @@ -31,6 +33,12 @@ class GroupMember < Member super end + def send_request + notification_service.new_group_access_request(self) + + super + end + def post_create_hook notification_service.new_group_member(self) @@ -56,4 +64,10 @@ class GroupMember < Member super end + + def post_decline_request + notification_service.decline_group_access_request(self) + + super + end end diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 46955b430f3..250ee04fd1d 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -11,8 +11,6 @@ class ProjectMember < Member default_scope { where(source_type: SOURCE_TYPE) } scope :in_project, ->(project) { where(source_id: project.id) } - scope :in_projects, ->(projects) { where(source_id: projects.pluck(:id)) } - scope :with_user, ->(user) { where(user_id: user.id) } before_destroy :delete_member_todos @@ -84,7 +82,7 @@ class ProjectMember < Member Gitlab::Access.sym_options end - def access_roles + def access_level_roles Gitlab::Access.options end end @@ -113,6 +111,12 @@ class ProjectMember < Member super end + def send_request + notification_service.new_project_access_request(self) + + super + end + def post_create_hook unless owner? event_service.join_project(self.project, self.user) @@ -148,6 +152,12 @@ class ProjectMember < Member super end + def post_decline_request + notification_service.decline_project_access_request(self) + + super + end + def event_service EventCreateService.new end diff --git a/app/models/note.rb b/app/models/note.rb index 58133f1581f..4b6748053ff 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -187,6 +187,10 @@ class Note < ActiveRecord::Base award_emoji_supported? && contains_emoji_only? end + def emoji_awardable? + !system? + end + def clear_blank_line_code! self.line_code = nil if self.line_code.blank? end diff --git a/app/models/project.rb b/app/models/project.rb index dfa99fe0df2..0bb815e64e7 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -5,6 +5,7 @@ class Project < ActiveRecord::Base include Gitlab::ShellAdapter include Gitlab::VisibilityLevel include Gitlab::CurrentSettings + include AccessRequestable include Referable include Sortable include AfterCommitQueue @@ -80,7 +81,7 @@ class Project < ActiveRecord::Base has_one :jira_service, dependent: :destroy has_one :redmine_service, dependent: :destroy has_one :custom_issue_tracker_service, dependent: :destroy - has_one :gitlab_issue_tracker_service, dependent: :destroy + has_one :gitlab_issue_tracker_service, dependent: :destroy, inverse_of: :project has_one :external_wiki_service, dependent: :destroy has_one :forked_project_link, dependent: :destroy, foreign_key: "forked_to_project_id" @@ -102,8 +103,9 @@ class Project < ActiveRecord::Base has_many :snippets, dependent: :destroy, class_name: 'ProjectSnippet' has_many :hooks, dependent: :destroy, class_name: 'ProjectHook' has_many :protected_branches, dependent: :destroy - has_many :project_members, dependent: :destroy, as: :source, class_name: 'ProjectMember' - has_many :users, through: :project_members + has_many :project_members, dependent: :destroy, as: :source, class_name: 'ProjectMember' + alias_method :members, :project_members + has_many :users, -> { where(members: { requested_at: nil }) }, through: :project_members has_many :deploy_keys_projects, dependent: :destroy has_many :deploy_keys, through: :deploy_keys_projects has_many :users_star_projects, dependent: :destroy @@ -125,6 +127,8 @@ class Project < ActiveRecord::Base has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' has_many :variables, dependent: :destroy, class_name: 'Ci::Variable', foreign_key: :gl_project_id has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :gl_project_id + has_many :environments, dependent: :destroy + has_many :deployments, dependent: :destroy accepts_nested_attributes_for :variables, allow_destroy: true @@ -680,16 +684,6 @@ class Project < ActiveRecord::Base end end - def project_member_by_name_or_email(name = nil, email = nil) - user = users.find_by('name like ? or email like ?', name, email) - project_members.where(user: user) if user - end - - # Get Team Member record by user id - def project_member_by_id(user_id) - project_members.find_by(user_id: user_id) - end - def name_with_namespace @name_with_namespace ||= begin if namespace @@ -699,6 +693,7 @@ class Project < ActiveRecord::Base end end end + alias_method :human_name, :name_with_namespace def path_with_namespace if namespace diff --git a/app/models/project_team.rb b/app/models/project_team.rb index e29e854860a..73e736820af 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -21,23 +21,13 @@ class ProjectTeam end end - def find(user_id) - user = project.users.find_by(id: user_id) - - if group - user ||= group.users.find_by(id: user_id) - end - - user - end - def find_member(user_id) - member = project.project_members.find_by(user_id: user_id) + member = project.members.non_request.find_by(user_id: user_id) # If user is not in project members # we should check for group membership if group && !member - member = group.group_members.find_by(user_id: user_id) + member = group.members.non_request.find_by(user_id: user_id) end member @@ -61,13 +51,10 @@ class ProjectTeam ProjectMember.truncate_team(project) end - def users - members - end - def members @members ||= fetch_members end + alias_method :users, :members def guests @guests ||= fetch_members(:guests) @@ -150,7 +137,7 @@ class ProjectTeam def max_member_access(user_id) access = [] - project.project_members.each do |member| + project.members.non_request.each do |member| if member.user_id == user_id access << member.access_field if member.access_field break @@ -158,7 +145,7 @@ class ProjectTeam end if group - group.group_members.each do |member| + group.members.non_request.each do |member| if member.user_id == user_id access << member.access_field if member.access_field break @@ -173,6 +160,7 @@ class ProjectTeam access.compact.max end + private def max_invited_level(user_id) project.project_group_links.map do |group_link| @@ -189,17 +177,15 @@ class ProjectTeam end.compact.max end - private - def fetch_members(level = nil) - project_members = project.project_members - group_members = group ? group.group_members : [] + project_members = project.members.non_request + group_members = group ? group.members.non_request : [] 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 + im = invited_group.members.non_request if level int_level = GroupMember.access_level_roles[level.to_s.singularize.titleize] diff --git a/app/models/repository.rb b/app/models/repository.rb index 1ab163510bf..e5b277cb198 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -446,7 +446,7 @@ class Repository def blob_at(sha, path) unless Gitlab::Git.blank_ref?(sha) - Gitlab::Git::Blob.find(self, sha, path) + Blob.decorate(Gitlab::Git::Blob.find(self, sha, path)) end end diff --git a/app/models/service.rb b/app/models/service.rb index bf352397509..40d39933ad8 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -18,7 +18,7 @@ class Service < ActiveRecord::Base after_commit :reset_updated_properties after_commit :cache_project_has_external_issue_tracker - belongs_to :project + belongs_to :project, inverse_of: :services has_one :service_hook validates :project_id, presence: true, unless: Proc.new { |service| service.template? } diff --git a/app/models/todo.rb b/app/models/todo.rb index 3a091373329..2792fa9b9a8 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -2,6 +2,7 @@ class Todo < ActiveRecord::Base ASSIGNED = 1 MENTIONED = 2 BUILD_FAILED = 3 + MARKED = 4 belongs_to :author, class_name: "User" belongs_to :note diff --git a/app/models/user.rb b/app/models/user.rb index a5b3c8afe51..8d0427da5ab 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -56,8 +56,7 @@ class User < ActiveRecord::Base # Groups has_many :members, dependent: :destroy - has_many :project_members, source: 'ProjectMember' - has_many :group_members, source: 'GroupMember' + has_many :group_members, dependent: :destroy, source: 'GroupMember' has_many :groups, through: :group_members has_many :owned_groups, -> { where members: { access_level: Gitlab::Access::OWNER } }, through: :group_members, source: :group has_many :masters_groups, -> { where members: { access_level: Gitlab::Access::MASTER } }, through: :group_members, source: :group @@ -65,13 +64,13 @@ class User < ActiveRecord::Base # Projects has_many :groups_projects, through: :groups, source: :projects has_many :personal_projects, through: :namespace, source: :projects + has_many :project_members, dependent: :destroy, class_name: 'ProjectMember' has_many :projects, through: :project_members has_many :created_projects, foreign_key: :creator_id, class_name: 'Project' has_many :users_star_projects, dependent: :destroy has_many :starred_projects, through: :users_star_projects, source: :project has_many :snippets, dependent: :destroy, foreign_key: :author_id, class_name: "Snippet" - has_many :project_members, dependent: :destroy, class_name: 'ProjectMember' has_many :issues, dependent: :destroy, foreign_key: :author_id has_many :notes, dependent: :destroy, foreign_key: :author_id has_many :merge_requests, dependent: :destroy, foreign_key: :author_id diff --git a/app/services/ci/create_builds_service.rb b/app/services/ci/create_builds_service.rb index b2882b23d31..2dcb052d274 100644 --- a/app/services/ci/create_builds_service.rb +++ b/app/services/ci/create_builds_service.rb @@ -35,7 +35,8 @@ module Ci :options, :allow_failure, :stage, - :stage_idx) + :stage_idx, + :environment) build_attrs.merge!(pipeline: @pipeline, ref: @pipeline.ref, diff --git a/app/services/create_deployment_service.rb b/app/services/create_deployment_service.rb new file mode 100644 index 00000000000..efeb9df9527 --- /dev/null +++ b/app/services/create_deployment_service.rb @@ -0,0 +1,18 @@ +require_relative 'base_service' + +class CreateDeploymentService < BaseService + def execute(deployable = nil) + environment = project.environments.find_or_create_by( + name: params[:environment] + ) + + project.deployments.create( + environment: environment, + ref: params[:ref], + tag: params[:tag], + sha: params[:sha], + user: current_user, + deployable: deployable + ) + end +end diff --git a/app/services/git_hooks_service.rb b/app/services/git_hooks_service.rb index 8f5c3393dfc..d7a0c25a044 100644 --- a/app/services/git_hooks_service.rb +++ b/app/services/git_hooks_service.rb @@ -3,7 +3,7 @@ class GitHooksService def execute(user, repo_path, oldrev, newrev, ref) @repo_path = repo_path - @user = Gitlab::ShellEnv.gl_id(user) + @user = Gitlab::GlId.gl_id(user) @oldrev = oldrev @newrev = newrev @ref = ref diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 875a3f4fab6..f804ac171c4 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -173,16 +173,26 @@ class NotificationService end end + # Project access request + def new_project_access_request(project_member) + mailer.member_access_requested_email(project_member.real_source_type, project_member.id).deliver_later + end + + def decline_project_access_request(project_member) + mailer.member_access_denied_email(project_member.real_source_type, project_member.project.id, project_member.user.id).deliver_later + end + def invite_project_member(project_member, token) - mailer.project_member_invited_email(project_member.id, token).deliver_later + mailer.member_invited_email(project_member.real_source_type, project_member.id, token).deliver_later end def accept_project_invite(project_member) - mailer.project_invite_accepted_email(project_member.id).deliver_later + mailer.member_invite_accepted_email(project_member.real_source_type, project_member.id).deliver_later end def decline_project_invite(project_member) - mailer.project_invite_declined_email( + mailer.member_invite_declined_email( + project_member.real_source_type, project_member.project.id, project_member.invite_email, project_member.access_level, @@ -191,23 +201,33 @@ class NotificationService end def new_project_member(project_member) - mailer.project_access_granted_email(project_member.id).deliver_later + mailer.member_access_granted_email(project_member.real_source_type, project_member.id).deliver_later end def update_project_member(project_member) - mailer.project_access_granted_email(project_member.id).deliver_later + mailer.member_access_granted_email(project_member.real_source_type, project_member.id).deliver_later + end + + # Group access request + def new_group_access_request(group_member) + mailer.member_access_requested_email(group_member.real_source_type, group_member.id).deliver_later + end + + def decline_group_access_request(group_member) + mailer.member_access_denied_email(group_member.real_source_type, group_member.group.id, group_member.user.id).deliver_later end def invite_group_member(group_member, token) - mailer.group_member_invited_email(group_member.id, token).deliver_later + mailer.member_invited_email(group_member.real_source_type, group_member.id, token).deliver_later end def accept_group_invite(group_member) - mailer.group_invite_accepted_email(group_member.id).deliver_later + mailer.member_invite_accepted_email(group_member.id).deliver_later end def decline_group_invite(group_member) - mailer.group_invite_declined_email( + mailer.member_invite_declined_email( + group_member.real_source_type, group_member.group.id, group_member.invite_email, group_member.access_level, @@ -216,11 +236,11 @@ class NotificationService end def new_group_member(group_member) - mailer.group_access_granted_email(group_member.id).deliver_later + mailer.member_access_granted_email(group_member.real_source_type, group_member.id).deliver_later end def update_group_member(group_member) - mailer.group_access_granted_email(group_member.id).deliver_later + mailer.member_access_granted_email(group_member.real_source_type, group_member.id).deliver_later end def project_was_moved(project, old_path_with_namespace) diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 8e03ff8ddde..e1f9ea64dc4 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -139,10 +139,16 @@ class TodoService pending_todos(user, attributes).update_all(state: :done) end + # When user marks an issue as todo + def mark_todo(issuable, current_user) + attributes = attributes_for_todo(issuable.project, issuable, current_user, Todo::MARKED) + create_todos(current_user, attributes) + end + private def create_todos(users, attributes) - Array(users).each do |user| + Array(users).map do |user| next if pending_todos(user, attributes).exists? Todo.create(attributes.merge(user_id: user.id)) end diff --git a/app/views/admin/background_jobs/_head.html.haml b/app/views/admin/background_jobs/_head.html.haml new file mode 100644 index 00000000000..d78682532ed --- /dev/null +++ b/app/views/admin/background_jobs/_head.html.haml @@ -0,0 +1,14 @@ +.nav-links.sub-nav + %ul{ class: (container_class) } + = nav_link(controller: :background_jobs) do + = link_to admin_background_jobs_path, title: 'Background Jobs' do + %span + Background Jobs + = nav_link(controller: :logs) do + = link_to admin_logs_path, title: 'Logs' do + %span + Logs + = nav_link(controller: :health_check) do + = link_to admin_health_check_path, title: 'Health Check' do + %span + Health Check diff --git a/app/views/admin/background_jobs/show.html.haml b/app/views/admin/background_jobs/show.html.haml index de5bc050cf0..654d261aa99 100644 --- a/app/views/admin/background_jobs/show.html.haml +++ b/app/views/admin/background_jobs/show.html.haml @@ -1,46 +1,50 @@ +- @no_container = true - page_title "Background Jobs" -%h3.page-title Background Jobs -%p.light GitLab uses #{link_to "sidekiq", "http://sidekiq.org/"} library for async job processing += render 'admin/background_jobs/head' -%hr +%div{ class: (container_class) } + %h3.page-title Background Jobs + %p.light GitLab uses #{link_to "sidekiq", "http://sidekiq.org/"} library for async job processing -.panel.panel-default - .panel-heading Sidekiq running processes - .panel-body - - if @sidekiq_processes.empty? - %h4.cred - %i.fa.fa-exclamation-triangle - There are no running sidekiq processes. Please restart GitLab - - else - .table-holder - %table.table - %thead - %th USER - %th PID - %th CPU - %th MEM - %th STATE - %th START - %th COMMAND - %tbody - - @sidekiq_processes.each do |process| - - next unless process.match(/(sidekiq \d+\.\d+\.\d+.+$)/) - - data = process.strip.split(' ') - %tr - %td= gitlab_config.user - - 5.times do - %td= data.shift - %td= data.join(' ') + %hr - .clearfix - %p - %i.fa.fa-exclamation-circle - If '[25 of 25 busy]' is shown, restart GitLab with 'sudo service gitlab reload'. - %p - %i.fa.fa-exclamation-circle - If more than one sidekiq process is listed, stop GitLab, kill the remaining sidekiq processes (sudo pkill -u #{gitlab_config.user} -f sidekiq) and restart GitLab. + .panel.panel-default + .panel-heading Sidekiq running processes + .panel-body + - if @sidekiq_processes.empty? + %h4.cred + %i.fa.fa-exclamation-triangle + There are no running sidekiq processes. Please restart GitLab + - else + .table-holder + %table.table + %thead + %th USER + %th PID + %th CPU + %th MEM + %th STATE + %th START + %th COMMAND + %tbody + - @sidekiq_processes.each do |process| + - next unless process.match(/(sidekiq \d+\.\d+\.\d+.+$)/) + - data = process.strip.split(' ') + %tr + %td= gitlab_config.user + - 5.times do + %td= data.shift + %td= data.join(' ') + .clearfix + %p + %i.fa.fa-exclamation-circle + If '[25 of 25 busy]' is shown, restart GitLab with 'sudo service gitlab reload'. + %p + %i.fa.fa-exclamation-circle + If more than one sidekiq process is listed, stop GitLab, kill the remaining sidekiq processes (sudo pkill -u #{gitlab_config.user} -f sidekiq) and restart GitLab. -.panel.panel-default - %iframe{src: sidekiq_path, width: '100%', height: 970, style: "border: none"} + + .panel.panel-default + %iframe{src: sidekiq_path, width: '100%', height: 970, style: "border: none"} diff --git a/app/views/admin/builds/index.html.haml b/app/views/admin/builds/index.html.haml index d74cf8598e8..efd5b12cfeb 100644 --- a/app/views/admin/builds/index.html.haml +++ b/app/views/admin/builds/index.html.haml @@ -1,49 +1,54 @@ -.top-area - %ul.nav-links - %li{class: ('active' if @scope.nil?)} - = link_to admin_builds_path do - All - %span.badge.js-totalbuilds-count= @all_builds.count(:id) - - %li{class: ('active' if @scope == 'running')} - = link_to admin_builds_path(scope: :running) do - Running - %span.badge.js-running-count= number_with_delimiter(@all_builds.running_or_pending.count(:id)) - - %li{class: ('active' if @scope == 'finished')} - = link_to admin_builds_path(scope: :finished) do - Finished - %span.badge.js-running-count= number_with_delimiter(@all_builds.finished.count(:id)) - - .nav-controls - - if @all_builds.running_or_pending.any? - = link_to 'Cancel all', cancel_all_admin_builds_path, data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post - -.row-content-block.second-block - #{(@scope || 'all').capitalize} builds - -%ul.content-list - - if @builds.blank? - %li - .nothing-here-block No builds to show - - else - .table-holder - %table.table.builds - %thead - %tr - %th Status - %th Build ID - %th Project - %th Commit - %th Ref - %th Runner - %th Name - %th Tags - %th Duration - %th Finished at - %th - - - @builds.each do |build| - = render "admin/builds/build", build: build - - = paginate @builds, theme: 'gitlab' +- @no_container = true += render "admin/dashboard/head" + +%div{ class: (container_class) } + + .top-area + %ul.nav-links + %li{class: ('active' if @scope.nil?)} + = link_to admin_builds_path do + All + %span.badge.js-totalbuilds-count= @all_builds.count(:id) + + %li{class: ('active' if @scope == 'running')} + = link_to admin_builds_path(scope: :running) do + Running + %span.badge.js-running-count= number_with_delimiter(@all_builds.running_or_pending.count(:id)) + + %li{class: ('active' if @scope == 'finished')} + = link_to admin_builds_path(scope: :finished) do + Finished + %span.badge.js-running-count= number_with_delimiter(@all_builds.finished.count(:id)) + + .nav-controls + - if @all_builds.running_or_pending.any? + = link_to 'Cancel all', cancel_all_admin_builds_path, data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post + + .row-content-block.second-block + #{(@scope || 'all').capitalize} builds + + %ul.content-list + - if @builds.blank? + %li + .nothing-here-block No builds to show + - else + .table-holder + %table.table.builds + %thead + %tr + %th Status + %th Build ID + %th Project + %th Commit + %th Ref + %th Runner + %th Name + %th Tags + %th Duration + %th Finished at + %th + + - @builds.each do |build| + = render "admin/builds/build", build: build + + = paginate @builds, theme: 'gitlab' diff --git a/app/views/admin/dashboard/_head.html.haml b/app/views/admin/dashboard/_head.html.haml new file mode 100644 index 00000000000..7b3f88c24df --- /dev/null +++ b/app/views/admin/dashboard/_head.html.haml @@ -0,0 +1,22 @@ +.nav-links.sub-nav + %ul{ class: (container_class) } + = nav_link(controller: :dashboard, html_options: {class: 'home'}) do + = link_to admin_root_path, title: 'Overview' do + %span + Overview + = nav_link(controller: [:admin, :projects]) do + = link_to admin_namespaces_projects_path, title: 'Projects' do + %span + Projects + = nav_link(controller: :users) do + = link_to admin_users_path, title: 'Users' do + %span + Users + = nav_link(controller: :groups) do + = link_to admin_groups_path, title: 'Groups' do + %span + Groups + = nav_link path: 'builds#index' do + = link_to admin_builds_path, title: 'Builds' do + %span + Builds diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 6dd2fef395d..4682016a886 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -1,155 +1,159 @@ -.admin-dashboard.prepend-top-default - .row - .col-md-4 - %h4 Statistics - %hr - %p - Forks - %span.light.pull-right - = number_with_delimiter(ForkedProjectLink.count) - %p - Issues - %span.light.pull-right - = number_with_delimiter(Issue.count) - %p - Merge Requests - %span.light.pull-right - = number_with_delimiter(MergeRequest.count) - %p - Notes - %span.light.pull-right - = number_with_delimiter(Note.count) - %p - Snippets - %span.light.pull-right - = number_with_delimiter(Snippet.count) - %p - SSH Keys - %span.light.pull-right - = number_with_delimiter(Key.count) - %p - Milestones - %span.light.pull-right - = number_with_delimiter(Milestone.count) - %p - Active Users - %span.light.pull-right - = number_with_delimiter(User.active.count) - .col-md-4 - %h4 - Features - %hr - %p - Sign up - %span.light.pull-right - = boolean_to_icon signup_enabled? - %p - LDAP - %span.light.pull-right - = boolean_to_icon Gitlab.config.ldap.enabled - %p - Gravatar - %span.light.pull-right - = boolean_to_icon gravatar_enabled? - %p - OmniAuth - %span.light.pull-right - = boolean_to_icon Gitlab.config.omniauth.enabled - %p - Reply by email - %span.light.pull-right - = boolean_to_icon Gitlab::IncomingEmail.enabled? - .col-md-4 - %h4 - Components - - if current_application_settings.version_check_enabled - .pull-right - = version_status_badge +- @no_container = true += render "admin/dashboard/head" - %hr - %p - GitLab - %span.pull-right - = Gitlab::VERSION - %p - GitLab Shell - %span.pull-right - = Gitlab::Shell.new.version - %p - GitLab API - %span.pull-right - = API::API::version - %p - Git - %span.pull-right - = Gitlab::Git.version - %p - Ruby - %span.pull-right - #{RUBY_VERSION}p#{RUBY_PATCHLEVEL} - - %p - Rails - %span.pull-right - #{Rails::VERSION::STRING} - - %p - = Gitlab::Database.adapter_name - %span.pull-right - = Gitlab::Database.version - %hr - .row - .col-sm-4 - .light-well - %h4 Projects - .data - = link_to admin_namespaces_projects_path do - %h1= number_with_delimiter(Project.count) - %hr - = link_to('New Project', new_project_path, class: "btn btn-new") - .col-sm-4 - .light-well - %h4 Users - .data - = link_to admin_users_path do - %h1= number_with_delimiter(User.count) - %hr - = link_to 'New User', new_admin_user_path, class: "btn btn-new" - .col-sm-4 - .light-well - %h4 Groups - .data - = link_to admin_groups_path do - %h1= number_with_delimiter(Group.count) - %hr - = link_to 'New Group', new_admin_group_path, class: "btn btn-new" - - .row.prepend-top-10 - .col-md-4 - %h4 Latest projects - %hr - - @projects.each do |project| +%div{ class: (container_class) } + .admin-dashboard.prepend-top-default + .row + .col-md-4 + %h4 Statistics + %hr %p - = link_to project.name_with_namespace, [:admin, project.namespace.becomes(Namespace), project], class: 'str-truncated' + Forks %span.light.pull-right - #{time_ago_with_tooltip(project.created_at)} - - .col-md-4 - %h4 Latest users - %hr - - @users.each do |user| + = number_with_delimiter(ForkedProjectLink.count) %p - = link_to [:admin, user], class: 'str-truncated' do - = user.name + Issues %span.light.pull-right - #{time_ago_with_tooltip(user.created_at)} - - .col-md-4 - %h4 Latest groups - %hr - - @groups.each do |group| + = number_with_delimiter(Issue.count) + %p + Merge Requests + %span.light.pull-right + = number_with_delimiter(MergeRequest.count) + %p + Notes + %span.light.pull-right + = number_with_delimiter(Note.count) + %p + Snippets + %span.light.pull-right + = number_with_delimiter(Snippet.count) + %p + SSH Keys + %span.light.pull-right + = number_with_delimiter(Key.count) + %p + Milestones + %span.light.pull-right + = number_with_delimiter(Milestone.count) + %p + Active Users + %span.light.pull-right + = number_with_delimiter(User.active.count) + .col-md-4 + %h4 + Features + %hr + %p + Sign up + %span.light.pull-right + = boolean_to_icon signup_enabled? %p - = link_to [:admin, group], class: 'str-truncated' do - = group.name + LDAP %span.light.pull-right - #{time_ago_with_tooltip(group.created_at)} + = boolean_to_icon Gitlab.config.ldap.enabled + %p + Gravatar + %span.light.pull-right + = boolean_to_icon gravatar_enabled? + %p + OmniAuth + %span.light.pull-right + = boolean_to_icon Gitlab.config.omniauth.enabled + %p + Reply by email + %span.light.pull-right + = boolean_to_icon Gitlab::IncomingEmail.enabled? + .col-md-4 + %h4 + Components + - if current_application_settings.version_check_enabled + .pull-right + = version_status_badge + + %hr + %p + GitLab + %span.pull-right + = Gitlab::VERSION + %p + GitLab Shell + %span.pull-right + = Gitlab::Shell.new.version + %p + GitLab API + %span.pull-right + = API::API::version + %p + Git + %span.pull-right + = Gitlab::Git.version + %p + Ruby + %span.pull-right + #{RUBY_VERSION}p#{RUBY_PATCHLEVEL} + + %p + Rails + %span.pull-right + #{Rails::VERSION::STRING} + + %p + = Gitlab::Database.adapter_name + %span.pull-right + = Gitlab::Database.version + %hr + .row + .col-sm-4 + .light-well + %h4 Projects + .data + = link_to admin_namespaces_projects_path do + %h1= number_with_delimiter(Project.count) + %hr + = link_to('New Project', new_project_path, class: "btn btn-new") + .col-sm-4 + .light-well + %h4 Users + .data + = link_to admin_users_path do + %h1= number_with_delimiter(User.count) + %hr + = link_to 'New User', new_admin_user_path, class: "btn btn-new" + .col-sm-4 + .light-well + %h4 Groups + .data + = link_to admin_groups_path do + %h1= number_with_delimiter(Group.count) + %hr + = link_to 'New Group', new_admin_group_path, class: "btn btn-new" + + .row.prepend-top-10 + .col-md-4 + %h4 Latest projects + %hr + - @projects.each do |project| + %p + = link_to project.name_with_namespace, [:admin, project.namespace.becomes(Namespace), project], class: 'str-truncated' + %span.light.pull-right + #{time_ago_with_tooltip(project.created_at)} + + .col-md-4 + %h4 Latest users + %hr + - @users.each do |user| + %p + = link_to [:admin, user], class: 'str-truncated' do + = user.name + %span.light.pull-right + #{time_ago_with_tooltip(user.created_at)} + + .col-md-4 + %h4 Latest groups + %hr + - @groups.each do |group| + %p + = link_to [:admin, group], class: 'str-truncated' do + = group.name + %span.light.pull-right + #{time_ago_with_tooltip(group.created_at)} diff --git a/app/views/admin/groups/index.html.haml b/app/views/admin/groups/index.html.haml index 775072a7441..4f1996ef7ab 100644 --- a/app/views/admin/groups/index.html.haml +++ b/app/views/admin/groups/index.html.haml @@ -1,41 +1,45 @@ +- @no_container = true - page_title "Groups" -%h3.page-title - Groups (#{number_with_delimiter(@groups.total_count)}) += render "admin/dashboard/head" -%p.light - Group allows you to keep projects organized. - Use groups for uniting related projects. +%div{ class: (container_class) } + %h3.page-title + Groups (#{number_with_delimiter(@groups.total_count)}) -.top-area - .nav-search - = form_tag admin_groups_path, method: :get, class: 'form-inline' do - = hidden_field_tag :sort, @sort - = text_field_tag :name, params[:name], class: "form-control" - = button_tag "Search", class: "btn submit btn-primary" + %p.light + Group allows you to keep projects organized. + Use groups for uniting related projects. - .nav-controls - .dropdown.inline - %a.dropdown-toggle.btn{href: '#', "data-toggle" => "dropdown"} - %span.light - - if @sort.present? - = sort_options_hash[@sort] - - else - = sort_title_recently_created - %b.caret - %ul.dropdown-menu - %li - = link_to admin_groups_path(sort: sort_value_recently_created) do + .top-area + .nav-search + = form_tag admin_groups_path, method: :get, class: 'form-inline' do + = hidden_field_tag :sort, @sort + = text_field_tag :name, params[:name], class: "form-control" + = button_tag "Search", class: "btn submit btn-primary" + + .nav-controls + .dropdown.inline + %a.dropdown-toggle.btn{href: '#', "data-toggle" => "dropdown"} + %span.light + - if @sort.present? + = sort_options_hash[@sort] + - else = sort_title_recently_created - = link_to admin_groups_path(sort: sort_value_oldest_created) do - = sort_title_oldest_created - = link_to admin_groups_path(sort: sort_value_recently_updated) do - = sort_title_recently_updated - = link_to admin_groups_path(sort: sort_value_oldest_updated) do - = sort_title_oldest_updated - = link_to 'New Group', new_admin_group_path, class: "btn btn-new" + %b.caret + %ul.dropdown-menu + %li + = link_to admin_groups_path(sort: sort_value_recently_created) do + = sort_title_recently_created + = link_to admin_groups_path(sort: sort_value_oldest_created) do + = sort_title_oldest_created + = link_to admin_groups_path(sort: sort_value_recently_updated) do + = sort_title_recently_updated + = link_to admin_groups_path(sort: sort_value_oldest_updated) do + = sort_title_oldest_updated + = link_to 'New Group', new_admin_group_path, class: "btn btn-new" -%ul.content-list - - @groups.each do |group| - = render 'group', group: group + %ul.content-list + - @groups.each do |group| + = render 'group', group: group -= paginate @groups, theme: "gitlab" + = paginate @groups, theme: "gitlab" diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml index f309e80a39a..5b8a0262ea0 100644 --- a/app/views/admin/groups/show.html.haml +++ b/app/views/admin/groups/show.html.haml @@ -109,7 +109,7 @@ %span.pull-right.light = member.human_access - if can?(current_user, :destroy_group_member, member) - = link_to group_group_member_path(@group, member), data: { confirm: remove_user_from_group_message(@group, member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do + = link_to group_group_member_path(@group, member), data: { confirm: remove_member_message(member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do %i.fa.fa-minus.fa-inverse .panel-footer = paginate @members, param_name: 'members_page', theme: 'gitlab' diff --git a/app/views/admin/health_check/show.html.haml b/app/views/admin/health_check/show.html.haml index c2313986a7f..7b8407f9152 100644 --- a/app/views/admin/health_check/show.html.haml +++ b/app/views/admin/health_check/show.html.haml @@ -1,49 +1,52 @@ +- @no_container = true - page_title "Health Check" += render 'admin/background_jobs/head' -%h3.page-title - Health Check -.bs-callout.clearfix - .pull-left - %p - Access token is - %code#health-check-token= current_application_settings.health_check_access_token - = button_to reset_health_check_token_admin_application_settings_path, - method: :put, class: 'btn btn-default', - data: { confirm: 'Are you sure you want to reset the health check token?' } do - = icon('refresh') - Reset health check access token -%p.light - Health information can be retrieved as plain text, JSON, or XML using: - %ul - %li - %code= health_check_url(token: current_application_settings.health_check_access_token) - %li - %code= health_check_url(token: current_application_settings.health_check_access_token, format: :json) - %li - %code= health_check_url(token: current_application_settings.health_check_access_token, format: :xml) +%div{ class: (container_class) } + %h3.page-title + Health Check + .bs-callout.clearfix + .pull-left + %p + Access token is + %code#health-check-token= current_application_settings.health_check_access_token + = button_to reset_health_check_token_admin_application_settings_path, + method: :put, class: 'btn btn-default', + data: { confirm: 'Are you sure you want to reset the health check token?' } do + = icon('refresh') + Reset health check access token + %p.light + Health information can be retrieved as plain text, JSON, or XML using: + %ul + %li + %code= health_check_url(token: current_application_settings.health_check_access_token) + %li + %code= health_check_url(token: current_application_settings.health_check_access_token, format: :json) + %li + %code= health_check_url(token: current_application_settings.health_check_access_token, format: :xml) -%p.light - You can also ask for the status of specific services: - %ul - %li - %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :cache) - %li - %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :database) - %li - %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :migrations) + %p.light + You can also ask for the status of specific services: + %ul + %li + %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :cache) + %li + %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :database) + %li + %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :migrations) -%hr -.panel.panel-default - .panel-heading - Current Status: - - if @errors.blank? - = icon('circle', class: 'cgreen') - Healthy - - else - = icon('warning', class: 'cred') - Unhealthy - .panel-body - - if @errors.blank? - No Health Problems Detected - - else - = @errors + %hr + .panel.panel-default + .panel-heading + Current Status: + - if @errors.blank? + = icon('circle', class: 'cgreen') + Healthy + - else + = icon('warning', class: 'cred') + Unhealthy + .panel-body + - if @errors.blank? + No Health Problems Detected + - else + = @errors diff --git a/app/views/admin/logs/show.html.haml b/app/views/admin/logs/show.html.haml index 698feb571ac..5ddc3b9ea85 100644 --- a/app/views/admin/logs/show.html.haml +++ b/app/views/admin/logs/show.html.haml @@ -1,28 +1,32 @@ +- @no_container = true - page_title "Logs" - loggers = [Gitlab::GitLogger, Gitlab::AppLogger, Gitlab::ProductionLogger, Gitlab::SidekiqLogger, Gitlab::RepositoryCheckLogger] -%ul.nav-links.log-tabs - - loggers.each do |klass| - %li{ class: (klass == Gitlab::GitLogger ? 'active' : '') } - = link_to klass::file_name, "##{klass::file_name_noext}", - 'data-toggle' => 'tab' -.row-content-block - To prevent performance issues admin logs output the last 2000 lines -.tab-content - - loggers.each do |klass| - .tab-pane{ class: (klass == Gitlab::GitLogger ? 'active' : ''), - id: klass::file_name_noext } - .file-holder#README - .file-title - %i.fa.fa-file - = klass::file_name - .pull-right - = link_to '#', class: 'log-bottom' do - %i.fa.fa-arrow-down - Scroll down - .file-content.logs - %ol - - klass.read_latest.each do |line| - %li - %p= line += render 'admin/background_jobs/head' + +%div{ class: (container_class) } + %ul.nav-links.log-tabs + - loggers.each do |klass| + %li{ class: (klass == Gitlab::GitLogger ? 'active' : '') } + = link_to klass::file_name, "##{klass::file_name_noext}", + 'data-toggle' => 'tab' + .row-content-block + To prevent performance issues admin logs output the last 2000 lines + .tab-content + - loggers.each do |klass| + .tab-pane{ class: (klass == Gitlab::GitLogger ? 'active' : ''), + id: klass::file_name_noext } + .file-holder#README + .file-title + %i.fa.fa-file + = klass::file_name + .pull-right + = link_to '#', class: 'log-bottom' do + %i.fa.fa-arrow-down + Scroll down + .file-content.logs + %ol + - klass.read_latest.each do |line| + %li + %p= line diff --git a/app/views/admin/projects/index.html.haml b/app/views/admin/projects/index.html.haml index aa07afa0d62..4822cb693c2 100644 --- a/app/views/admin/projects/index.html.haml +++ b/app/views/admin/projects/index.html.haml @@ -1,94 +1,97 @@ +- @no_container = true - page_title "Projects" = render 'shared/show_aside' += render "admin/dashboard/head" -.row.prepend-top-default - %aside.col-md-3 - .panel.admin-filter - = form_tag admin_namespaces_projects_path, method: :get, class: '' do - .form-group - = label_tag :name, 'Name:' - = text_field_tag :name, params[:name], class: "form-control" +%div{ class: (container_class) } + .row.prepend-top-default + %aside.col-md-3 + .panel.admin-filter + = form_tag admin_namespaces_projects_path, method: :get, class: '' do + .form-group + = label_tag :name, 'Name:' + = text_field_tag :name, params[:name], class: "form-control" - .form-group - = label_tag :namespace_id, "Namespace" - = namespace_select_tag :namespace_id, selected: params[:namespace_id], class: 'input-large' + .form-group + = label_tag :namespace_id, "Namespace" + = namespace_select_tag :namespace_id, selected: params[:namespace_id], class: 'input-large' - .form-group - %strong Activity - .checkbox - = label_tag :with_push do - = check_box_tag :with_push, 1, params[:with_push] - %span Projects with push events - .checkbox - = label_tag :abandoned do - = check_box_tag :abandoned, 1, params[:abandoned] - %span No activity over 6 month - .checkbox - = label_tag :with_archived do - = check_box_tag :with_archived, 1, params[:with_archived] - %span Show archived projects + .form-group + %strong Activity + .checkbox + = label_tag :with_push do + = check_box_tag :with_push, 1, params[:with_push] + %span Projects with push events + .checkbox + = label_tag :abandoned do + = check_box_tag :abandoned, 1, params[:abandoned] + %span No activity over 6 month + .checkbox + = label_tag :with_archived do + = check_box_tag :with_archived, 1, params[:with_archived] + %span Show archived projects - %fieldset - %strong Visibility level: - .visibility-levels - - Project.visibility_levels.each do |label, level| - .checkbox - %label - = check_box_tag 'visibility_levels[]', level, params[:visibility_levels].present? && params[:visibility_levels].include?(level.to_s) - %span.descr - = visibility_level_icon(level) - = label - %fieldset - %strong Problems - .checkbox - = label_tag :last_repository_check_failed do - = check_box_tag :last_repository_check_failed, 1, params[:last_repository_check_failed] - %span Last repository check failed + %fieldset + %strong Visibility level: + .visibility-levels + - Project.visibility_levels.each do |label, level| + .checkbox + %label + = check_box_tag 'visibility_levels[]', level, params[:visibility_levels].present? && params[:visibility_levels].include?(level.to_s) + %span.descr + = visibility_level_icon(level) + = label + %fieldset + %strong Problems + .checkbox + = label_tag :last_repository_check_failed do + = check_box_tag :last_repository_check_failed, 1, params[:last_repository_check_failed] + %span Last repository check failed - = hidden_field_tag :sort, params[:sort] - = button_tag "Search", class: "btn submit btn-primary" - = link_to "Reset", admin_namespaces_projects_path, class: "btn btn-cancel" + = hidden_field_tag :sort, params[:sort] + = button_tag "Search", class: "btn submit btn-primary" + = link_to "Reset", admin_namespaces_projects_path, class: "btn btn-cancel" - %section.col-md-9 - .panel.panel-default - .panel-heading - Projects (#{@projects.total_count}) - .controls - .dropdown.inline - %button.dropdown-toggle.btn.btn-sm{type: 'button', 'data-toggle' => 'dropdown'} - %span.light - - if @sort.present? - = sort_options_hash[@sort] - - else - = sort_title_recently_created - %b.caret - %ul.dropdown-menu - %li - = link_to admin_namespaces_projects_path(sort: sort_value_recently_created) do + %section.col-md-9 + .panel.panel-default + .panel-heading + Projects (#{@projects.total_count}) + .controls + .dropdown.inline + %button.dropdown-toggle.btn.btn-sm{type: 'button', 'data-toggle' => 'dropdown'} + %span.light + - if @sort.present? + = sort_options_hash[@sort] + - else = sort_title_recently_created - = link_to admin_namespaces_projects_path(sort: sort_value_oldest_created) do - = sort_title_oldest_created - = link_to admin_namespaces_projects_path(sort: sort_value_recently_updated) do - = sort_title_recently_updated - = link_to admin_namespaces_projects_path(sort: sort_value_oldest_updated) do - = sort_title_oldest_updated - = link_to admin_namespaces_projects_path(sort: sort_value_largest_repo) do - = sort_title_largest_repo - = link_to 'New Project', new_project_path, class: "btn btn-sm btn-success" - %ul.well-list - - @projects.each do |project| - %li - .list-item-name - %span{ class: visibility_level_color(project.visibility_level) } - = visibility_level_icon(project.visibility_level) - = link_to project.name_with_namespace, [:admin, project.namespace.becomes(Namespace), project] - .pull-right - - if project.archived - %span.label.label-warning archived - %span.label.label-gray - = repository_size(project) - = link_to 'Edit', edit_namespace_project_path(project.namespace, project), id: "edit_#{dom_id(project)}", class: "btn btn-sm" - = link_to 'Destroy', [project.namespace.becomes(Namespace), project], data: { confirm: remove_project_message(project) }, method: :delete, class: "btn btn-sm btn-remove" - - if @projects.blank? - .nothing-here-block 0 projects matches - = paginate @projects, theme: "gitlab" + %b.caret + %ul.dropdown-menu + %li + = link_to admin_namespaces_projects_path(sort: sort_value_recently_created) do + = sort_title_recently_created + = link_to admin_namespaces_projects_path(sort: sort_value_oldest_created) do + = sort_title_oldest_created + = link_to admin_namespaces_projects_path(sort: sort_value_recently_updated) do + = sort_title_recently_updated + = link_to admin_namespaces_projects_path(sort: sort_value_oldest_updated) do + = sort_title_oldest_updated + = link_to admin_namespaces_projects_path(sort: sort_value_largest_repo) do + = sort_title_largest_repo + = link_to 'New Project', new_project_path, class: "btn btn-sm btn-success" + %ul.well-list + - @projects.each do |project| + %li + .list-item-name + %span{ class: visibility_level_color(project.visibility_level) } + = visibility_level_icon(project.visibility_level) + = link_to project.name_with_namespace, [:admin, project.namespace.becomes(Namespace), project] + .pull-right + - if project.archived + %span.label.label-warning archived + %span.label.label-gray + = repository_size(project) + = link_to 'Edit', edit_namespace_project_path(project.namespace, project), id: "edit_#{dom_id(project)}", class: "btn btn-sm" + = link_to 'Destroy', [project.namespace.becomes(Namespace), project], data: { confirm: remove_project_message(project) }, method: :delete, class: "btn btn-sm btn-remove" + - if @projects.blank? + .nothing-here-block 0 projects matches + = paginate @projects, theme: "gitlab" diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index 73986d21bcf..9e55a562e18 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -142,7 +142,7 @@ %i.fa.fa-pencil-square-o %ul.well-list - @group_members.each do |member| - = render 'groups/group_members/group_member', member: member, show_controls: false + = render 'shared/members/member', member: member, show_controls: false .panel-footer = paginate @group_members, param_name: 'group_members_page', theme: 'gitlab' @@ -172,7 +172,7 @@ %span.light Owner - else %span.light= project_member.human_access - = link_to namespace_project_project_member_path(@project.namespace, @project, project_member), data: { confirm: remove_from_project_team_message(@project, project_member)}, method: :delete, remote: true, class: "btn btn-sm btn-remove" do + = link_to namespace_project_project_member_path(@project.namespace, @project, project_member), data: { confirm: remove_member_message(project_member)}, method: :delete, remote: true, class: "btn btn-sm btn-remove" do %i.fa.fa-times .panel-footer = paginate @project_members, param_name: 'project_members_page', theme: 'gitlab' diff --git a/app/views/admin/users/groups.html.haml b/app/views/admin/users/groups.html.haml index dbecb7bbfd6..b0a709a568a 100644 --- a/app/views/admin/users/groups.html.haml +++ b/app/views/admin/users/groups.html.haml @@ -13,7 +13,7 @@ .pull-right %span.light= group_member.human_access - unless group_member.owner? - = link_to group_group_member_path(group, group_member), data: { confirm: remove_user_from_group_message(group, group_member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do + = link_to group_group_member_path(group, group_member), data: { confirm: remove_member_message(group_member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do %i.fa.fa-times.fa-inverse - else .nothing-here-block This user has no groups. diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml index d6743081c8e..d0a696da64b 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -1,107 +1,110 @@ +- @no_container = true - page_title "Users" = render 'shared/show_aside' += render "admin/dashboard/head" -.admin-filter - %ul.nav-links - %li{class: "#{'active' unless params[:filter]}"} - = link_to admin_users_path do - Active - %small.badge= number_with_delimiter(User.active.count) - %li{class: "#{'active' if params[:filter] == "admins"}"} - = link_to admin_users_path(filter: "admins") do - Admins - %small.badge= number_with_delimiter(User.admins.count) - %li.filter-two-factor-enabled{class: "#{'active' if params[:filter] == 'two_factor_enabled'}"} - = link_to admin_users_path(filter: 'two_factor_enabled') do - 2FA Enabled - %small.badge= number_with_delimiter(User.with_two_factor.count) - %li.filter-two-factor-disabled{class: "#{'active' if params[:filter] == 'two_factor_disabled'}"} - = link_to admin_users_path(filter: 'two_factor_disabled') do - 2FA Disabled - %small.badge= number_with_delimiter(User.without_two_factor.count) - %li.filter-external{class: "#{'active' if params[:filter] == 'external'}"} - = link_to admin_users_path(filter: 'external') do - External - %small.badge= number_with_delimiter(User.external.count) - %li{class: "#{'active' if params[:filter] == "blocked"}"} - = link_to admin_users_path(filter: "blocked") do - Blocked - %small.badge= number_with_delimiter(User.blocked.count) - %li{class: "#{'active' if params[:filter] == "wop"}"} - = link_to admin_users_path(filter: "wop") do - Without projects - %small.badge= number_with_delimiter(User.without_projects.count) +%div{ class: (container_class) } + .admin-filter + %ul.nav-links + %li{class: "#{'active' unless params[:filter]}"} + = link_to admin_users_path do + Active + %small.badge= number_with_delimiter(User.active.count) + %li{class: "#{'active' if params[:filter] == "admins"}"} + = link_to admin_users_path(filter: "admins") do + Admins + %small.badge= number_with_delimiter(User.admins.count) + %li.filter-two-factor-enabled{class: "#{'active' if params[:filter] == 'two_factor_enabled'}"} + = link_to admin_users_path(filter: 'two_factor_enabled') do + 2FA Enabled + %small.badge= number_with_delimiter(User.with_two_factor.count) + %li.filter-two-factor-disabled{class: "#{'active' if params[:filter] == 'two_factor_disabled'}"} + = link_to admin_users_path(filter: 'two_factor_disabled') do + 2FA Disabled + %small.badge= number_with_delimiter(User.without_two_factor.count) + %li.filter-external{class: "#{'active' if params[:filter] == 'external'}"} + = link_to admin_users_path(filter: 'external') do + External + %small.badge= number_with_delimiter(User.external.count) + %li{class: "#{'active' if params[:filter] == "blocked"}"} + = link_to admin_users_path(filter: "blocked") do + Blocked + %small.badge= number_with_delimiter(User.blocked.count) + %li{class: "#{'active' if params[:filter] == "wop"}"} + = link_to admin_users_path(filter: "wop") do + Without projects + %small.badge= number_with_delimiter(User.without_projects.count) - .row-content-block.second-block - .pull-right - .dropdown.inline - %a.dropdown-toggle.btn{href: '#', "data-toggle" => "dropdown"} - %span.light - - if @sort.present? - = sort_options_hash[@sort] - - else - = sort_title_name - %b.caret - %ul.dropdown-menu - %li - = link_to admin_users_path(sort: sort_value_name, filter: params[:filter]) do + .row-content-block.second-block + .pull-right + .dropdown.inline + %a.dropdown-toggle.btn{href: '#', "data-toggle" => "dropdown"} + %span.light + - if @sort.present? + = sort_options_hash[@sort] + - else = sort_title_name - = link_to admin_users_path(sort: sort_value_recently_signin, filter: params[:filter]) do - = sort_title_recently_signin - = link_to admin_users_path(sort: sort_value_oldest_signin, filter: params[:filter]) do - = sort_title_oldest_signin - = link_to admin_users_path(sort: sort_value_recently_created, filter: params[:filter]) do - = sort_title_recently_created - = link_to admin_users_path(sort: sort_value_oldest_created, filter: params[:filter]) do - = sort_title_oldest_created - = link_to admin_users_path(sort: sort_value_recently_updated, filter: params[:filter]) do - = sort_title_recently_updated - = link_to admin_users_path(sort: sort_value_oldest_updated, filter: params[:filter]) do - = sort_title_oldest_updated + %b.caret + %ul.dropdown-menu + %li + = link_to admin_users_path(sort: sort_value_name, filter: params[:filter]) do + = sort_title_name + = link_to admin_users_path(sort: sort_value_recently_signin, filter: params[:filter]) do + = sort_title_recently_signin + = link_to admin_users_path(sort: sort_value_oldest_signin, filter: params[:filter]) do + = sort_title_oldest_signin + = link_to admin_users_path(sort: sort_value_recently_created, filter: params[:filter]) do + = sort_title_recently_created + = link_to admin_users_path(sort: sort_value_oldest_created, filter: params[:filter]) do + = sort_title_oldest_created + = link_to admin_users_path(sort: sort_value_recently_updated, filter: params[:filter]) do + = sort_title_recently_updated + = link_to admin_users_path(sort: sort_value_oldest_updated, filter: params[:filter]) do + = sort_title_oldest_updated - = link_to 'New User', new_admin_user_path, class: "btn btn-new" - = form_tag admin_users_path, method: :get, class: 'form-inline' do - .form-group - = search_field_tag :name, params[:name], placeholder: 'Name, email or username', class: 'form-control', spellcheck: false - = hidden_field_tag "filter", params[:filter] - = button_tag class: 'btn btn-primary' do - %i.fa.fa-search + = link_to 'New User', new_admin_user_path, class: "btn btn-new" + = form_tag admin_users_path, method: :get, class: 'form-inline' do + .form-group + = search_field_tag :name, params[:name], placeholder: 'Name, email or username', class: 'form-control', spellcheck: false + = hidden_field_tag "filter", params[:filter] + = button_tag class: 'btn btn-primary' do + %i.fa.fa-search -.panel.panel-default - %ul.well-list - - @users.each do |user| - %li - .list-item-name - - if user.blocked? - = icon("lock", class: "cred") - - else - = icon("user", class: "cgreen") - = link_to user.name, [:admin, user] - - if user.admin? - %strong.cred (Admin) - - if user.external? - %strong.cred (External) - - if user == current_user - %span.cred It's you! - .pull-right - %span.light - %i.fa.fa-envelope - = mail_to user.email, user.email, class: 'light' - + .panel.panel-default + %ul.well-list + - @users.each do |user| + %li + .list-item-name + - if user.blocked? + = icon("lock", class: "cred") + - else + = icon("user", class: "cgreen") + = link_to user.name, [:admin, user] + - if user.admin? + %strong.cred (Admin) + - if user.external? + %strong.cred (External) + - if user == current_user + %span.cred It's you! .pull-right - = link_to 'Edit', edit_admin_user_path(user), id: "edit_#{dom_id(user)}", class: 'btn-grouped btn btn-xs' - - unless user == current_user - - if user.ldap_blocked? - = link_to '#', title: 'Cannot unblock LDAP blocked users', data: {toggle: 'tooltip'}, class: 'btn-grouped btn btn-xs btn-success disabled' do - %i.fa.fa-lock - Unblock - - elsif user.blocked? - = link_to 'Unblock', unblock_admin_user_path(user), method: :put, class: 'btn-grouped btn btn-xs btn-success' - - else - = link_to 'Block', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: 'btn-grouped btn btn-xs btn-warning' - - if user.access_locked? - = link_to 'Unlock', unlock_admin_user_path(user), method: :put, class: 'btn-grouped btn btn-xs btn-success', data: { confirm: 'Are you sure?' } - - if user.can_be_removed? - = link_to 'Destroy', [:admin, user], data: { confirm: "USER #{user.name} WILL BE REMOVED! All issues, merge requests and groups linked to this user will also be removed! Maybe block the user instead? Are you sure?" }, method: :delete, class: 'btn-grouped btn btn-xs btn-remove' -= paginate @users, theme: "gitlab" + %span.light + %i.fa.fa-envelope + = mail_to user.email, user.email, class: 'light' + + .pull-right + = link_to 'Edit', edit_admin_user_path(user), id: "edit_#{dom_id(user)}", class: 'btn-grouped btn btn-xs' + - unless user == current_user + - if user.ldap_blocked? + = link_to '#', title: 'Cannot unblock LDAP blocked users', data: {toggle: 'tooltip'}, class: 'btn-grouped btn btn-xs btn-success disabled' do + %i.fa.fa-lock + Unblock + - elsif user.blocked? + = link_to 'Unblock', unblock_admin_user_path(user), method: :put, class: 'btn-grouped btn btn-xs btn-success' + - else + = link_to 'Block', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: 'btn-grouped btn btn-xs btn-warning' + - if user.access_locked? + = link_to 'Unlock', unlock_admin_user_path(user), method: :put, class: 'btn-grouped btn btn-xs btn-success', data: { confirm: 'Are you sure?' } + - if user.can_be_removed? + = link_to 'Destroy', [:admin, user], data: { confirm: "USER #{user.name} WILL BE REMOVED! All issues, merge requests and groups linked to this user will also be removed! Maybe block the user instead? Are you sure?" }, method: :delete, class: 'btn-grouped btn btn-xs btn-remove' + = paginate @users, theme: "gitlab" diff --git a/app/views/admin/users/projects.html.haml b/app/views/admin/users/projects.html.haml index b655b2a15f5..84b9ceb23b3 100644 --- a/app/views/admin/users/projects.html.haml +++ b/app/views/admin/users/projects.html.haml @@ -38,6 +38,5 @@ %span.light= member.human_access - if member.respond_to? :project - = link_to namespace_project_project_member_path(project.namespace, project, member), data: { confirm: remove_from_project_team_message(project, member) }, remote: true, method: :delete, class: "btn-xs btn btn-remove", title: 'Remove user from project' do + = link_to namespace_project_project_member_path(project.namespace, project, member), data: { confirm: remove_member_message(member) }, remote: true, method: :delete, class: "btn-xs btn btn-remove", title: 'Remove user from project' do %i.fa.fa-times - diff --git a/app/views/groups/group_members/_group_member.html.haml b/app/views/groups/group_members/_group_member.html.haml deleted file mode 100644 index 6bb542e658d..00000000000 --- a/app/views/groups/group_members/_group_member.html.haml +++ /dev/null @@ -1,57 +0,0 @@ -- user = member.user -- return unless user || member.invite? -- show_roles = local_assigns.fetch(:show_roles, true) - -%li{class: "#{dom_class(member)} js-toggle-container", id: dom_id(member)} - %span{class: ("list-item-name" if show_controls)} - - if member.user - = image_tag avatar_icon(user, 24), class: "avatar s24", alt: '' - %strong - = link_to user.name, user_path(user) - %span.cgray= user.username - - if user == current_user - %span.label.label-success It's you - - if user.blocked? - %label.label.label-danger - %strong Blocked - - else - = image_tag avatar_icon(member.invite_email, 24), class: "avatar s24", alt: '' - %strong - = member.invite_email - %span.cgray - invited - - if member.created_by - by - = link_to member.created_by.name, user_path(member.created_by) - = time_ago_with_tooltip(member.created_at) - - - if show_controls && can?(current_user, :admin_group_member, @group) - = link_to resend_invite_group_group_member_path(@group, member), method: :post, class: "btn-xs btn", title: 'Resend invite' do - Resend invite - - - if show_roles && should_user_see_group_roles?(current_user, @group) - %span.pull-right - %strong.member-access-level= member.human_access - - if show_controls - - if can?(current_user, :update_group_member, member) - = button_tag class: "btn-xs btn btn-grouped inline js-toggle-button", - title: 'Edit access level', type: 'button' do - = icon('pencil') - - - if can?(current_user, :destroy_group_member, member) - - - if current_user == user - = link_to leave_group_group_members_path(@group), data: { confirm: leave_group_message(@group.name)}, method: :delete, class: "btn-xs btn btn-remove", title: 'Remove user from group' do - = icon("sign-out") - Leave - - else - = link_to group_group_member_path(@group, member), data: { confirm: remove_user_from_group_message(@group, member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do - = icon('trash') - - .edit-member.hide.js-toggle-content - %br - = form_for [@group, member], remote: true do |f| - .prepend-top-10 - = f.select :access_level, options_for_select(GroupMember.access_level_roles, member.access_level), {}, class: 'form-control' - .prepend-top-10 - = f.submit 'Save', class: 'btn btn-save btn-sm' diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index 0eb6bbd4420..a36531e095a 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -6,12 +6,13 @@ .panel-heading Add new user to group .panel-body - - if should_user_see_group_roles?(current_user, @group) - %p.light - Members of group have access to all group projects. + %p.light + Members of group have access to all group projects. .new-group-member-holder = render "new_group_member" + = render 'shared/members/requests', membership_source: @group, members: @members.request + .panel.panel-default .panel-heading %strong #{@group.name} @@ -25,9 +26,8 @@ = button_tag class: 'btn', title: 'Search' do = icon("search") %ul.content-list - - @members.each do |member| - = render 'groups/group_members/group_member', member: member, show_controls: true - = paginate @members, theme: 'gitlab' + = render partial: 'shared/members/member', collection: @members.non_request, as: :member + = paginate @members.non_request, theme: 'gitlab' :javascript $('form.member-search-form').on('submit', function(event) { diff --git a/app/views/groups/group_members/update.js.haml b/app/views/groups/group_members/update.js.haml index df726e2b2b9..b0b3a51ce58 100644 --- a/app/views/groups/group_members/update.js.haml +++ b/app/views/groups/group_members/update.js.haml @@ -1,2 +1,2 @@ :plain - $("##{dom_id(@group_member)}").replaceWith('#{escape_javascript(render(@group_member, member: @group_member, show_controls: true))}'); + $("##{dom_id(@group_member)}").replaceWith('#{escape_javascript(render(@group_member, member: @group_member))}'); diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 85635bc4616..62ebd69485c 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -19,6 +19,9 @@ .cover-desc.description = markdown(@group.description, pipeline: :description) + - if current_user + = render 'shared/members/access_request_buttons', source: @group + %div{ class: container_class } .top-area %ul.nav-links diff --git a/app/views/layouts/admin.html.haml b/app/views/layouts/admin.html.haml index 6591c52bdbd..87064cc9b3f 100644 --- a/app/views/layouts/admin.html.haml +++ b/app/views/layouts/admin.html.haml @@ -1,5 +1,5 @@ - page_title "Admin Area" - header_title "Admin Area", admin_root_path -- sidebar "admin" +- nav "admin" = render template: "layouts/application" diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index ad30a367fc5..ef31520f5cb 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -27,9 +27,8 @@ %li = link_to dashboard_todos_path, title: 'Todos', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = icon('bell fw') - - unless todos_pending_count == 0 - %span.badge.todos-pending-count - = todos_pending_count + %span.badge.todos-pending-count{ class: ("hidden" if todos_pending_count == 0) } + = todos_pending_count - if current_user.can_create_project? %li = link_to new_project_path, title: 'New project', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do @@ -51,7 +50,7 @@ %h1.title= title .header-logo - #logo + = link_to root_path, class: 'home', title: 'Dashboard', id: 'logo' do = brand_header_logo = yield :header_content diff --git a/app/views/layouts/nav/_admin.html.haml b/app/views/layouts/nav/_admin.html.haml index f292730fe45..54aa34bee0b 100644 --- a/app/views/layouts/nav/_admin.html.haml +++ b/app/views/layouts/nav/_admin.html.haml @@ -1,107 +1,64 @@ -%ul.nav.nav-sidebar - = nav_link(controller: :dashboard, html_options: {class: 'home'}) do - = link_to admin_root_path, title: 'Overview' do - = icon('dashboard fw') +%ul.nav-links.scrolling-tabs + .fade-left + = nav_link(controller: %w(dashboard admin projects users groups builds), html_options: {class: 'home'}) do + = link_to admin_root_path, title: 'Overview', class: 'shortcuts-tree' do %span Overview - = nav_link(controller: [:admin, :projects]) do - = link_to admin_namespaces_projects_path, title: 'Projects' do - = icon('cube fw') + = nav_link(controller: %w(background_jobs logs health_check)) do + = link_to admin_background_jobs_path, title: 'Monitoring' do %span - Projects - = nav_link(controller: :users) do - = link_to admin_users_path, title: 'Users' do - = icon('user fw') - %span - Users - = nav_link(controller: :groups) do - = link_to admin_groups_path, title: 'Groups' do - = icon('group fw') - %span - Groups + Monitoring = nav_link(controller: :deploy_keys) do = link_to admin_deploy_keys_path, title: 'Deploy Keys' do - = icon('key fw') %span Deploy Keys = nav_link path: ['runners#index', 'runners#show'] do = link_to admin_runners_path, title: 'Runners' do - = icon('cog fw') %span Runners - %span.count= number_with_delimiter(Ci::Runner.count(:all)) - = nav_link path: 'builds#index' do - = link_to admin_builds_path, title: 'Builds' do - = icon('link fw') - %span - Builds - %span.count= number_with_delimiter(Ci::Build.count(:all)) - = nav_link(controller: :logs) do - = link_to admin_logs_path, title: 'Logs' do - = icon('file-text fw') - %span - Logs - = nav_link(controller: :health_check) do - = link_to admin_health_check_path, title: 'Health Check' do - = icon('medkit fw') - %span - Health Check = nav_link(controller: :broadcast_messages) do = link_to admin_broadcast_messages_path, title: 'Messages' do - = icon('bullhorn fw') %span Messages = nav_link(controller: :hooks) do = link_to admin_hooks_path, title: 'Hooks' do - = icon('external-link fw') %span Hooks - = nav_link(controller: :background_jobs) do - = link_to admin_background_jobs_path, title: 'Background Jobs' do - = icon('cog fw') - %span - Background Jobs + = nav_link(controller: :appearances) do = link_to admin_appearances_path, title: 'Appearances' do - = icon('image') %span Appearance = nav_link(controller: :applications) do = link_to admin_applications_path, title: 'Applications' do - = icon('cloud fw') %span Applications = nav_link(controller: :services) do = link_to admin_application_settings_services_path, title: 'Service Templates' do - = icon('copy fw') %span Service Templates = nav_link(controller: :labels) do = link_to admin_labels_path, title: 'Labels' do - = icon('tags fw') %span Labels = nav_link(controller: :abuse_reports) do = link_to admin_abuse_reports_path, title: "Abuse Reports" do - = icon('exclamation-circle fw') %span Abuse Reports - %span.count= number_with_delimiter(AbuseReport.count(:all)) + %span.badge.count= number_with_delimiter(AbuseReport.count(:all)) - if askimet_enabled? = nav_link(controller: :spam_logs) do = link_to admin_spam_logs_path, title: "Spam Logs" do - = icon('exclamation-triangle fw') %span Spam Logs - %span.count= number_with_delimiter(SpamLog.count(:all)) = nav_link(controller: :application_settings, html_options: { class: 'separate-item'}) do = link_to admin_application_settings_path, title: 'Settings' do - = icon('cogs fw') %span Settings + .fade-right diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml index 18cae5bf87f..52e41b1a857 100644 --- a/app/views/layouts/nav/_dashboard.html.haml +++ b/app/views/layouts/nav/_dashboard.html.haml @@ -1,54 +1,64 @@ %ul.nav.nav-sidebar = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: {class: "#{project_tab_class} home"}) do = link_to dashboard_projects_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do - = navbar_icon('project') + .icon-container + = navbar_icon('project') %span Projects = nav_link(controller: :todos) do = link_to dashboard_todos_path, title: 'Todos' do - = icon('bell fw') + .icon-container + = icon('bell fw') %span Todos %span.count= number_with_delimiter(todos_pending_count) = nav_link(path: 'dashboard#activity') do = link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', title: 'Activity' do - = navbar_icon('activity') + .icon-container + = navbar_icon('activity') %span Activity = nav_link(controller: [:groups, 'groups/milestones', 'groups/group_members']) do = link_to dashboard_groups_path, title: 'Groups' do - = navbar_icon('group') + .icon-container + = navbar_icon('group') %span Groups = nav_link(controller: 'dashboard/milestones') do = link_to dashboard_milestones_path, title: 'Milestones' do - = navbar_icon('milestones') + .icon-container + = navbar_icon('milestones') %span Milestones = nav_link(path: 'dashboard#issues') do = link_to assigned_issues_dashboard_path, title: 'Issues', class: 'dashboard-shortcuts-issues' do - = navbar_icon('issues') + .icon-container + = navbar_icon('issues') %span Issues %span.count= number_with_delimiter(current_user.assigned_issues.opened.count) = nav_link(path: 'dashboard#merge_requests') do = link_to assigned_mrs_dashboard_path, title: 'Merge Requests', class: 'dashboard-shortcuts-merge_requests' do - = navbar_icon('mr') + .icon-container + = navbar_icon('mr') %span Merge Requests %span.count= number_with_delimiter(current_user.assigned_merge_requests.opened.count) = nav_link(controller: :snippets) do = link_to dashboard_snippets_path, title: 'Snippets' do - = icon('clipboard fw') + .icon-container + = icon('clipboard fw') %span Snippets = nav_link(controller: :help) do = link_to help_path, title: 'Help' do - = icon('question-circle fw') + .icon-container + = icon('question-circle fw') %span Help = nav_link(html_options: {class: profile_tab_class}) do = link_to profile_path, title: 'Profile Settings', data: {placement: 'bottom'} do - = icon('user fw') + .icon-container + = icon('user fw') %span Profile Settings diff --git a/app/views/layouts/nav/_group_settings.html.haml b/app/views/layouts/nav/_group_settings.html.haml index 0b2673f1a82..dac46648b9f 100644 --- a/app/views/layouts/nav/_group_settings.html.haml +++ b/app/views/layouts/nav/_group_settings.html.haml @@ -14,7 +14,3 @@ %li = link_to edit_group_path(@group) do Edit Group - %li - = link_to leave_group_group_members_path(@group), - data: { confirm: leave_group_message(@group.name) }, method: :delete, title: 'Leave group' do - Leave Group diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index 53d1fcc30a6..a851cae4b56 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -1,23 +1,26 @@ - if current_user .controls - - access = user_max_access_in_project(current_user.id, @project) - - can_edit = can?(current_user, :admin_project, @project) .dropdown.project-settings-dropdown %a.dropdown-new.btn.btn-default#project-settings-button{href: '#', 'data-toggle' => 'dropdown'} = icon('cog') = icon('caret-down') %ul.dropdown-menu.dropdown-menu-align-right - = render 'layouts/nav/project_settings' - %li.divider - - if can_edit - %li - = link_to edit_project_path(@project) do - Edit Project - - if access - %li - = link_to leave_namespace_project_project_members_path(@project.namespace, @project), - data: { confirm: leave_project_message(@project) }, method: :delete, title: 'Leave project' do - Leave Project + - access = @project.team.max_member_access(current_user.id) + - can_edit = can?(current_user, :admin_project, @project) + + = render 'layouts/nav/project_settings', access: access, can_edit: can_edit + + - if can_edit || access + %li.divider + - if can_edit + %li + = link_to edit_project_path(@project) do + Edit Project + - if access + %li + = link_to polymorphic_path([:leave, @project, :members]), + data: { confirm: leave_confirmation_message(@project) }, method: :delete, title: 'Leave project' do + Leave Project %div{ class: nav_control_class } %ul.nav-links.scrolling-tabs @@ -39,7 +42,7 @@ Code - if project_nav_tab? :pipelines - = nav_link(controller: :pipelines) do + = nav_link(controller: [:pipelines, :builds, :environments]) do = link_to project_pipelines_path(@project), title: 'Pipelines', class: 'shortcuts-pipelines' do %span Pipelines diff --git a/app/views/layouts/nav/_project_settings.html.haml b/app/views/layouts/nav/_project_settings.html.haml index 885e78d38c6..13d32bd1354 100644 --- a/app/views/layouts/nav/_project_settings.html.haml +++ b/app/views/layouts/nav/_project_settings.html.haml @@ -3,43 +3,43 @@ = link_to namespace_project_project_members_path(@project.namespace, @project), title: 'Members', class: 'team-tab tab' do %span Members - -- 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 - %span - Groups -= nav_link(controller: :deploy_keys) do - = link_to namespace_project_deploy_keys_path(@project.namespace, @project), title: 'Deploy Keys' do - %span - Deploy Keys -= nav_link(controller: :hooks) do - = link_to namespace_project_hooks_path(@project.namespace, @project), title: 'Webhooks' do - %span - Webhooks -= nav_link(controller: :services) do - = link_to namespace_project_services_path(@project.namespace, @project), title: 'Services' do - %span - Services -= nav_link(controller: :protected_branches) do - = link_to namespace_project_protected_branches_path(@project.namespace, @project), title: 'Protected Branches' do - %span - Protected Branches - -- if @project.builds_enabled? - = nav_link(controller: :runners) do - = link_to namespace_project_runners_path(@project.namespace, @project), title: 'Runners' do +- if access && can_edit + - 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 + %span + Groups + = nav_link(controller: :deploy_keys) do + = link_to namespace_project_deploy_keys_path(@project.namespace, @project), title: 'Deploy Keys' do %span - Runners - = nav_link(controller: :variables) do - = link_to namespace_project_variables_path(@project.namespace, @project), title: 'Variables' do + Deploy Keys + = nav_link(controller: :hooks) do + = link_to namespace_project_hooks_path(@project.namespace, @project), title: 'Webhooks' do %span - Variables - = nav_link(controller: :triggers) do - = link_to namespace_project_triggers_path(@project.namespace, @project), title: 'Triggers' do + Webhooks + = nav_link(controller: :services) do + = link_to namespace_project_services_path(@project.namespace, @project), title: 'Services' do %span - Triggers - = nav_link(controller: :badges) do - = link_to namespace_project_badges_path(@project.namespace, @project), title: 'Badges' do + Services + = nav_link(controller: :protected_branches) do + = link_to namespace_project_protected_branches_path(@project.namespace, @project), title: 'Protected Branches' do %span - Badges + Protected Branches + + - if @project.builds_enabled? + = nav_link(controller: :runners) do + = link_to namespace_project_runners_path(@project.namespace, @project), title: 'Runners' do + %span + Runners + = nav_link(controller: :variables) do + = link_to namespace_project_variables_path(@project.namespace, @project), title: 'Variables' do + %span + Variables + = nav_link(controller: :triggers) do + = link_to namespace_project_triggers_path(@project.namespace, @project), title: 'Triggers' do + %span + Triggers + = nav_link(controller: :badges) do + = link_to namespace_project_badges_path(@project.namespace, @project), title: 'Badges' do + %span + Badges diff --git a/app/views/notify/group_access_granted_email.html.haml b/app/views/notify/group_access_granted_email.html.haml deleted file mode 100644 index f1916d624b6..00000000000 --- a/app/views/notify/group_access_granted_email.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -%p - = "You have been granted #{@group_member.human_access} access to group" - = link_to group_url(@group) do - = @group.name diff --git a/app/views/notify/group_access_granted_email.text.erb b/app/views/notify/group_access_granted_email.text.erb deleted file mode 100644 index ef9617bfc16..00000000000 --- a/app/views/notify/group_access_granted_email.text.erb +++ /dev/null @@ -1,4 +0,0 @@ - -You have been granted <%= @group_member.human_access %> access to group <%= @group.name %> - -<%= url_for(group_url(@group)) %> diff --git a/app/views/notify/group_invite_accepted_email.html.haml b/app/views/notify/group_invite_accepted_email.html.haml deleted file mode 100644 index 55efad384a7..00000000000 --- a/app/views/notify/group_invite_accepted_email.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -%p - #{@group_member.invite_email}, now known as - #{link_to @group_member.user.name, user_url(@group_member.user)}, - has accepted your invitation to join group - #{link_to @group.name, group_url(@group)}. - diff --git a/app/views/notify/group_invite_accepted_email.text.erb b/app/views/notify/group_invite_accepted_email.text.erb deleted file mode 100644 index f8b70f7a5a6..00000000000 --- a/app/views/notify/group_invite_accepted_email.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= @group_member.invite_email %>, now known as <%= @group_member.user.name %>, has accepted your invitation to join group <%= @group.name %>. - -<%= group_url(@group) %> diff --git a/app/views/notify/group_invite_declined_email.html.haml b/app/views/notify/group_invite_declined_email.html.haml deleted file mode 100644 index f9525d84fac..00000000000 --- a/app/views/notify/group_invite_declined_email.html.haml +++ /dev/null @@ -1,5 +0,0 @@ -%p - #{@invite_email} - has declined your invitation to join group - #{link_to @group.name, group_url(@group)}. - diff --git a/app/views/notify/group_invite_declined_email.text.erb b/app/views/notify/group_invite_declined_email.text.erb deleted file mode 100644 index 6c19a288d15..00000000000 --- a/app/views/notify/group_invite_declined_email.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= @invite_email %> has declined your invitation to join group <%= @group.name %>. - -<%= group_url(@group) %> diff --git a/app/views/notify/group_member_invited_email.html.haml b/app/views/notify/group_member_invited_email.html.haml deleted file mode 100644 index 163e88bfea3..00000000000 --- a/app/views/notify/group_member_invited_email.html.haml +++ /dev/null @@ -1,14 +0,0 @@ -%p - You have been invited - - if inviter = @group_member.created_by - by - = link_to inviter.name, user_url(inviter) - to join group - = link_to @group.name, group_url(@group) - as #{@group_member.human_access}. - -%p - = link_to 'Accept invitation', invite_url(@token) - or - = link_to 'decline', decline_invite_url(@token) - diff --git a/app/views/notify/group_member_invited_email.text.erb b/app/views/notify/group_member_invited_email.text.erb deleted file mode 100644 index 28ce4819b14..00000000000 --- a/app/views/notify/group_member_invited_email.text.erb +++ /dev/null @@ -1,4 +0,0 @@ -You have been invited <%= "by #{@group_member.created_by.name} " if @group_member.created_by %>to join group <%= @group.name %> as <%= @group_member.human_access %>. - -Accept invitation: <%= invite_url(@token) %> -Decline invitation: <%= decline_invite_url(@token) %> diff --git a/app/views/notify/member_access_denied_email.html.haml b/app/views/notify/member_access_denied_email.html.haml new file mode 100644 index 00000000000..71c9c50071a --- /dev/null +++ b/app/views/notify/member_access_denied_email.html.haml @@ -0,0 +1,4 @@ +%p + Your request to join the + #{link_to member_source.human_name, member_source.web_url} #{member_source.model_name.singular} + has been denied. diff --git a/app/views/notify/member_access_denied_email.text.erb b/app/views/notify/member_access_denied_email.text.erb new file mode 100644 index 00000000000..87f2ef817ee --- /dev/null +++ b/app/views/notify/member_access_denied_email.text.erb @@ -0,0 +1,3 @@ +Your request to join the <%= member_source.human_name %> <%= member_source.model_name.singular %> has been denied. + +<%= member_source.web_url %> diff --git a/app/views/notify/member_access_granted_email.html.haml b/app/views/notify/member_access_granted_email.html.haml new file mode 100644 index 00000000000..18dec806539 --- /dev/null +++ b/app/views/notify/member_access_granted_email.html.haml @@ -0,0 +1,3 @@ +%p + You have been granted #{member.human_access} access to the + #{link_to member_source.human_name, member_source.web_url} #{member_source.model_name.singular}. diff --git a/app/views/notify/member_access_granted_email.text.erb b/app/views/notify/member_access_granted_email.text.erb new file mode 100644 index 00000000000..a9fb3a589a5 --- /dev/null +++ b/app/views/notify/member_access_granted_email.text.erb @@ -0,0 +1,3 @@ +You have been granted <%= member.human_access %> access to the <%= member_source.human_name %> <%= member_source.model_name.singular %>. + +<%= member_source.web_url %> diff --git a/app/views/notify/member_access_requested_email.html.haml b/app/views/notify/member_access_requested_email.html.haml new file mode 100644 index 00000000000..76f1f08a0cb --- /dev/null +++ b/app/views/notify/member_access_requested_email.html.haml @@ -0,0 +1,3 @@ +%p + #{link_to member.user.name, member.user} requested #{member.human_access} + access to the #{link_to member_source.human_name, polymorphic_url([member_source, :members])} #{member_source.model_name.singular}. diff --git a/app/views/notify/member_access_requested_email.text.erb b/app/views/notify/member_access_requested_email.text.erb new file mode 100644 index 00000000000..9c5ee0eaf26 --- /dev/null +++ b/app/views/notify/member_access_requested_email.text.erb @@ -0,0 +1,3 @@ +<%= member.user.name %> (<%= user_url(member.user) %>) requested <%= member.human_access %> access to the <%= member_source.human_name %> <%= member_source.model_name.singular %>. + +<%= polymorphic_url([member_source, :members]) %> diff --git a/app/views/notify/member_invite_accepted_email.html.haml b/app/views/notify/member_invite_accepted_email.html.haml new file mode 100644 index 00000000000..2d1d40881eb --- /dev/null +++ b/app/views/notify/member_invite_accepted_email.html.haml @@ -0,0 +1,5 @@ +%p + #{member.invite_email}, now known as + #{link_to member.user.name, user_url(member.user)}, + has accepted your invitation to join the + #{link_to member_source.human_name, member_source.web_url} #{member_source.model_name.singular}. diff --git a/app/views/notify/member_invite_accepted_email.text.erb b/app/views/notify/member_invite_accepted_email.text.erb new file mode 100644 index 00000000000..cef87101427 --- /dev/null +++ b/app/views/notify/member_invite_accepted_email.text.erb @@ -0,0 +1,3 @@ +<%= member.invite_email %>, now known as <%= member.user.name %>, has accepted your invitation to join the <%= member_source.human_name %> <%= member_source.model_name.singular %>. + +<%= member_source.web_url %> diff --git a/app/views/notify/member_invite_declined_email.html.haml b/app/views/notify/member_invite_declined_email.html.haml new file mode 100644 index 00000000000..aa1b373d1a6 --- /dev/null +++ b/app/views/notify/member_invite_declined_email.html.haml @@ -0,0 +1,4 @@ +%p + #{@invite_email} + has declined your invitation to join the + #{link_to member_source.human_name, member_source.web_url} #{member_source.model_name.singular}. diff --git a/app/views/notify/member_invite_declined_email.text.erb b/app/views/notify/member_invite_declined_email.text.erb new file mode 100644 index 00000000000..8bc305910c4 --- /dev/null +++ b/app/views/notify/member_invite_declined_email.text.erb @@ -0,0 +1,3 @@ +<%= @invite_email %> has declined your invitation to join the <%= member_source.human_name %> <%= member_source.model_name.singular %>. + +<%= member_source.web_url %> diff --git a/app/views/notify/member_invited_email.html.haml b/app/views/notify/member_invited_email.html.haml new file mode 100644 index 00000000000..b8b75da3f2f --- /dev/null +++ b/app/views/notify/member_invited_email.html.haml @@ -0,0 +1,13 @@ +%p + You have been invited + - if member.created_by + by + = link_to member.created_by.name, user_url(member.created_by) + to join the + = link_to member_source.human_name, member_source.web_url + #{member_source.model_name.singular} as #{member.human_access}. + +%p + = link_to 'Accept invitation', invite_url(@token) + or + = link_to 'decline', decline_invite_url(@token) diff --git a/app/views/notify/member_invited_email.text.erb b/app/views/notify/member_invited_email.text.erb new file mode 100644 index 00000000000..0a6393355be --- /dev/null +++ b/app/views/notify/member_invited_email.text.erb @@ -0,0 +1,4 @@ +You have been invited <%= "by #{member.created_by.name} " if member.created_by %>to join the <%= member_source.human_name %> <%= member_source.model_name.singular %> as <%= member.human_access %>. + +Accept invitation: <%= invite_url(@token) %> +Decline invitation: <%= decline_invite_url(@token) %> diff --git a/app/views/notify/project_access_granted_email.html.haml b/app/views/notify/project_access_granted_email.html.haml deleted file mode 100644 index dfc30a2d360..00000000000 --- a/app/views/notify/project_access_granted_email.html.haml +++ /dev/null @@ -1,5 +0,0 @@ -%p - = "You have been granted #{@project_member.human_access} access to project" -%p - = link_to namespace_project_url(@project.namespace, @project) do - = @project.name_with_namespace diff --git a/app/views/notify/project_access_granted_email.text.erb b/app/views/notify/project_access_granted_email.text.erb deleted file mode 100644 index 68eb1611ba7..00000000000 --- a/app/views/notify/project_access_granted_email.text.erb +++ /dev/null @@ -1,4 +0,0 @@ - -You have been granted <%= @project_member.human_access %> access to project <%= @project.name_with_namespace %> - -<%= url_for(namespace_project_url(@project.namespace, @project)) %> diff --git a/app/views/notify/project_invite_accepted_email.html.haml b/app/views/notify/project_invite_accepted_email.html.haml deleted file mode 100644 index 7e58d30b10a..00000000000 --- a/app/views/notify/project_invite_accepted_email.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -%p - #{@project_member.invite_email}, now known as - #{link_to @project_member.user.name, user_url(@project_member.user)}, - has accepted your invitation to join project - #{link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project)}. - diff --git a/app/views/notify/project_invite_accepted_email.text.erb b/app/views/notify/project_invite_accepted_email.text.erb deleted file mode 100644 index fcbe752114d..00000000000 --- a/app/views/notify/project_invite_accepted_email.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= @project_member.invite_email %>, now known as <%= @project_member.user.name %>, has accepted your invitation to join project <%= @project.name_with_namespace %>. - -<%= namespace_project_url(@project.namespace, @project) %> diff --git a/app/views/notify/project_invite_declined_email.html.haml b/app/views/notify/project_invite_declined_email.html.haml deleted file mode 100644 index c2d7e6f6e3a..00000000000 --- a/app/views/notify/project_invite_declined_email.html.haml +++ /dev/null @@ -1,5 +0,0 @@ -%p - #{@invite_email} - has declined your invitation to join project - #{link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project)}. - diff --git a/app/views/notify/project_invite_declined_email.text.erb b/app/views/notify/project_invite_declined_email.text.erb deleted file mode 100644 index 484687fa51c..00000000000 --- a/app/views/notify/project_invite_declined_email.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= @invite_email %> has declined your invitation to join project <%= @project.name_with_namespace %>. - -<%= namespace_project_url(@project.namespace, @project) %> diff --git a/app/views/notify/project_member_invited_email.html.haml b/app/views/notify/project_member_invited_email.html.haml deleted file mode 100644 index 79eb89616de..00000000000 --- a/app/views/notify/project_member_invited_email.html.haml +++ /dev/null @@ -1,13 +0,0 @@ -%p - You have been invited - - if inviter = @project_member.created_by - by - = link_to inviter.name, user_url(inviter) - to join project - = link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project) - as #{@project_member.human_access}. - -%p - = link_to 'Accept invitation', invite_url(@token) - or - = link_to 'decline', decline_invite_url(@token) diff --git a/app/views/notify/project_member_invited_email.text.erb b/app/views/notify/project_member_invited_email.text.erb deleted file mode 100644 index e0706272115..00000000000 --- a/app/views/notify/project_member_invited_email.text.erb +++ /dev/null @@ -1,4 +0,0 @@ -You have been invited <%= "by #{@project_member.created_by.name} " if @project_member.created_by %>to join project <%= @project.name_with_namespace %> as <%= @project_member.human_access %>. - -Accept invitation: <%= invite_url(@token) %> -Decline invitation: <%= decline_invite_url(@token) %> diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index ce76cb73c9c..593be2617c1 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -51,9 +51,9 @@ %p Use a hardware device to add the second factor of authentication. %p - As U2F devices are only supported by a few browsers, it's recommended that you set up a - two-factor authentication app as well as a U2F device so you'll always be able to log in - using an unsupported browser. + As U2F devices are only supported by a few browsers, we require that you set up a + two-factor authentication app before a U2F device. That way you'll always be able to + log in - even when you're using an unsupported browser. .col-lg-9 %p - if @registration_key_handles.present? diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index f5bc1b4e409..2b19ee93eea 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -29,10 +29,13 @@ .project-clone-holder = render "shared/clone_panel" - .project-repo-buttons.btn-group.project-right-buttons - = render "projects/buttons/download" - = render 'projects/buttons/dropdown' - = render 'projects/buttons/notifications' + .project-repo-buttons.project-right-buttons + - if current_user + = render 'shared/members/access_request_buttons', source: @project + .btn-group + = render "projects/buttons/download" + = render 'projects/buttons/dropdown' + = render 'projects/buttons/notifications' :javascript new Star(); diff --git a/app/views/projects/builds/_sidebar.html.haml b/app/views/projects/builds/_sidebar.html.haml index 5d931389dfb..cab21f0cf19 100644 --- a/app/views/projects/builds/_sidebar.html.haml +++ b/app/views/projects/builds/_sidebar.html.haml @@ -11,19 +11,33 @@ %p.build-detail-row #{@build.coverage}% - - if can?(current_user, :read_build, @project) && @build.artifacts? + - if can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?) .block{ class: ("block-first" if !@build.coverage) } .title Build artifacts - .btn-group.btn-group-justified{ role: :group } - = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do - Download + - if @build.artifacts_expired? + %p.build-detail-row + The artifacts were removed + #{time_ago_with_tooltip(@build.artifacts_expire_at)} + - elsif @build.artifacts_expire_at + %p.build-detail-row + The artifacts will be removed in + %span.js-artifacts-remove= @build.artifacts_expire_at - - if @build.artifacts_metadata? - = link_to browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do - Browse + - if @build.artifacts? + .btn-group.btn-group-justified{ role: :group } + - if @build.artifacts_expire_at + = link_to keep_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post do + Keep - .block{ class: ("block-first" if !@build.coverage && !(can?(current_user, :read_build, @project) && @build.artifacts?)) } + = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do + Download + + - if @build.artifacts_metadata? + = link_to browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do + Browse + + .block{ class: ("block-first" if !@build.coverage && !(can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?))) } .title Build details - if @build.retryable? diff --git a/app/views/projects/buttons/_notifications.html.haml b/app/views/projects/buttons/_notifications.html.haml index 3b97dc9328f..a7a97181096 100644 --- a/app/views/projects/buttons/_notifications.html.haml +++ b/app/views/projects/buttons/_notifications.html.haml @@ -1,7 +1,7 @@ - if @notification_setting = form_for @notification_setting, url: namespace_project_notification_setting_path(@project.namespace.becomes(Namespace), @project), method: :patch, remote: true, html: { class: 'inline', id: 'notification-form' } do |f| = f.hidden_field :level - .dropdown + .dropdown.hidden-sm %button.btn.btn-default.notifications-btn#notifications-button{ data: { toggle: "dropdown" }, aria: { haspopup: "true", expanded: "false" } } = icon('bell') = notification_title(@notification_setting.level) diff --git a/app/views/projects/buttons/_star.html.haml b/app/views/projects/buttons/_star.html.haml index 02dbb2985a4..71cf5582a4c 100644 --- a/app/views/projects/buttons/_star.html.haml +++ b/app/views/projects/buttons/_star.html.haml @@ -1,5 +1,5 @@ - if current_user - = link_to toggle_star_namespace_project_path(@project.namespace, @project), class: 'btn star-btn toggle-star has-tooltip', method: :post, remote: true, title: "Star project" do + = link_to toggle_star_namespace_project_path(@project.namespace, @project), { class: 'btn star-btn toggle-star has-tooltip', method: :post, remote: true, title: current_user.starred?(@project) ? 'Unstar project' : 'Star project' } do - if current_user.starred?(@project) = icon('star fw') %span.starred Unstar diff --git a/app/views/projects/commits/_head.html.haml b/app/views/projects/commits/_head.html.haml index a72e8ba73ad..c8aa849c217 100644 --- a/app/views/projects/commits/_head.html.haml +++ b/app/views/projects/commits/_head.html.haml @@ -1,6 +1,6 @@ .scrolling-tabs-container - %ul.nav-links.sub-nav.scrolling-tabs - %div{ class: (container_class) } + .nav-links.sub-nav.scrolling-tabs + %ul{ class: (container_class) } .fade-left = nav_link(controller: %w(tree blob blame edit_tree new_tree find_file)) do = link_to project_files_path(@project) do diff --git a/app/views/projects/container_registry/_tag.html.haml b/app/views/projects/container_registry/_tag.html.haml index 4e9f936539b..f35faa6afb5 100644 --- a/app/views/projects/container_registry/_tag.html.haml +++ b/app/views/projects/container_registry/_tag.html.haml @@ -9,11 +9,19 @@ - else \- %td - = number_to_human_size(tag.total_size) - · - = pluralize(tag.layers.size, "layer") + - if tag.total_size + = number_to_human_size(tag.total_size) + · + = pluralize(tag.layers.size, "layer") + - else + .light + \- %td - = time_ago_in_words(tag.created_at) + - if tag.created_at + = time_ago_in_words(tag.created_at) + - else + .light + \- - if can?(current_user, :update_container_image, @project) %td.content .controls.hidden-xs.pull-right diff --git a/app/views/projects/deployments/_commit.html.haml b/app/views/projects/deployments/_commit.html.haml new file mode 100644 index 00000000000..0f9d9512d88 --- /dev/null +++ b/app/views/projects/deployments/_commit.html.haml @@ -0,0 +1,12 @@ +%div.branch-commit + - if deployment.ref + = link_to deployment.ref, namespace_project_commits_path(@project.namespace, @project, deployment.ref), class: "monospace" + · + = link_to deployment.short_sha, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-id monospace" + + %p.commit-title + %span + - if commit_title = deployment.commit_title + = link_to_gfm commit_title, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-row-message" + - else + Cant find HEAD commit for this branch diff --git a/app/views/projects/deployments/_deployment.html.haml b/app/views/projects/deployments/_deployment.html.haml new file mode 100644 index 00000000000..d08dd92f1f6 --- /dev/null +++ b/app/views/projects/deployments/_deployment.html.haml @@ -0,0 +1,23 @@ +%tr.deployment + %td + %strong= "##{deployment.iid}" + + %td + = render 'projects/deployments/commit', deployment: deployment + + %td + - if deployment.deployable + = link_to namespace_project_build_path(@project.namespace, @project, deployment.deployable) do + = "#{deployment.deployable.name} (##{deployment.deployable.id})" + + %td + #{time_ago_with_tooltip(deployment.created_at)} + + %td + - if can?(current_user, :create_deployment, deployment) && deployment.deployable + .pull-right + = link_to retry_namespace_project_build_path(@project.namespace, @project, deployment.deployable), method: :post, class: 'btn btn-build' do + - if deployment.last? + Retry + - else + Rollback diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml index d9c4b410d32..6c11afbe420 100644 --- a/app/views/projects/diffs/_diffs.html.haml +++ b/app/views/projects/diffs/_diffs.html.haml @@ -24,6 +24,7 @@ - diff_commit = commit_for_diff(diff_file) - blob = project.repository.blob_for_diff(diff_commit, diff_file) - next unless blob + - blob.load_all_data!(project.repository) unless blob.only_display_raw? = render 'projects/diffs/file', i: index, project: project, diff_file: diff_file, diff_commit: diff_commit, blob: blob, diff_refs: diff_refs diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml index e5983c58039..2395ea3c275 100644 --- a/app/views/projects/diffs/_file.html.haml +++ b/app/views/projects/diffs/_file.html.haml @@ -49,6 +49,8 @@ = render "projects/diffs/parallel_view", diff_file: diff_file, project: project, blob: blob, index: i - else = render "projects/diffs/text_file", diff_file: diff_file, index: i + - elsif blob.only_display_raw? + .nothing-here-block This file is too large to display. - elsif blob.image? - old_file = project.repository.prev_blob_for_diff(diff_commit, diff_file) = render "projects/diffs/image", diff_file: diff_file, old_file: old_file, file: blob, index: i, diff_refs: diff_refs diff --git a/app/views/projects/environments/_environment.html.haml b/app/views/projects/environments/_environment.html.haml new file mode 100644 index 00000000000..eafa246d05f --- /dev/null +++ b/app/views/projects/environments/_environment.html.haml @@ -0,0 +1,17 @@ +- last_deployment = environment.last_deployment + +%tr.environment + %td + %strong + = link_to environment.name, namespace_project_environment_path(@project.namespace, @project, environment) + + %td + - if last_deployment + = render 'projects/deployments/commit', deployment: last_deployment + - else + %p.commit-title + No deployments yet + + %td + - if last_deployment + #{time_ago_with_tooltip(last_deployment.created_at)} diff --git a/app/views/projects/environments/_form.html.haml b/app/views/projects/environments/_form.html.haml new file mode 100644 index 00000000000..c07f4bd510c --- /dev/null +++ b/app/views/projects/environments/_form.html.haml @@ -0,0 +1,7 @@ += form_for @environment, url: namespace_project_environments_path(@project.namespace, @project), html: { class: 'col-lg-9' } do |f| + = form_errors(@environment) + .form-group + = f.label :name, 'Name', class: 'label-light' + = f.text_field :name, required: true, class: 'form-control' + = f.submit 'Create environment', class: 'btn btn-create' + = link_to 'Cancel', namespace_project_environments_path(@project.namespace, @project), class: 'btn btn-cancel' diff --git a/app/views/projects/environments/_header_title.html.haml b/app/views/projects/environments/_header_title.html.haml new file mode 100644 index 00000000000..e056fccad5d --- /dev/null +++ b/app/views/projects/environments/_header_title.html.haml @@ -0,0 +1 @@ +- header_title project_title(@project, "Environments", project_environments_path(@project)) diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml new file mode 100644 index 00000000000..ae9e77e7d89 --- /dev/null +++ b/app/views/projects/environments/index.html.haml @@ -0,0 +1,23 @@ +- @no_container = true +- page_title "Environments" += render "projects/pipelines/head" + +%div{ class: (container_class) } + - if can?(current_user, :create_environment, @project) + .top-area + .nav-controls + = link_to new_namespace_project_environment_path(@project.namespace, @project), class: 'btn btn-create' do + New environment + + - if @environments.blank? + %ul.content-list.environments + %li.nothing-here-block + No environments to show + - else + .table-holder + %table.table.environments + %tbody + %th Environment + %th Last deployment + %th Date + = render @environments diff --git a/app/views/projects/environments/new.html.haml b/app/views/projects/environments/new.html.haml new file mode 100644 index 00000000000..54465828ba9 --- /dev/null +++ b/app/views/projects/environments/new.html.haml @@ -0,0 +1,9 @@ +- page_title 'New Environment' + +.row.prepend-top-default.append-bottom-default + .col-lg-3 + %h4.prepend-top-0 + New Environment + %p Environments allow you to track deployments of your application + + = render 'form' diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml new file mode 100644 index 00000000000..069b77b5adf --- /dev/null +++ b/app/views/projects/environments/show.html.haml @@ -0,0 +1,33 @@ +- @no_container = true +- page_title "Environments" += render "projects/pipelines/head" + +%div{ class: (container_class) } + .top-area + .col-md-9 + %h3.page-title= @environment.name.titleize + + .col-md-3 + .nav-controls + - if can?(current_user, :update_environment, @environment) + = link_to 'Destroy', namespace_project_environment_path(@project.namespace, @project, @environment), data: { confirm: 'Are you sure you want to delete this environment?' }, class: 'btn btn-danger', method: :delete + + - if @deployments.blank? + %ul.content-list.environments + %li.nothing-here-block + No deployments for + %strong= @environment.name + - else + .table-holder + %table.table.environments + %thead + %tr + %th ID + %th Commit + %th Build + %th Date + %th + + = render @deployments + + = paginate @deployments, theme: 'gitlab' diff --git a/app/views/projects/issues/_head.html.haml b/app/views/projects/issues/_head.html.haml index 166dae248b6..403adb7426b 100644 --- a/app/views/projects/issues/_head.html.haml +++ b/app/views/projects/issues/_head.html.haml @@ -1,5 +1,5 @@ -%ul.nav-links.sub-nav - %div{ class: (container_class) } +.nav-links.sub-nav + %ul{ class: (container_class) } - if project_nav_tab?(:issues) && !current_controller?(:merge_requests) = nav_link(controller: :issues) do = link_to url_for_project_issues(@project, only_path: true), title: 'Issues' do diff --git a/app/views/projects/merge_requests/widget/_merged.html.haml b/app/views/projects/merge_requests/widget/_merged.html.haml index ec4beae9727..19b5d0ff066 100644 --- a/app/views/projects/merge_requests/widget/_merged.html.haml +++ b/app/views/projects/merge_requests/widget/_merged.html.haml @@ -6,46 +6,29 @@ - if @merge_request.merge_event by #{link_to_member(@project, @merge_request.merge_event.author, avatar: true)} #{time_ago_with_tooltip(@merge_request.merge_event.created_at)} - %div - - if !@merge_request.source_branch_exists? || (params[:delete_source] == 'true') + - if !@merge_request.source_branch_exists? || (params[:delete_source] == 'true') + %p + The changes were merged into + #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}. + The source branch has been removed. + = render 'projects/merge_requests/widget/merged_buttons' + - elsif @merge_request.can_remove_source_branch?(current_user) + .remove_source_branch_widget %p The changes were merged into #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}. - The source branch has been removed. - = render 'projects/merge_requests/widget/merged_buttons' - - elsif @merge_request.can_remove_source_branch?(current_user) - .remove_source_branch_widget - %p - The changes were merged into - #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}. - You can remove the source branch now. - = render 'projects/merge_requests/widget/merged_buttons', source_branch_exists: true - .remove_source_branch_widget.failed.hide - %p - Failed to remove source branch '#{@merge_request.source_branch}'. - - .remove_source_branch_in_progress.hide - %p - = icon('spinner spin') - Removing source branch '#{@merge_request.source_branch}'. Please wait, this page will be automatically reloaded. - - :javascript - $('.remove_source_branch').on('click', function() { - $('.remove_source_branch_widget').hide(); - $('.remove_source_branch_in_progress').show(); - }); - - $(".remove_source_branch").on("ajax:success", function (e, data, status, xhr) { - location.reload(); - }); + You can remove the source branch now. + = render 'projects/merge_requests/widget/merged_buttons', source_branch_exists: true + .remove_source_branch_widget.failed.hide + %p + Failed to remove source branch '#{@merge_request.source_branch}'. - $(".remove_source_branch").on("ajax:error", function (e, data, status, xhr) { - $('.remove_source_branch_widget').hide(); - $('.remove_source_branch_in_progress').hide(); - $('.remove_source_branch_widget.failed').show(); - }); - - else + .remove_source_branch_in_progress.hide %p - The changes were merged into - #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}. - = render 'projects/merge_requests/widget/merged_buttons' + = icon('spinner spin') + Removing source branch '#{@merge_request.source_branch}'. Please wait, this page will be automatically reloaded. + - else + %p + The changes were merged into + #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}. + = render 'projects/merge_requests/widget/merged_buttons' diff --git a/app/views/projects/merge_requests/widget/_merged_buttons.haml b/app/views/projects/merge_requests/widget/_merged_buttons.haml index 56167509af9..d836a253507 100644 --- a/app/views/projects/merge_requests/widget/_merged_buttons.haml +++ b/app/views/projects/merge_requests/widget/_merged_buttons.haml @@ -3,9 +3,9 @@ - mr_can_be_cherry_picked = @merge_request.can_be_cherry_picked? - if can_remove_source_branch || mr_can_be_reverted || mr_can_be_cherry_picked - .btn-group + .clearfix.merged-buttons - if can_remove_source_branch - = link_to namespace_project_branch_path(@merge_request.source_project.namespace, @merge_request.source_project, @merge_request.source_branch), remote: true, method: :delete, class: "btn btn-default btn-grouped btn-sm remove_source_branch" do + = link_to namespace_project_branch_path(@merge_request.source_project.namespace, @merge_request.source_project, @merge_request.source_branch), remote: true, method: :delete, class: "btn btn-default btn-sm remove_source_branch" do = icon('trash-o') Remove Source Branch - if mr_can_be_reverted diff --git a/app/views/projects/pipelines/_head.html.haml b/app/views/projects/pipelines/_head.html.haml index d0ba0d27d7c..d65faf86d4e 100644 --- a/app/views/projects/pipelines/_head.html.haml +++ b/app/views/projects/pipelines/_head.html.haml @@ -1,5 +1,5 @@ -%ul.nav-links.sub-nav - %div{ class: (container_class) } +.nav-links.sub-nav + %ul{ class: (container_class) } - if project_nav_tab? :pipelines = nav_link(controller: :pipelines) do = link_to project_pipelines_path(@project), title: 'Pipelines', class: 'shortcuts-pipelines' do @@ -11,3 +11,9 @@ = link_to project_builds_path(@project), title: 'Builds', class: 'shortcuts-builds' do %span Builds + + - if project_nav_tab? :environments + = nav_link(controller: %w(environments)) do + = link_to project_environments_path(@project), title: 'Environments', class: 'shortcuts-environments' do + %span + Environments diff --git a/app/views/projects/project_members/_group_members.html.haml b/app/views/projects/project_members/_group_members.html.haml index 6671ee2c6d6..cb6136c215a 100644 --- a/app/views/projects/project_members/_group_members.html.haml +++ b/app/views/projects/project_members/_group_members.html.haml @@ -6,11 +6,14 @@ (#{members.count}) - if can?(current_user, :admin_group_member, @group) .controls - = link_to group_group_members_path(@group), class: 'btn' do - Manage group members + = link_to 'Manage group members', + group_group_members_path(@group), + class: 'btn' %ul.content-list - - members.limit(20).each do |member| - = render 'groups/group_members/group_member', member: member, show_controls: false - - if members.count > 20 + = render partial: 'shared/members/member', + collection: members.limit(20), + as: :member, + locals: { show_controls: false } + - if members.size > 20 %li and #{members.count - 20} more. For full list visit #{link_to 'group members page', group_group_members_path(@group)} diff --git a/app/views/projects/project_members/_new_project_member.html.haml b/app/views/projects/project_members/_new_project_member.html.haml index f0f3bb3c177..82892a33358 100644 --- a/app/views/projects/project_members/_new_project_member.html.haml +++ b/app/views/projects/project_members/_new_project_member.html.haml @@ -9,7 +9,7 @@ .form-group = f.label :access_level, "Project Access", class: 'control-label' .col-sm-10 - = select_tag :access_level, options_for_select(ProjectMember.access_roles, @project_member.access_level), class: "project-access-select select2" + = select_tag :access_level, options_for_select(ProjectMember.access_level_roles, @project_member.access_level), class: "project-access-select select2" .help-block Read more about role permissions %strong= link_to "here", help_page_path("permissions", "permissions"), class: "vlink" diff --git a/app/views/projects/project_members/_project_member.html.haml b/app/views/projects/project_members/_project_member.html.haml deleted file mode 100644 index 268f140d7db..00000000000 --- a/app/views/projects/project_members/_project_member.html.haml +++ /dev/null @@ -1,55 +0,0 @@ -- user = member.user -- return unless user || member.invite? - -%li{class: "#{dom_class(member)} js-toggle-container project_member_row access-#{member.human_access.downcase}", id: dom_id(member)} - %span.list-item-name - - if member.user - = image_tag avatar_icon(user, 24), class: "avatar s24", alt: '' - %strong - = link_to user.name, user_path(user) - %span.cgray= user.username - - if user == current_user - %span.label.label-success It's you - - if user.blocked? - %label.label.label-danger - %strong Blocked - - else - = image_tag avatar_icon(member.invite_email, 24), class: "avatar s24", alt: '' - %strong - = member.invite_email - %span.cgray - invited - - if member.created_by - by - = link_to member.created_by.name, user_path(member.created_by) - = time_ago_with_tooltip(member.created_at) - - - if can?(current_user, :admin_project_member, @project) - = link_to resend_invite_namespace_project_project_member_path(@project.namespace, @project, member), method: :post, class: "btn-xs btn", title: 'Resend invite' do - Resend invite - - - if can?(current_user, :admin_project_member, @project) - .pull-right - %strong= member.human_access - - if can?(current_user, :update_project_member, member) - = button_tag class: "btn-xs btn-grouped inline btn js-toggle-button", - title: 'Edit access level', type: 'button' do - = icon('pencil') - - - if can?(current_user, :destroy_project_member, member) - - - if current_user == user - = link_to leave_namespace_project_project_members_path(@project.namespace, @project), data: { confirm: leave_project_message(@project) }, method: :delete, class: "btn-xs btn btn-remove", title: 'Leave project' do - = icon("sign-out") - Leave - - else - = link_to namespace_project_project_member_path(@project.namespace, @project, member), data: { confirm: remove_from_project_team_message(@project, member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from team' do - = icon('trash') - - .edit-member.hide.js-toggle-content - %br - = form_for member, as: :project_member, url: namespace_project_project_member_path(@project.namespace, @project, member), remote: true do |f| - .prepend-top-10 - = f.select :access_level, options_for_select(ProjectMember.access_roles, member.access_level), {}, class: 'form-control' - .prepend-top-10 - = f.submit 'Save', class: 'btn btn-save' diff --git a/app/views/projects/project_members/_shared_group_members.html.haml b/app/views/projects/project_members/_shared_group_members.html.haml index ae13f8428f0..952844acefc 100644 --- a/app/views/projects/project_members/_shared_group_members.html.haml +++ b/app/views/projects/project_members/_shared_group_members.html.haml @@ -14,8 +14,10 @@ %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 + = render partial: 'shared/members/member', + collection: shared_group.group_members.order(access_level: :desc).limit(20), + as: :member, + locals: { 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/_team.html.haml b/app/views/projects/project_members/_team.html.haml index e8dce30425f..03207614258 100644 --- a/app/views/projects/project_members/_team.html.haml +++ b/app/views/projects/project_members/_team.html.haml @@ -11,8 +11,7 @@ = button_tag class: 'btn', title: 'Search' do = icon("search") %ul.content-list - - members.each do |project_member| - = render 'project_member', member: project_member + = render partial: 'shared/members/member', collection: members, as: :member :javascript $('form.member-search-form').on('submit', function (event) { diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index 15dc064e7ea..357ccccaf1d 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -13,7 +13,9 @@ Users with access to this project are listed below. = render "new_project_member" - = render "team", members: @project_members + = render 'shared/members/requests', membership_source: @project, members: @project_members.request + + = render 'team', members: @project_members.non_request - if @group = render "group_members", members: @group_members diff --git a/app/views/shared/groups/_group.html.haml b/app/views/shared/groups/_group.html.haml index a25365a94f2..1ad95351005 100644 --- a/app/views/shared/groups/_group.html.haml +++ b/app/views/shared/groups/_group.html.haml @@ -9,7 +9,7 @@ = link_to edit_group_path(group), class: "btn" do = icon('cogs') - = link_to leave_group_group_members_path(group), data: { confirm: leave_group_message(group.name) }, method: :delete, class: "btn", title: 'Leave this group' do + = link_to leave_group_group_members_path(group), data: { confirm: leave_confirmation_message(group) }, method: :delete, class: "btn", title: 'Leave this group' do = icon('sign-out') .stats diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index fb906de829a..539c4f3630a 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -1,9 +1,21 @@ +- todo = has_todo(issuable) %aside.right-sidebar{ class: sidebar_gutter_collapsed_class } .issuable-sidebar - can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project) .block.issuable-sidebar-header - %a.gutter-toggle.pull-right.js-sidebar-toggle{href: '#'} + - if current_user + %span.issuable-header-text.hide-collapsed.pull-left + Todo + %a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", aria: { label: "Toggle sidebar" } } = sidebar_gutter_toggle_icon + - if current_user + %button.btn.btn-default.issuable-header-btn.pull-right.js-issuable-todo{ type: "button", aria: { label: (todo.nil? ? "Add Todo" : "Mark Done") }, data: { todo_text: "Add Todo", mark_text: "Mark Done", id: (todo.id unless todo.nil?), issuable: issuable.id, issuable_type: issuable.class.name.underscore, url: namespace_project_todos_path(@project.namespace, @project) } } + %span.js-issuable-todo-text + - if todo.nil? + Add Todo + - else + Mark Done + = icon('spin spinner', class: 'hidden js-issuable-todo-loading') = form_for [@project.namespace.becomes(Namespace), @project, issuable], remote: true, format: :json, html: {class: 'issuable-context-form inline-update js-issuable-update'} do |f| .block.assignee diff --git a/app/views/shared/members/_access_request_buttons.html.haml b/app/views/shared/members/_access_request_buttons.html.haml new file mode 100644 index 00000000000..ed0a6ebcf84 --- /dev/null +++ b/app/views/shared/members/_access_request_buttons.html.haml @@ -0,0 +1,12 @@ +- member = source.members.find_by(user_id: current_user.id) + +- if member + - if member.request? + = link_to 'Withdraw Access Request', polymorphic_path([:leave, source, :members]), + method: :delete, + data: { confirm: remove_member_message(member) }, + class: 'btn access-request-button hidden-xs' +- else + = link_to 'Request Access', polymorphic_path([:request_access, source, :members]), + method: :post, + class: 'btn access-request-button hidden-xs' diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml new file mode 100644 index 00000000000..c69d4cbfbe3 --- /dev/null +++ b/app/views/shared/members/_member.html.haml @@ -0,0 +1,77 @@ +- show_roles = local_assigns.fetch(:show_roles, true) +- show_controls = local_assigns.fetch(:show_controls, true) +- user = member.user + +%li.js-toggle-container{ class: dom_class(member), id: dom_id(member) } + %span{ class: ("list-item-name" if show_controls) } + - if user + = image_tag avatar_icon(user, 24), class: "avatar s24", alt: '' + %strong + = link_to user.name, user_path(user) + %span.cgray= user.username + + - if user == current_user + %span.label.label-success It's you + + - if user.blocked? + %label.label.label-danger + %strong Blocked + + - if member.request? + %span.cgray + – Requested + = time_ago_with_tooltip(member.requested_at) + - else + = image_tag avatar_icon(member.invite_email, 24), class: "avatar s24", alt: '' + %strong= member.invite_email + %span.cgray + – Invited + - if member.created_by + by + = link_to member.created_by.name, user_path(member.created_by) + = time_ago_with_tooltip(member.created_at) + + - if show_controls && can?(current_user, action_member_permission(:admin, member), member.source) + = link_to 'Resend invite', polymorphic_path([:resend_invite, member]), + method: :post, + class: 'btn-xs btn' + + - if show_roles && can_see_member_roles?(source: member.source, user: current_user) + %span.pull-right + %strong= member.human_access + - if show_controls + - if can?(current_user, action_member_permission(:update, member), member) + = button_tag icon('pencil'), + type: 'button', + class: 'btn-xs btn btn-grouped inline js-toggle-button', + title: 'Edit access level' + + - if member.request? + + = link_to icon('check inverse'), polymorphic_path([:approve_access_request, member]), + method: :post, + class: 'btn-xs btn btn-success', + title: 'Grant access' + + - if can?(current_user, action_member_permission(:destroy, member), member) + + - if current_user == user + = link_to icon('sign-out', text: 'Leave'), polymorphic_path([:leave, member.source, :members]), + method: :delete, + data: { confirm: leave_confirmation_message(member.source) }, + class: 'btn-xs btn btn-remove' + - else + = link_to icon('trash'), member, + remote: true, + method: :delete, + data: { confirm: remove_member_message(member) }, + class: 'btn-xs btn btn-remove', + title: remove_member_title(member) + + .edit-member.hide.js-toggle-content + %br + = form_for member, remote: true do |f| + .prepend-top-10 + = f.select :access_level, options_for_select(member.class.access_level_roles, member.access_level), {}, class: 'form-control' + .prepend-top-10 + = f.submit 'Save', class: 'btn btn-save btn-sm' diff --git a/app/views/shared/members/_requests.html.haml b/app/views/shared/members/_requests.html.haml new file mode 100644 index 00000000000..b5963876034 --- /dev/null +++ b/app/views/shared/members/_requests.html.haml @@ -0,0 +1,8 @@ +- if members.any? + .panel.panel-default + .panel-heading + %strong= membership_source.name + access requests + %small= "(#{members.size})" + %ul.content-list + = render partial: 'shared/members/member', collection: members, as: :member diff --git a/app/views/shared/milestones/_merge_requests_tab.haml b/app/views/shared/milestones/_merge_requests_tab.haml index c29d8ee6737..9c193f901e2 100644 --- a/app/views/shared/milestones/_merge_requests_tab.haml +++ b/app/views/shared/milestones/_merge_requests_tab.haml @@ -3,10 +3,10 @@ .row.prepend-top-default .col-md-3 - = render 'shared/milestones/issuables', args.merge(title: 'Work in progress (open and unassigned)', issuables: merge_requests.opened.unassigned, id: 'unassigned') + = render 'shared/milestones/issuables', args.merge(title: 'Work in progress (open and unassigned)', issuables: merge_requests.opened.unassigned, id: 'unassigned', show_counter: true) .col-md-3 - = render 'shared/milestones/issuables', args.merge(title: 'Waiting for merge (open and assigned)', issuables: merge_requests.opened.assigned, id: 'ongoing') + = render 'shared/milestones/issuables', args.merge(title: 'Waiting for merge (open and assigned)', issuables: merge_requests.opened.assigned, id: 'ongoing', show_counter: true) .col-md-3 - = render 'shared/milestones/issuables', args.merge(title: 'Rejected (closed)', issuables: merge_requests.closed, id: 'closed') + = render 'shared/milestones/issuables', args.merge(title: 'Rejected (closed)', issuables: merge_requests.closed, id: 'closed', show_counter: true) .col-md-3 - = render 'shared/milestones/issuables', args.merge(title: 'Merged', issuables: merge_requests.merged, id: 'merged', primary: true) + = render 'shared/milestones/issuables', args.merge(title: 'Merged', issuables: merge_requests.merged, id: 'merged', primary: true, show_counter: true) diff --git a/app/views/u2f/_register.html.haml b/app/views/u2f/_register.html.haml index 46af591fc43..cbb8dfb7829 100644 --- a/app/views/u2f/_register.html.haml +++ b/app/views/u2f/_register.html.haml @@ -4,11 +4,18 @@ %p Your browser doesn't support U2F. Please use Google Chrome desktop (version 41 or newer). %script#js-register-u2f-setup{ type: "text/template" } - .row.append-bottom-10 - .col-md-3 - %a#js-setup-u2f-device.btn.btn-info{ href: 'javascript:void(0)' } Setup New U2F Device - .col-md-9 - %p Your U2F device needs to be set up. Plug it in (if not already) and click the button on the left. + - if current_user.two_factor_otp_enabled? + .row.append-bottom-10 + .col-md-3 + %button#js-setup-u2f-device.btn.btn-info Setup New U2F Device + .col-md-9 + %p Your U2F device needs to be set up. Plug it in (if not already) and click the button on the left. + - else + .row.append-bottom-10 + .col-md-3 + %button#js-setup-u2f-device.btn.btn-info{ disabled: true } Setup New U2F Device + .col-md-9 + %p.text-warning You need to register a two-factor authentication app before you can set up a U2F device. %script#js-register-u2f-in-progress{ type: "text/template" } %p Trying to communicate with your device. Plug it in (if you haven't already) and press the button on the device now. diff --git a/app/workers/expire_build_artifacts_worker.rb b/app/workers/expire_build_artifacts_worker.rb new file mode 100644 index 00000000000..c64ea108d52 --- /dev/null +++ b/app/workers/expire_build_artifacts_worker.rb @@ -0,0 +1,13 @@ +class ExpireBuildArtifactsWorker + include Sidekiq::Worker + + def perform + Rails.logger.info 'Cleaning old build artifacts' + + builds = Ci::Build.with_expired_artifacts + builds.find_each(batch_size: 50).each do |build| + Rails.logger.debug "Removing artifacts build #{build.id}..." + build.erase_artifacts! + end + end +end diff --git a/app/workers/stuck_ci_builds_worker.rb b/app/workers/stuck_ci_builds_worker.rb index ca594e77e7c..6828013b377 100644 --- a/app/workers/stuck_ci_builds_worker.rb +++ b/app/workers/stuck_ci_builds_worker.rb @@ -6,7 +6,7 @@ class StuckCiBuildsWorker def perform Rails.logger.info 'Cleaning stuck builds' - builds = Ci::Build.running_or_pending.where('updated_at < ?', BUILD_STUCK_TIMEOUT.ago) + builds = Ci::Build.joins(:project).running_or_pending.where('ci_builds.updated_at < ?', BUILD_STUCK_TIMEOUT.ago) builds.find_each(batch_size: 50).each do |build| Rails.logger.debug "Dropping stuck #{build.status} build #{build.id} for runner #{build.runner_id}" build.drop diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 1048ef6e243..75e1a3c1093 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -164,6 +164,9 @@ production: &base # Flag stuck CI builds as failed stuck_ci_builds_worker: cron: "0 0 * * *" + # Remove expired build artifacts + expire_build_artifacts_worker: + cron: "50 * * * *" # Periodically run 'git fsck' on all repositories. If started more than # once per hour you will have concurrent 'git fsck' jobs. repository_check_worker: diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 436751b9d16..916fd33e767 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -279,6 +279,9 @@ Settings['cron_jobs'] ||= Settingslogic.new({}) Settings.cron_jobs['stuck_ci_builds_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['stuck_ci_builds_worker']['cron'] ||= '0 0 * * *' Settings.cron_jobs['stuck_ci_builds_worker']['job_class'] = 'StuckCiBuildsWorker' +Settings.cron_jobs['expire_build_artifacts_worker'] ||= Settingslogic.new({}) +Settings.cron_jobs['expire_build_artifacts_worker']['cron'] ||= '50 * * * *' +Settings.cron_jobs['expire_build_artifacts_worker']['job_class'] = 'ExpireBuildArtifactsWorker' Settings.cron_jobs['repository_check_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['repository_check_worker']['cron'] ||= '20 * * * *' Settings.cron_jobs['repository_check_worker']['job_class'] = 'RepositoryCheck::BatchWorker' diff --git a/config/initializers/chronic_duration.rb b/config/initializers/chronic_duration.rb new file mode 100644 index 00000000000..b65b06c813a --- /dev/null +++ b/config/initializers/chronic_duration.rb @@ -0,0 +1 @@ +ChronicDuration.raise_exceptions = true diff --git a/config/initializers/metrics.rb b/config/initializers/metrics.rb index f6509ee43f1..d159f4eded2 100644 --- a/config/initializers/metrics.rb +++ b/config/initializers/metrics.rb @@ -128,14 +128,25 @@ if Gitlab::Metrics.enabled? config.instrument_instance_methods(API::Helpers) config.instrument_instance_methods(RepositoryCheck::SingleRepositoryWorker) - # Iterate over each non-super private instance method to keep up to date if - # internals change - RepositoryCheck::SingleRepositoryWorker.private_instance_methods(false).each do |method| - config.instrument_instance_method(RepositoryCheck::SingleRepositoryWorker, method) - end end GC::Profiler.enable Gitlab::Metrics::Sampler.new.start + + module TrackNewRedisConnections + def connect(*args) + val = super + + if current_transaction = Gitlab::Metrics::Transaction.current + current_transaction.increment(:new_redis_connections, 1) + end + + val + end + end + + class ::Redis::Client + prepend TrackNewRedisConnections + end end diff --git a/config/routes.rb b/config/routes.rb index 95fbe7dd9df..d52cbb22428 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -30,6 +30,11 @@ Rails.application.routes.draw do mount LetterOpenerWeb::Engine, at: '/rails/letter_opener' end + concern :access_requestable do + post :request_access, on: :collection + post :approve_access_request, on: :member + end + namespace :ci do # CI API Ci::API::API.logger Rails.logger @@ -409,7 +414,7 @@ Rails.application.routes.draw do end scope module: :groups do - resources :group_members, only: [:index, :create, :update, :destroy] do + resources :group_members, only: [:index, :create, :update, :destroy], concerns: :access_requestable do post :resend_invite, on: :member delete :leave, on: :collection end @@ -704,6 +709,8 @@ Rails.application.routes.draw do end end + resources :environments, only: [:index, :show, :new, :create, :destroy] + resources :builds, only: [:index, :show], constraints: { id: /\d+/ } do collection do post :cancel_all @@ -722,6 +729,7 @@ Rails.application.routes.draw do get :download get :browse, path: 'browse(/*path)', format: false get :file, path: 'file/*path', format: false + post :keep end end @@ -765,7 +773,7 @@ Rails.application.routes.draw do end end - resources :project_members, except: [:new, :edit], constraints: { id: /[a-zA-Z.\/0-9_\-#%+]+/ } do + resources :project_members, except: [:new, :edit], constraints: { id: /[a-zA-Z.\/0-9_\-#%+]+/ }, concerns: :access_requestable do collection do delete :leave @@ -789,6 +797,8 @@ Rails.application.routes.draw do end end + resources :todos, only: [:create, :update], constraints: { id: /\d+/ } + resources :uploads, only: [:create] do collection do get ":secret/:filename", action: :show, as: :show, constraints: { filename: /[^\/]+/ } diff --git a/db/fixtures/development/15_award_emoji.rb b/db/fixtures/development/15_award_emoji.rb new file mode 100644 index 00000000000..baac32f2d10 --- /dev/null +++ b/db/fixtures/development/15_award_emoji.rb @@ -0,0 +1,33 @@ +Gitlab::Seeder.quiet do + emoji = Gitlab::AwardEmoji.emojis.keys + + Issue.order(Gitlab::Database.random).limit(Issue.count / 2).each do |issue| + project = issue.project + + project.team.users.sample(2).each do |user| + issue.create_award_emoji(emoji.sample, user) + + issue.notes.sample(2).each do |note| + next if note.system? + note.create_award_emoji(emoji.sample, user) + end + + print '.' + end + end + + MergeRequest.order(Gitlab::Database.random).limit(MergeRequest.count / 2).each do |mr| + project = mr.project + + project.team.users.sample(2).each do |user| + mr.create_award_emoji(emoji.sample, user) + + mr.notes.sample(2).each do |note| + next if note.system? + note.create_award_emoji(emoji.sample, user) + end + + print '.' + end + end +end diff --git a/db/migrate/20160314114439_add_requested_at_to_members.rb b/db/migrate/20160314114439_add_requested_at_to_members.rb new file mode 100644 index 00000000000..273819d4cd8 --- /dev/null +++ b/db/migrate/20160314114439_add_requested_at_to_members.rb @@ -0,0 +1,5 @@ +class AddRequestedAtToMembers < ActiveRecord::Migration + def change + add_column :members, :requested_at, :datetime + end +end diff --git a/db/migrate/20160416182152_convert_award_note_to_emoji_award.rb b/db/migrate/20160416182152_convert_award_note_to_emoji_award.rb index c226bc11f6c..95ee03611d9 100644 --- a/db/migrate/20160416182152_convert_award_note_to_emoji_award.rb +++ b/db/migrate/20160416182152_convert_award_note_to_emoji_award.rb @@ -1,10 +1,37 @@ # rubocop:disable all class ConvertAwardNoteToEmojiAward < ActiveRecord::Migration - def change - def up - execute "INSERT INTO award_emoji (awardable_type, awardable_id, user_id, name, created_at, updated_at) (SELECT noteable_type, noteable_id, author_id, note, created_at, updated_at FROM notes WHERE is_award = true)" + disable_ddl_transaction! + + def up + if Gitlab::Database.postgresql? + migrate_postgresql + else + migrate_mysql + end + end + + def down + add_column :notes, :is_award, :boolean + # This migration does NOT move the awards on notes, if the table is dropped in another migration, these notes will be lost. + execute "INSERT INTO notes (noteable_type, noteable_id, author_id, note, created_at, updated_at, is_award) (SELECT awardable_type, awardable_id, user_id, name, created_at, updated_at, TRUE FROM award_emoji)" + end + + def migrate_postgresql + connection.transaction do + execute 'LOCK notes IN EXCLUSIVE MODE' + execute "INSERT INTO award_emoji (awardable_type, awardable_id, user_id, name, created_at, updated_at) (SELECT noteable_type, noteable_id, author_id, note, created_at, updated_at FROM notes WHERE is_award = true)" execute "DELETE FROM notes WHERE is_award = true" + remove_column :notes, :is_award, :boolean end end + + def migrate_mysql + execute 'LOCK TABLES notes WRITE, award_emoji WRITE;' + execute 'INSERT INTO award_emoji (awardable_type, awardable_id, user_id, name, created_at, updated_at) (SELECT noteable_type, noteable_id, author_id, note, created_at, updated_at FROM notes WHERE is_award = true);' + execute "DELETE FROM notes WHERE is_award = true" + remove_column :notes, :is_award, :boolean + ensure + execute 'UNLOCK TABLES' + end end diff --git a/db/migrate/20160416190505_remove_note_is_award.rb b/db/migrate/20160416190505_remove_note_is_award.rb deleted file mode 100644 index dd24917feb9..00000000000 --- a/db/migrate/20160416190505_remove_note_is_award.rb +++ /dev/null @@ -1,6 +0,0 @@ -# rubocop:disable all -class RemoveNoteIsAward < ActiveRecord::Migration - def change - remove_column :notes, :is_award, :boolean - end -end diff --git a/db/migrate/20160518200441_add_artifacts_expire_date_to_ci_builds.rb b/db/migrate/20160518200441_add_artifacts_expire_date_to_ci_builds.rb new file mode 100644 index 00000000000..915167b038d --- /dev/null +++ b/db/migrate/20160518200441_add_artifacts_expire_date_to_ci_builds.rb @@ -0,0 +1,5 @@ +class AddArtifactsExpireDateToCiBuilds < ActiveRecord::Migration + def change + add_column :ci_builds, :artifacts_expire_at, :timestamp + end +end diff --git a/db/migrate/20160610204157_add_deployments.rb b/db/migrate/20160610204157_add_deployments.rb new file mode 100644 index 00000000000..cb144ea8a6d --- /dev/null +++ b/db/migrate/20160610204157_add_deployments.rb @@ -0,0 +1,27 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddDeployments < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + def change + create_table :deployments, force: true do |t| + t.integer :iid, null: false + t.integer :project_id, null: false + t.integer :environment_id, null: false + t.string :ref, null: false + t.boolean :tag, null: false + t.string :sha, null: false + t.integer :user_id + t.integer :deployable_id + t.string :deployable_type + t.datetime :created_at + t.datetime :updated_at + end + + add_index :deployments, :project_id + add_index :deployments, [:project_id, :iid], unique: true + add_index :deployments, [:project_id, :environment_id] + add_index :deployments, [:project_id, :environment_id, :iid] + end +end diff --git a/db/migrate/20160610204158_add_environments.rb b/db/migrate/20160610204158_add_environments.rb new file mode 100644 index 00000000000..e1c71d173c4 --- /dev/null +++ b/db/migrate/20160610204158_add_environments.rb @@ -0,0 +1,17 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddEnvironments < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + def change + create_table :environments, force: true do |t| + t.integer :project_id, null: false + t.string :name, null: false + t.datetime :created_at + t.datetime :updated_at + end + + add_index :environments, [:project_id, :name] + end +end diff --git a/db/migrate/20160610211845_add_environment_to_builds.rb b/db/migrate/20160610211845_add_environment_to_builds.rb new file mode 100644 index 00000000000..990e445ac55 --- /dev/null +++ b/db/migrate/20160610211845_add_environment_to_builds.rb @@ -0,0 +1,10 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddEnvironmentToBuilds < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + def change + add_column :ci_builds, :environment, :string + end +end diff --git a/db/migrate/20160615142710_add_index_on_requested_at_to_members.rb b/db/migrate/20160615142710_add_index_on_requested_at_to_members.rb new file mode 100644 index 00000000000..63f7392e54f --- /dev/null +++ b/db/migrate/20160615142710_add_index_on_requested_at_to_members.rb @@ -0,0 +1,9 @@ +class AddIndexOnRequestedAtToMembers < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + disable_ddl_transaction! + + def change + add_concurrent_index :members, :requested_at + end +end diff --git a/db/schema.rb b/db/schema.rb index 3dccbbd50ba..6a3be7297e3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20160610301627) do +ActiveRecord::Schema.define(version: 20160615142710) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -144,9 +144,9 @@ ActiveRecord::Schema.define(version: 20160610301627) do t.text "commands" t.integer "job_id" t.string "name" - t.boolean "deploy", default: false + t.boolean "deploy", default: false t.text "options" - t.boolean "allow_failure", default: false, null: false + t.boolean "allow_failure", default: false, null: false t.string "stage" t.integer "trigger_request_id" t.integer "stage_idx" @@ -161,6 +161,8 @@ ActiveRecord::Schema.define(version: 20160610301627) do t.text "artifacts_metadata" t.integer "erased_by_id" t.datetime "erased_at" + t.string "environment" + t.datetime "artifacts_expire_at" end add_index "ci_builds", ["commit_id", "stage_idx", "created_at"], name: "index_ci_builds_on_commit_id_and_stage_idx_and_created_at", using: :btree @@ -381,6 +383,25 @@ ActiveRecord::Schema.define(version: 20160610301627) do add_index "deploy_keys_projects", ["project_id"], name: "index_deploy_keys_projects_on_project_id", using: :btree + create_table "deployments", force: :cascade do |t| + t.integer "iid", null: false + t.integer "project_id", null: false + t.integer "environment_id", null: false + t.string "ref", null: false + t.boolean "tag", null: false + t.string "sha", null: false + t.integer "user_id" + t.integer "deployable_id" + t.string "deployable_type" + t.datetime "created_at" + t.datetime "updated_at" + end + + add_index "deployments", ["project_id", "environment_id", "iid"], name: "index_deployments_on_project_id_and_environment_id_and_iid", using: :btree + add_index "deployments", ["project_id", "environment_id"], name: "index_deployments_on_project_id_and_environment_id", using: :btree + add_index "deployments", ["project_id", "iid"], name: "index_deployments_on_project_id_and_iid", unique: true, using: :btree + add_index "deployments", ["project_id"], name: "index_deployments_on_project_id", using: :btree + create_table "emails", force: :cascade do |t| t.integer "user_id", null: false t.string "email", null: false @@ -391,6 +412,15 @@ ActiveRecord::Schema.define(version: 20160610301627) do add_index "emails", ["email"], name: "index_emails_on_email", unique: true, using: :btree add_index "emails", ["user_id"], name: "index_emails_on_user_id", using: :btree + create_table "environments", force: :cascade do |t| + t.integer "project_id" + t.string "name", null: false + t.datetime "created_at" + t.datetime "updated_at" + end + + add_index "environments", ["project_id", "name"], name: "index_environments_on_project_id_and_name", using: :btree + create_table "events", force: :cascade do |t| t.string "target_type" t.integer "target_id" @@ -536,11 +566,13 @@ ActiveRecord::Schema.define(version: 20160610301627) do t.string "invite_email" t.string "invite_token" t.datetime "invite_accepted_at" + t.datetime "requested_at" end add_index "members", ["access_level"], name: "index_members_on_access_level", using: :btree add_index "members", ["created_at", "id"], name: "index_members_on_created_at_and_id", using: :btree add_index "members", ["invite_token"], name: "index_members_on_invite_token", unique: true, using: :btree + add_index "members", ["requested_at"], name: "index_members_on_requested_at", using: :btree add_index "members", ["source_id", "source_type"], name: "index_members_on_source_id_and_source_type", using: :btree add_index "members", ["type"], name: "index_members_on_type", using: :btree add_index "members", ["user_id"], name: "index_members_on_user_id", using: :btree @@ -747,37 +779,37 @@ ActiveRecord::Schema.define(version: 20160610301627) do t.datetime "created_at" t.datetime "updated_at" t.integer "creator_id" - t.boolean "issues_enabled", default: true, null: false - t.boolean "merge_requests_enabled", default: true, null: false - t.boolean "wiki_enabled", default: true, null: false + t.boolean "issues_enabled", default: true, null: false + t.boolean "merge_requests_enabled", default: true, null: false + t.boolean "wiki_enabled", default: true, null: false t.integer "namespace_id" - t.boolean "snippets_enabled", default: true, null: false + t.boolean "snippets_enabled", default: true, null: false t.datetime "last_activity_at" t.string "import_url" - t.integer "visibility_level", default: 0, null: false - t.boolean "archived", default: false, null: false + t.integer "visibility_level", default: 0, null: false + t.boolean "archived", default: false, null: false t.string "avatar" t.string "import_status" t.float "repository_size", default: 0.0 - t.integer "star_count", default: 0, null: false + t.integer "star_count", default: 0, null: false t.string "import_type" t.string "import_source" t.integer "commit_count", default: 0 t.text "import_error" t.integer "ci_id" - t.boolean "builds_enabled", default: true, null: false - t.boolean "shared_runners_enabled", default: true, null: false + t.boolean "builds_enabled", default: true, null: false + t.boolean "shared_runners_enabled", default: true, null: false t.string "runners_token" t.string "build_coverage_regex" - t.boolean "build_allow_git_fetch", default: true, null: false - t.integer "build_timeout", default: 3600, null: false + t.boolean "build_allow_git_fetch", default: true, null: false + t.integer "build_timeout", default: 3600, null: false t.boolean "pending_delete", default: false - t.boolean "public_builds", default: true, null: false + t.boolean "public_builds", default: true, null: false t.integer "pushes_since_gc", default: 0 t.boolean "last_repository_check_failed" t.datetime "last_repository_check_at" t.boolean "container_registry_enabled" - t.boolean "only_allow_merge_if_build_succeeds", default: false, null: false + t.boolean "only_allow_merge_if_build_succeeds", default: false, null: false t.boolean "has_external_issue_tracker" end diff --git a/doc/administration/troubleshooting/sidekiq.md b/doc/administration/troubleshooting/sidekiq.md index a776cd3f05e..b71f8fabbc8 100644 --- a/doc/administration/troubleshooting/sidekiq.md +++ b/doc/administration/troubleshooting/sidekiq.md @@ -147,7 +147,8 @@ bt To output a backtrace from all threads at once: ``` -apply all thread bt +set pagination off +thread apply all bt ``` Once you're done debugging with `gdb`, be sure to detach from the process and diff --git a/doc/api/README.md b/doc/api/README.md index 27c5962decf..e3fc5a09f21 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -8,32 +8,39 @@ under [`/lib/api`](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/lib/api). Documentation for various API resources can be found separately in the following locations: -- [Users](users.md) -- [Session](session.md) -- [Projects](projects.md) including setting Webhooks -- [Project Snippets](project_snippets.md) -- [Services](services.md) -- [Repositories](repositories.md) -- [Repository Files](repository_files.md) -- [Commits](commits.md) -- [Tags](tags.md) - [Branches](branches.md) -- [Merge Requests](merge_requests.md) +- [Builds](builds.md) +- [Build triggers](build_triggers.md) +- [Build Variables](build_variables.md) +- [Commits](commits.md) +- [Deploy Keys](deploy_keys.md) +- [Groups](groups.md) - [Issues](issues.md) +- [Keys](keys.md) - [Labels](labels.md) +- [Merge Requests](merge_requests.md) - [Milestones](milestones.md) -- [Notes](notes.md) (comments) -- [Deploy Keys](deploy_keys.md) -- [System Hooks](system_hooks.md) -- [Groups](groups.md) +- [Open source license templates](licenses.md) - [Namespaces](namespaces.md) -- [Settings](settings.md) -- [Keys](keys.md) -- [Builds](builds.md) -- [Build triggers](build_triggers.md) -- [Build Variables](build_variables.md) +- [Notes](notes.md) (comments) +- [Projects](projects.md) including setting Webhooks +- [Project Snippets](project_snippets.md) +- [Repositories](repositories.md) +- [Repository Files](repository_files.md) - [Runners](runners.md) -- [Open source license templates](licenses.md) +- [Services](services.md) +- [Session](session.md) +- [Settings](settings.md) +- [System Hooks](system_hooks.md) +- [Tags](tags.md) +- [Users](users.md) + +### Internal CI API + +The following documentation is for the [internal CI API](ci/README.md): + +- [Builds](ci/builds.md) +- [Runners](ci/runners.md) ## Authentication diff --git a/doc/api/builds.md b/doc/api/builds.md index 5669bd0cdda..de998944352 100644 --- a/doc/api/builds.md +++ b/doc/api/builds.md @@ -21,85 +21,85 @@ Example of response ```json [ - { - "commit": { - "author_email": "admin@example.com", - "author_name": "Administrator", - "created_at": "2015-12-24T16:51:14.000+01:00", - "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", - "message": "Test the CI integration.", - "short_id": "0ff3ae19", - "title": "Test the CI integration." - }, - "coverage": null, - "created_at": "2015-12-24T15:51:21.802Z", - "artifacts_file": { - "filename": "artifacts.zip", - "size": 1000 - }, - "finished_at": "2015-12-24T17:54:27.895Z", - "id": 7, - "name": "teaspoon", - "ref": "master", - "runner": null, - "stage": "test", - "started_at": "2015-12-24T17:54:27.722Z", - "status": "failed", - "tag": false, - "user": { - "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", - "bio": null, - "created_at": "2015-12-21T13:14:24.077Z", - "id": 1, - "is_admin": true, - "linkedin": "", - "name": "Administrator", - "skype": "", - "state": "active", - "twitter": "", - "username": "root", - "web_url": "http://gitlab.dev/u/root", - "website_url": "" - } + { + "commit": { + "author_email": "admin@example.com", + "author_name": "Administrator", + "created_at": "2015-12-24T16:51:14.000+01:00", + "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", + "message": "Test the CI integration.", + "short_id": "0ff3ae19", + "title": "Test the CI integration." + }, + "coverage": null, + "created_at": "2015-12-24T15:51:21.802Z", + "artifacts_file": { + "filename": "artifacts.zip", + "size": 1000 }, - { - "commit": { - "author_email": "admin@example.com", - "author_name": "Administrator", - "created_at": "2015-12-24T16:51:14.000+01:00", - "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", - "message": "Test the CI integration.", - "short_id": "0ff3ae19", - "title": "Test the CI integration." - }, - "coverage": null, - "created_at": "2015-12-24T15:51:21.727Z", - "artifacts_file": null, - "finished_at": "2015-12-24T17:54:24.921Z", - "id": 6, - "name": "spinach:other", - "ref": "master", - "runner": null, - "stage": "test", - "started_at": "2015-12-24T17:54:24.729Z", - "status": "failed", - "tag": false, - "user": { - "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", - "bio": null, - "created_at": "2015-12-21T13:14:24.077Z", - "id": 1, - "is_admin": true, - "linkedin": "", - "name": "Administrator", - "skype": "", - "state": "active", - "twitter": "", - "username": "root", - "web_url": "http://gitlab.dev/u/root", - "website_url": "" - } + "finished_at": "2015-12-24T17:54:27.895Z", + "id": 7, + "name": "teaspoon", + "ref": "master", + "runner": null, + "stage": "test", + "started_at": "2015-12-24T17:54:27.722Z", + "status": "failed", + "tag": false, + "user": { + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "bio": null, + "created_at": "2015-12-21T13:14:24.077Z", + "id": 1, + "is_admin": true, + "linkedin": "", + "name": "Administrator", + "skype": "", + "state": "active", + "twitter": "", + "username": "root", + "web_url": "http://gitlab.dev/u/root", + "website_url": "" + } + }, + { + "commit": { + "author_email": "admin@example.com", + "author_name": "Administrator", + "created_at": "2015-12-24T16:51:14.000+01:00", + "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", + "message": "Test the CI integration.", + "short_id": "0ff3ae19", + "title": "Test the CI integration." + }, + "coverage": null, + "created_at": "2015-12-24T15:51:21.727Z", + "artifacts_file": null, + "finished_at": "2015-12-24T17:54:24.921Z", + "id": 6, + "name": "spinach:other", + "ref": "master", + "runner": null, + "stage": "test", + "started_at": "2015-12-24T17:54:24.729Z", + "status": "failed", + "tag": false, + "user": { + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "bio": null, + "created_at": "2015-12-21T13:14:24.077Z", + "id": 1, + "is_admin": true, + "linkedin": "", + "name": "Administrator", + "skype": "", + "state": "active", + "twitter": "", + "username": "root", + "web_url": "http://gitlab.dev/u/root", + "website_url": "" } + } ] ``` @@ -125,68 +125,68 @@ Example of response ```json [ - { - "commit": { - "author_email": "admin@example.com", - "author_name": "Administrator", - "created_at": "2015-12-24T16:51:14.000+01:00", - "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", - "message": "Test the CI integration.", - "short_id": "0ff3ae19", - "title": "Test the CI integration." - }, - "coverage": null, - "created_at": "2016-01-11T10:13:33.506Z", - "artifacts_file": null, - "finished_at": "2016-01-11T10:14:09.526Z", - "id": 69, - "name": "rubocop", - "ref": "master", - "runner": null, - "stage": "test", - "started_at": null, - "status": "canceled", - "tag": false, - "user": null + { + "commit": { + "author_email": "admin@example.com", + "author_name": "Administrator", + "created_at": "2015-12-24T16:51:14.000+01:00", + "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", + "message": "Test the CI integration.", + "short_id": "0ff3ae19", + "title": "Test the CI integration." }, - { - "commit": { - "author_email": "admin@example.com", - "author_name": "Administrator", - "created_at": "2015-12-24T16:51:14.000+01:00", - "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", - "message": "Test the CI integration.", - "short_id": "0ff3ae19", - "title": "Test the CI integration." - }, - "coverage": null, - "created_at": "2015-12-24T15:51:21.957Z", - "artifacts_file": null, - "finished_at": "2015-12-24T17:54:33.913Z", - "id": 9, - "name": "brakeman", - "ref": "master", - "runner": null, - "stage": "test", - "started_at": "2015-12-24T17:54:33.727Z", - "status": "failed", - "tag": false, - "user": { - "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", - "bio": null, - "created_at": "2015-12-21T13:14:24.077Z", - "id": 1, - "is_admin": true, - "linkedin": "", - "name": "Administrator", - "skype": "", - "state": "active", - "twitter": "", - "username": "root", - "web_url": "http://gitlab.dev/u/root", - "website_url": "" - } + "coverage": null, + "created_at": "2016-01-11T10:13:33.506Z", + "artifacts_file": null, + "finished_at": "2016-01-11T10:14:09.526Z", + "id": 69, + "name": "rubocop", + "ref": "master", + "runner": null, + "stage": "test", + "started_at": null, + "status": "canceled", + "tag": false, + "user": null + }, + { + "commit": { + "author_email": "admin@example.com", + "author_name": "Administrator", + "created_at": "2015-12-24T16:51:14.000+01:00", + "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", + "message": "Test the CI integration.", + "short_id": "0ff3ae19", + "title": "Test the CI integration." + }, + "coverage": null, + "created_at": "2015-12-24T15:51:21.957Z", + "artifacts_file": null, + "finished_at": "2015-12-24T17:54:33.913Z", + "id": 9, + "name": "brakeman", + "ref": "master", + "runner": null, + "stage": "test", + "started_at": "2015-12-24T17:54:33.727Z", + "status": "failed", + "tag": false, + "user": { + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "bio": null, + "created_at": "2015-12-21T13:14:24.077Z", + "id": 1, + "is_admin": true, + "linkedin": "", + "name": "Administrator", + "skype": "", + "state": "active", + "twitter": "", + "username": "root", + "web_url": "http://gitlab.dev/u/root", + "website_url": "" } + } ] ``` @@ -211,42 +211,42 @@ Example of response ```json { - "commit": { - "author_email": "admin@example.com", - "author_name": "Administrator", - "created_at": "2015-12-24T16:51:14.000+01:00", - "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", - "message": "Test the CI integration.", - "short_id": "0ff3ae19", - "title": "Test the CI integration." - }, - "coverage": null, - "created_at": "2015-12-24T15:51:21.880Z", - "artifacts_file": null, - "finished_at": "2015-12-24T17:54:31.198Z", - "id": 8, - "name": "rubocop", - "ref": "master", - "runner": null, - "stage": "test", - "started_at": "2015-12-24T17:54:30.733Z", - "status": "failed", - "tag": false, - "user": { - "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", - "bio": null, - "created_at": "2015-12-21T13:14:24.077Z", - "id": 1, - "is_admin": true, - "linkedin": "", - "name": "Administrator", - "skype": "", - "state": "active", - "twitter": "", - "username": "root", - "web_url": "http://gitlab.dev/u/root", - "website_url": "" - } + "commit": { + "author_email": "admin@example.com", + "author_name": "Administrator", + "created_at": "2015-12-24T16:51:14.000+01:00", + "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", + "message": "Test the CI integration.", + "short_id": "0ff3ae19", + "title": "Test the CI integration." + }, + "coverage": null, + "created_at": "2015-12-24T15:51:21.880Z", + "artifacts_file": null, + "finished_at": "2015-12-24T17:54:31.198Z", + "id": 8, + "name": "rubocop", + "ref": "master", + "runner": null, + "stage": "test", + "started_at": "2015-12-24T17:54:30.733Z", + "status": "failed", + "tag": false, + "user": { + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "bio": null, + "created_at": "2015-12-21T13:14:24.077Z", + "id": 1, + "is_admin": true, + "linkedin": "", + "name": "Administrator", + "skype": "", + "state": "active", + "twitter": "", + "username": "root", + "web_url": "http://gitlab.dev/u/root", + "website_url": "" + } } ``` @@ -323,28 +323,28 @@ Example of response ```json { - "commit": { - "author_email": "admin@example.com", - "author_name": "Administrator", - "created_at": "2015-12-24T16:51:14.000+01:00", - "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", - "message": "Test the CI integration.", - "short_id": "0ff3ae19", - "title": "Test the CI integration." - }, - "coverage": null, - "created_at": "2016-01-11T10:13:33.506Z", - "artifacts_file": null, - "finished_at": "2016-01-11T10:14:09.526Z", - "id": 69, - "name": "rubocop", - "ref": "master", - "runner": null, - "stage": "test", - "started_at": null, - "status": "canceled", - "tag": false, - "user": null + "commit": { + "author_email": "admin@example.com", + "author_name": "Administrator", + "created_at": "2015-12-24T16:51:14.000+01:00", + "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", + "message": "Test the CI integration.", + "short_id": "0ff3ae19", + "title": "Test the CI integration." + }, + "coverage": null, + "created_at": "2016-01-11T10:13:33.506Z", + "artifacts_file": null, + "finished_at": "2016-01-11T10:14:09.526Z", + "id": 69, + "name": "rubocop", + "ref": "master", + "runner": null, + "stage": "test", + "started_at": null, + "status": "canceled", + "tag": false, + "user": null } ``` @@ -369,28 +369,28 @@ Example of response ```json { - "commit": { - "author_email": "admin@example.com", - "author_name": "Administrator", - "created_at": "2015-12-24T16:51:14.000+01:00", - "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", - "message": "Test the CI integration.", - "short_id": "0ff3ae19", - "title": "Test the CI integration." - }, - "coverage": null, - "created_at": "2016-01-11T10:13:33.506Z", - "artifacts_file": null, - "finished_at": null, - "id": 69, - "name": "rubocop", - "ref": "master", - "runner": null, - "stage": "test", - "started_at": null, - "status": "pending", - "tag": false, - "user": null + "commit": { + "author_email": "admin@example.com", + "author_name": "Administrator", + "created_at": "2015-12-24T16:51:14.000+01:00", + "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", + "message": "Test the CI integration.", + "short_id": "0ff3ae19", + "title": "Test the CI integration." + }, + "coverage": null, + "created_at": "2016-01-11T10:13:33.506Z", + "artifacts_file": null, + "finished_at": null, + "id": 69, + "name": "rubocop", + "ref": "master", + "runner": null, + "stage": "test", + "started_at": null, + "status": "pending", + "tag": false, + "user": null } ``` @@ -419,27 +419,77 @@ Example of response ```json { - "commit": { - "author_email": "admin@example.com", - "author_name": "Administrator", - "created_at": "2015-12-24T16:51:14.000+01:00", - "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", - "message": "Test the CI integration.", - "short_id": "0ff3ae19", - "title": "Test the CI integration." - }, - "coverage": null, - "download_url": null, - "id": 69, - "name": "rubocop", - "ref": "master", - "runner": null, - "stage": "test", - "created_at": "2016-01-11T10:13:33.506Z", - "started_at": "2016-01-11T10:13:33.506Z", - "finished_at": "2016-01-11T10:15:10.506Z", - "status": "failed", - "tag": false, - "user": null + "commit": { + "author_email": "admin@example.com", + "author_name": "Administrator", + "created_at": "2015-12-24T16:51:14.000+01:00", + "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", + "message": "Test the CI integration.", + "short_id": "0ff3ae19", + "title": "Test the CI integration." + }, + "coverage": null, + "download_url": null, + "id": 69, + "name": "rubocop", + "ref": "master", + "runner": null, + "stage": "test", + "created_at": "2016-01-11T10:13:33.506Z", + "started_at": "2016-01-11T10:13:33.506Z", + "finished_at": "2016-01-11T10:15:10.506Z", + "status": "failed", + "tag": false, + "user": null +} +``` + +## Keep artifacts + +Prevents artifacts from being deleted when expiration is set. + +``` +POST /projects/:id/builds/:build_id/artifacts/keep +``` + +Parameters + +| Attribute | Type | required | Description | +|-------------|---------|----------|---------------------| +| `id` | integer | yes | The ID of a project | +| `build_id` | integer | yes | The ID of a build | + +Example request: + +``` +curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/builds/1/artifacts/keep" +``` + +Example response: + +```json +{ + "commit": { + "author_email": "admin@example.com", + "author_name": "Administrator", + "created_at": "2015-12-24T16:51:14.000+01:00", + "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", + "message": "Test the CI integration.", + "short_id": "0ff3ae19", + "title": "Test the CI integration." + }, + "coverage": null, + "download_url": null, + "id": 69, + "name": "rubocop", + "ref": "master", + "runner": null, + "stage": "test", + "created_at": "2016-01-11T10:13:33.506Z", + "started_at": "2016-01-11T10:13:33.506Z", + "finished_at": "2016-01-11T10:15:10.506Z", + "status": "failed", + "tag": false, + "user": null } ``` diff --git a/doc/api/ci/README.md b/doc/api/ci/README.md new file mode 100644 index 00000000000..96a281e27c8 --- /dev/null +++ b/doc/api/ci/README.md @@ -0,0 +1,24 @@ +# GitLab CI API + +## Purpose + +The main purpose of GitLab CI API is to provide the necessary data and context +for GitLab CI Runners. + +All relevant information about the consumer API can be found in a +[separate document](../../api/README.md). + +## API Prefix + +The current CI API prefix is `/ci/api/v1`. + +You need to prepend this prefix to all examples in this documentation, like: + +```bash +GET /ci/api/v1/builds/:id/artifacts +``` + +## Resources + +- [Builds](builds.md) +- [Runners](runners.md) diff --git a/doc/api/ci/builds.md b/doc/api/ci/builds.md new file mode 100644 index 00000000000..d779463fd8c --- /dev/null +++ b/doc/api/ci/builds.md @@ -0,0 +1,138 @@ +# Builds API + +API used by runners to receive and update builds. + +>**Note:** +This API is intended to be used only by Runners as their own +communication channel. For the consumer API see the +[Builds API](../builds.md). + +## Authentication + +This API uses two types of authentication: + +1. Unique Runner's token which is the token assigned to the Runner after it + has been registered. + +2. Using the build authorization token. + This is project's CI token that can be found under the **Builds** section of + a project's settings. The build authorization token can be passed as a + parameter or a value of `BUILD-TOKEN` header. + +These two methods of authentication are interchangeable. + +## Builds + +### Runs oldest pending build by runner + +``` +POST /ci/api/v1/builds/register +``` + +| Attribute | Type | Required | Description | +|-----------|---------|----------|---------------------| +| `token` | string | yes | Unique runner token | + + +``` +curl -X POST "https://gitlab.example.com/ci/api/v1/builds/register" -F "token=t0k3n" +``` + +### Update details of an existing build + +``` +PUT /ci/api/v1/builds/:id +``` + +| Attribute | Type | Required | Description | +|-----------|---------|----------|----------------------| +| `id` | integer | yes | The ID of a project | +| `token` | string | yes | Unique runner token | +| `state` | string | no | The state of a build | +| `trace` | string | no | The trace of a build | + +``` +curl -X PUT "https://gitlab.example.com/ci/api/v1/builds/1234" -F "token=t0k3n" -F "state=running" -F "trace=Running git clone...\n" +``` + +### Incremental build trace update + +Using this method you need to send trace content as a request body. You also need to provide the `Content-Range` header +with a range of sent trace part. Note that you need to send parts in the proper order, so the begining of the part +must start just after the end of the previous part. If you provide the wrong part, then GitLab CI API will return `416 +Range Not Satisfiable` response with a header `Range: 0-X`, where `X` is the current trace length. + +For example, if you receive `Range: 0-11` in the response, then your next part must contain a `Content-Range: 11-...` +header and a trace part covered by this range. + +For a valid update API will return `202` response with: +* `Build-Status: {status}` header containing current status of the build, +* `Range: 0-{length}` header with the current trace length. + +``` +PATCH /ci/api/v1/builds/:id/trace.txt +``` + +Parameters: + +| Attribute | Type | Required | Description | +|-----------|---------|----------|----------------------| +| `id` | integer | yes | The ID of a build | + +Headers: + +| Attribute | Type | Required | Description | +|-----------------|---------|----------|-----------------------------------| +| `BUILD-TOKEN` | string | yes | The build authorization token | +| `Content-Range` | string | yes | Bytes range of trace that is sent | + +``` +curl -X PATCH "https://gitlab.example.com/ci/api/v1/builds/1234/trace.txt" -H "BUILD-TOKEN=build_t0k3n" -H "Content-Range=0-21" -d "Running git clone...\n" +``` + + +### Upload artifacts to build + +``` +POST /ci/api/v1/builds/:id/artifacts +``` + +| Attribute | Type | Required | Description | +|-----------|---------|----------|-------------------------------| +| `id` | integer | yes | The ID of a build | +| `token` | string | yes | The build authorization token | +| `file` | mixed | yes | Artifacts file | + +``` +curl -X POST "https://gitlab.example.com/ci/api/v1/builds/1234/artifacts" -F "token=build_t0k3n" -F "file=@/path/to/file" +``` + +### Download the artifacts file from build + +``` +GET /ci/api/v1/builds/:id/artifacts +``` + +| Attribute | Type | Required | Description | +|-----------|---------|----------|-------------------------------| +| `id` | integer | yes | The ID of a build | +| `token` | string | yes | The build authorization token | + +``` +curl "https://gitlab.example.com/ci/api/v1/builds/1234/artifacts" -F "token=build_t0k3n" +``` + +### Remove the artifacts file from build + +``` +DELETE /ci/api/v1/builds/:id/artifacts +``` + +| Attribute | Type | Required | Description | +|-----------|---------|----------|-------------------------------| +| ` id` | integer | yes | The ID of a build | +| `token` | string | yes | The build authorization token | + +``` +curl -X DELETE "https://gitlab.example.com/ci/api/v1/builds/1234/artifacts" -F "token=build_t0k3n" +``` diff --git a/doc/api/ci/runners.md b/doc/api/ci/runners.md new file mode 100644 index 00000000000..96b3c42f773 --- /dev/null +++ b/doc/api/ci/runners.md @@ -0,0 +1,57 @@ +# Runners API + +API used by Runners to register and delete themselves. + +>**Note:** +This API is intended to be used only by Runners as their own +communication channel. For the consumer API see the +[new Runners API](../runners.md). + +## Authentication + +This API uses two types of authentication: + +1. Unique Runner's token, which is the token assigned to the Runner after it + has been registered. + +2. Using Runners' registration token. + This is a token that can be found in project's settings. + It can also be found in the **Admin > Runners** settings area. + There are two types of tokens you can pass: shared Runner registration + token or project specific registration token. + +## Register a new runner + +Used to make GitLab CI aware of available runners. + +```sh +POST /ci/api/v1/runners/register +``` + +| Attribute | Type | Required | Description | +| --------- | ------- | --------- | ----------- | +| `token` | string | yes | Runner's registration token | + +Example request: + +```sh +curl -X POST "https://gitlab.example.com/ci/api/v1/runners/register" -F "token=t0k3n" +``` + +## Delete a Runner + +Used to remove a Runner. + +```sh +DELETE /ci/api/v1/runners/delete +``` + +| Attribute | Type | Required | Description | +| --------- | ------- | --------- | ----------- | +| `token` | string | yes | Runner's registration token | + +Example request: + +```sh +curl -X DELETE "https://gitlab.example.com/ci/api/v1/runners/delete" -F "token=t0k3n" +``` diff --git a/doc/api/issues.md b/doc/api/issues.md index fc7a7ae0c0c..0bc82ef9edb 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -365,6 +365,9 @@ target project is not found, error `404` is returned. If the target project equals the source project or the user has insufficient permissions to move an issue, error `400` together with an explaining error message is returned. +If a given label and/or milestone with the same name also exists in the target +project, it will then be assigned to the issue that is being moved. + ``` POST /projects/:id/issues/:issue_id/move ``` diff --git a/doc/ci/README.md b/doc/ci/README.md index 4abc45bf9bb..ef72df97ce6 100644 --- a/doc/ci/README.md +++ b/doc/ci/README.md @@ -14,5 +14,5 @@ - [Trigger builds through the API](triggers/README.md) - [Build artifacts](build_artifacts/README.md) - [User permissions](permissions/README.md) -- [API](api/README.md) +- [API](../../api/ci/README.md) - [CI services (linked docker containers)](services/README.md) diff --git a/doc/ci/api/README.md b/doc/ci/api/README.md index aea808007fc..4ca8d92d7cc 100644 --- a/doc/ci/api/README.md +++ b/doc/ci/api/README.md @@ -1,22 +1,3 @@ # GitLab CI API -## Purpose - -Main purpose of GitLab CI API is to provide necessary data and context for -GitLab CI Runners. - -For consumer API take a look at this [documentation](../../api/README.md) where -you will find all relevant information. - -## API Prefix - -Current CI API prefix is `/ci/api/v1`. - -You need to prepend this prefix to all examples in this documentation, like: - - GET /ci/api/v1/builds/:id/artifacts - -## Resources - -- [Builds](builds.md) -- [Runners](runners.md) +This document was moved to a [new location](../../api/ci/README.md). diff --git a/doc/ci/api/builds.md b/doc/ci/api/builds.md index 79761a893da..f5bd3181c02 100644 --- a/doc/ci/api/builds.md +++ b/doc/ci/api/builds.md @@ -1,139 +1,3 @@ # Builds API -API used by runners to receive and update builds. - -_**Note:** This API is intended to be used only by Runners as their own -communication channel. For the consumer API see the -[Builds API](../../api/builds.md)._ - -## Authentication - -This API uses two types of authentication: - -1. Unique runner's token - - Token assigned to runner after it has been registered. - -2. Using build authorization token - - This is project's CI token that can be found in Continuous Integration - project settings. - - Build authorization token can be passed as a parameter or a value of - `BUILD-TOKEN` header. This method are interchangeable. - -## Builds - -### Runs oldest pending build by runner - -``` -POST /ci/api/v1/builds/register -``` - -| Attribute | Type | Required | Description | -|-----------|---------|----------|---------------------| -| `token` | string | yes | Unique runner token | - - -``` -curl -X POST "https://gitlab.example.com/ci/api/v1/builds/register" -F "token=t0k3n" -``` - -### Update details of an existing build - -``` -PUT /ci/api/v1/builds/:id -``` - -| Attribute | Type | Required | Description | -|-----------|---------|----------|----------------------| -| `id` | integer | yes | The ID of a project | -| `token` | string | yes | Unique runner token | -| `state` | string | no | The state of a build | -| `trace` | string | no | The trace of a build | - -``` -curl -X PUT "https://gitlab.example.com/ci/api/v1/builds/1234" -F "token=t0k3n" -F "state=running" -F "trace=Running git clone...\n" -``` - -### Incremental build trace update - -Using this method you need to send trace content as a request body. You also need to provide the `Content-Range` header -with a range of sent trace part. Note that you need to send parts in the proper order, so the begining of the part -must start just after the end of the previous part. If you provide the wrong part, then GitLab CI API will return `416 -Range Not Satisfiable` response with a header `Range: 0-X`, where `X` is the current trace length. - -For example, if you receive `Range: 0-11` in the response, then your next part must contain a `Content-Range: 11-...` -header and a trace part covered by this range. - -For a valid update API will return `202` response with: -* `Build-Status: {status}` header containing current status of the build, -* `Range: 0-{length}` header with the current trace length. - -``` -PATCH /ci/api/v1/builds/:id/trace.txt -``` - -Parameters: - -| Attribute | Type | Required | Description | -|-----------|---------|----------|----------------------| -| `id` | integer | yes | The ID of a build | - -Headers: - -| Attribute | Type | Required | Description | -|-----------------|---------|----------|-----------------------------------| -| `BUILD-TOKEN` | string | yes | The build authorization token | -| `Content-Range` | string | yes | Bytes range of trace that is sent | - -``` -curl -X PATCH "https://gitlab.example.com/ci/api/v1/builds/1234/trace.txt" -H "BUILD-TOKEN=build_t0k3n" -H "Content-Range=0-21" -d "Running git clone...\n" -``` - - -### Upload artifacts to build - -``` -POST /ci/api/v1/builds/:id/artifacts -``` - -| Attribute | Type | Required | Description | -|-----------|---------|----------|-------------------------------| -| `id` | integer | yes | The ID of a build | -| `token` | string | yes | The build authorization token | -| `file` | mixed | yes | Artifacts file | - -``` -curl -X POST "https://gitlab.example.com/ci/api/v1/builds/1234/artifacts" -F "token=build_t0k3n" -F "file=@/path/to/file" -``` - -### Download the artifacts file from build - -``` -GET /ci/api/v1/builds/:id/artifacts -``` - -| Attribute | Type | Required | Description | -|-----------|---------|----------|-------------------------------| -| `id` | integer | yes | The ID of a build | -| `token` | string | yes | The build authorization token | - -``` -curl "https://gitlab.example.com/ci/api/v1/builds/1234/artifacts" -F "token=build_t0k3n" -``` - -### Remove the artifacts file from build - -``` -DELETE /ci/api/v1/builds/:id/artifacts -``` - -| Attribute | Type | Required | Description | -|-----------|---------|----------|-------------------------------| -| ` id` | integer | yes | The ID of a build | -| `token` | string | yes | The build authorization token | - -``` -curl -X DELETE "https://gitlab.example.com/ci/api/v1/builds/1234/artifacts" -F "token=build_t0k3n" -``` +This document was moved to a [new location](../../api/ci/builds.md). diff --git a/doc/ci/api/runners.md b/doc/ci/api/runners.md index 2f01da4bd76..b14ea99db76 100644 --- a/doc/ci/api/runners.md +++ b/doc/ci/api/runners.md @@ -1,46 +1,3 @@ # Runners API -API used by runners to register and delete themselves. - -_**Note:** This API is intended to be used only by Runners as their own -communication channel. For the consumer API see the -[new Runners API](../../api/runners.md)._ - -## Authentication - -This API uses two types of authentication: - -1. Unique runner's token - - Token assigned to runner after it has been registered. - -2. Using runners' registration token - - This is a token that can be found in project's settings. - It can be also found in Admin area » Runners settings. - - There are two types of tokens you can pass - shared runner registration - token or project specific registration token. - -## Runners - -### Register a new runner - -Used to make GitLab CI aware of available runners. - - POST /ci/api/v1/runners/register - -Parameters: - - * `token` (required) - Registration token - - -### Delete a runner - -Used to remove runner. - - DELETE /ci/api/v1/runners/delete - -Parameters: - - * `token` (required) - Unique runner token +This document was moved to a [new location](../../api/ci/runners.md). diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md index ca52a483a59..7f83f846454 100644 --- a/doc/ci/docker/using_docker_build.md +++ b/doc/ci/docker/using_docker_build.md @@ -4,14 +4,14 @@ GitLab CI allows you to use Docker Engine to build and test docker-based project **This also allows to you to use `docker-compose` and other docker-enabled tools.** -This is one of new trends in Continuous Integration/Deployment to: +One of the new trends in Continuous Integration/Deployment is to: -1. create application image, -1. run test against created image, -1. push image to remote registry, -1. deploy server from pushed image +1. create an application image, +1. run tests against the created image, +1. push image to a remote registry, and +1. deploy to a server from the pushed image. -It's also useful in case when your application already has the `Dockerfile` that can be used to create and test image: +It's also useful when your application already has the `Dockerfile` that can be used to create and test an image: ```bash $ docker build -t my-image dockerfiles/ $ docker run my-docker-image /script/to/run/tests @@ -19,24 +19,25 @@ $ docker tag my-image my-registry:5000/my-image $ docker push my-registry:5000/my-image ``` -However, this requires special configuration of GitLab Runner to enable `docker` support during build. -**This requires running GitLab Runner in privileged mode which can be harmful when untrusted code is run.** +This requires special configuration of GitLab Runner to enable `docker` support during builds. -There are two methods to enable the use of `docker build` and `docker run` during build. +## Runner Configuration -## 1. Use shell executor +There are three methods to enable the use of `docker build` and `docker run` during builds; each with their own tradeoffs. + +### Use shell executor The simplest approach is to install GitLab Runner in `shell` execution mode. -GitLab Runner then executes build scripts as `gitlab-runner` user. +GitLab Runner then executes build scripts as the `gitlab-runner` user. 1. Install [GitLab Runner](https://gitlab.com/gitlab-org/gitlab-ci-multi-runner/#installation). 1. During GitLab Runner installation select `shell` as method of executing build scripts or use command: ```bash - $ sudo gitlab-runner register -n \ + $ sudo gitlab-ci-multi-runner register -n \ --url https://gitlab.com/ci \ - --token RUNNER_TOKEN \ + --registration-token REGISTRATION_TOKEN \ --executor shell --description "My Runner" ``` @@ -70,16 +71,18 @@ GitLab Runner then executes build scripts as `gitlab-runner` user. 5. You can now use `docker` command and install `docker-compose` if needed. -6. However, by adding `gitlab-runner` to `docker` group you are effectively granting `gitlab-runner` full root permissions. -For more information please checkout [On Docker security: `docker` group considered harmful](https://www.andreas-jung.com/contents/on-docker-security-docker-group-considered-harmful). +> **Note:** +* By adding `gitlab-runner` to the `docker` group you are effectively granting `gitlab-runner` full root permissions. +For more information please read [On Docker security: `docker` group considered harmful](https://www.andreas-jung.com/contents/on-docker-security-docker-group-considered-harmful). -## 2. Use docker-in-docker executor +### Use docker-in-docker executor -The second approach is to use the special Docker image with all tools installed +The second approach is to use the special docker-in-docker (dind) +[Docker image](https://hub.docker.com/_/docker/) with all tools installed (`docker` and `docker-compose`) and run the build script in context of that image in privileged mode. -In order to do that follow the steps: +In order to do that, follow the steps: 1. Install [GitLab Runner](https://gitlab.com/gitlab-org/gitlab-ci-multi-runner/#installation). @@ -87,9 +90,9 @@ In order to do that follow the steps: mode: ```bash - sudo gitlab-runner register -n \ + sudo gitlab-ci-multi-runner register -n \ --url https://gitlab.com/ci \ - --token RUNNER_TOKEN \ + --registration-token REGISTRATION_TOKEN \ --executor docker \ --description "My Docker Runner" \ --docker-image "docker:latest" \ @@ -119,11 +122,7 @@ In order to do that follow the steps: Insecure = false ``` - If you want to use the Shared Runners available on your GitLab CE/EE - installation in order to build Docker images, then make sure that your - Shared Runners configuration has the `privileged` mode set to `true`. - -1. You can now use `docker` from build script: +1. You can now use `docker` in the build script (note the inclusion of the `docker:dind` service): ```yaml image: docker:latest @@ -141,14 +140,177 @@ In order to do that follow the steps: - docker run my-docker-image /script/to/run/tests ``` -1. However, by enabling `--docker-privileged` you are effectively disabling all - the security mechanisms of containers and exposing your host to privilege - escalation which can lead to container breakout. - - For more information, check out the official Docker documentation on - [Runtime privilege and Linux capabilities][docker-cap]. +Docker-in-Docker works well, and is the recommended configuration, but it is not without its own challenges: +* By enabling `--docker-privileged`, you are effectively disabling all of +the security mechanisms of containers and exposing your host to privilege +escalation which can lead to container breakout. For more information, check out the official Docker documentation on +[Runtime privilege and Linux capabilities][docker-cap]. +* Using docker-in-docker, each build is in a clean environment without the past +history. Concurrent builds work fine because every build gets it's own instance of docker engine so they won't conflict with each other. But this also means builds can be slower because there's no caching of layers. +* By default, `docker:dind` uses `--storage-driver vfs` which is the slowest form +offered. An example project using this approach can be found here: https://gitlab.com/gitlab-examples/docker. +### Use Docker socket binding + +The third approach is to bind-mount `/var/run/docker.sock` into the container so that docker is available in the context of that image. + +In order to do that, follow the steps: + +1. Install [GitLab Runner](https://gitlab.com/gitlab-org/gitlab-ci-multi-runner/#installation). + +1. Register GitLab Runner from the command line to use `docker` and share `/var/run/docker.sock`: + + ```bash + sudo gitlab-ci-multi-runner register -n \ + --url https://gitlab.com/ci \ + --registration-token REGISTRATION_TOKEN \ + --executor docker \ + --description "My Docker Runner" \ + --docker-image "docker:latest" \ + --docker-volumes /var/run/docker.sock:/var/run/docker.sock + ``` + + The above command will register a new Runner to use the special + `docker:latest` image which is provided by Docker. **Notice that it's using + the Docker daemon of the Runner itself, and any containers spawned by docker commands will be siblings of the Runner rather than children of the runner.** This may have complications and limitations that are unsuitable for your workflow. + + The above command will create a `config.toml` entry similar to this: + + ``` + [[runners]] + url = "https://gitlab.com/ci" + token = REGISTRATION_TOKEN + executor = "docker" + [runners.docker] + tls_verify = false + image = "docker:latest" + privileged = false + disable_cache = false + volumes = ["/var/run/docker.sock", "/cache"] + [runners.cache] + Insecure = false + ``` + +1. You can now use `docker` in the build script (note that you don't need to include the `docker:dind` service as when using the Docker in Docker executor): + + ```yaml + image: docker:latest + + before_script: + - docker info + + build: + stage: build + script: + - docker build -t my-docker-image . + - docker run my-docker-image /script/to/run/tests + ``` + +While the above method avoids using Docker in privileged mode, you should be aware of the following implications: +* By sharing the docker daemon, you are effectively disabling all +the security mechanisms of containers and exposing your host to privilege +escalation which can lead to container breakout. For example, if a project +ran `docker rm -f $(docker ps -a -q)` it would remove the GitLab Runner +containers. +* Concurrent builds may not work; if your tests +create containers with specific names, they may conflict with each other. +* Sharing files and directories from the source repo into containers may not +work as expected since volume mounting is done in the context of the host +machine, not the build container. +e.g. `docker run --rm -t -i -v $(pwd)/src:/home/app/src test-image:latest run_app_tests` + +## Using the GitLab Container Registry + +> **Note:** +This feature requires GitLab 8.8 and GitLab Runner 1.2. + +Once you've built a Docker image, you can push it up to the built-in [GitLab Container Registry](../../container_registry/README.md). For example, if you're using +docker-in-docker on your runners, this is how your `.gitlab-ci.yml` could look: + + +```yaml + build: + image: docker:latest + services: + - docker:dind + stage: build + script: + - docker login -u gitlab-ci-token -p $CI_BUILD_TOKEN registry.example.com + - docker build -t registry.example.com/group/project:latest . + - docker push registry.example.com/group/project:latest +``` + +You have to use the credentials of the special `gitlab-ci-token` user with its +password stored in `$CI_BUILD_TOKEN` in order to push to the Registry connected +to your project. This allows you to automate building and deployment of your +Docker images. + +Here's a more elaborate example that splits up the tasks into 4 pipeline stages, +including two tests that run in parallel. The build is stored in the container +registry and used by subsequent stages, downloading the image +when needed. Changes to `master` also get tagged as `latest` and deployed using +an application-specific deploy script: + +```yaml +image: docker:latest +services: +- docker:dind + +stages: +- build +- test +- release +- deploy + +variables: + CONTAINER_TEST_IMAGE: registry.example.com/my-group/my-project:$CI_BUILD_REF_NAME + CONTAINER_RELEASE_IMAGE: registry.example.com/my-group/my-project:latest + +before_script: + - docker login -u gitlab-ci-token -p $CI_BUILD_TOKEN registry.example.com + +build: + stage: build + script: + - docker build --pull -t $CONTAINER_TEST_IMAGE . + - docker push $CONTAINER_TEST_IMAGE + +test1: + stage: test + script: + - docker pull $CONTAINER_TEST_IMAGE + - docker run $CONTAINER_TEST_IMAGE /script/to/run/tests + +test2: + stage: test + script: + - docker pull $CONTAINER_TEST_IMAGE + - docker run $CONTAINER_TEST_IMAGE /script/to/run/another/test + +release-image: + stage: release + script: + - docker pull $CONTAINER_TEST_IMAGE + - docker tag $CONTAINER_TEST_IMAGE $CONTAINER_RELEASE_IMAGE + - docker push $CONTAINER_RELEASE_IMAGE + only: + - master + +deploy: + stage: deploy + script: + - ./deploy.sh + only: + - master +``` + +Some things you should be aware of when using the Container Registry: +* You must log in to the container registry before running commands. Putting this in `before_script` will run it before each build job. +* Using `docker build --pull` makes sure that Docker fetches any changes to base images before building just in case your cache is stale. It takes slightly longer, but means you don’t get stuck without security patches to base images. +* Doing an explicit `docker pull` before each `docker run` makes sure to fetch the latest image that was just built. This is especially important if you are using multiple runners that cache images locally. Using the git SHA in your image tag makes this less necessary since each build will be unique and you shouldn't ever have a stale image, but it's still possible if you re-build a given commit after a dependency has changed. +* You don't want to build directly to `latest` in case there are multiple builds happening simultaneously. + [docker-in-docker]: https://blog.docker.com/2013/09/docker-can-now-run-within-docker/ [docker-cap]: https://docs.docker.com/engine/reference/run/#runtime-privilege-and-linux-capabilities diff --git a/doc/ci/docker/using_docker_images.md b/doc/ci/docker/using_docker_images.md index 56ac2195c49..a849905ac6b 100644 --- a/doc/ci/docker/using_docker_images.md +++ b/doc/ci/docker/using_docker_images.md @@ -23,7 +23,7 @@ To use GitLab Runner with docker you need to register a new runner to use the `docker` executor: ```bash -gitlab-runner register \ +gitlab-ci-multi-runner register \ --url "https://gitlab.com/" \ --registration-token "PROJECT_REGISTRATION_TOKEN" \ --description "docker-ruby-2.1" \ diff --git a/doc/ci/examples/php.md b/doc/ci/examples/php.md index 26953014502..17e1c64bb8a 100644 --- a/doc/ci/examples/php.md +++ b/doc/ci/examples/php.md @@ -263,10 +263,10 @@ terminal execute: ```bash # Check using docker executor -gitlab-runner exec docker test:app +gitlab-ci-multi-runner exec docker test:app # Check using shell executor -gitlab-runner exec shell test:app +gitlab-ci-multi-runner exec shell test:app ``` ## Example project diff --git a/doc/ci/runners/README.md b/doc/ci/runners/README.md index b42d7a62ebc..400784da617 100644 --- a/doc/ci/runners/README.md +++ b/doc/ci/runners/README.md @@ -63,10 +63,10 @@ instance. Now simply register the runner as any runner: ``` -sudo gitlab-runner register +sudo gitlab-ci-multi-runner register ``` -Shared runners are enabled by default as of GitLab 8.2, but can be disabled with the +Shared runners are enabled by default as of GitLab 8.2, but can be disabled with the `DISABLE SHARED RUNNERS` button. Previous versions of GitLab defaulted shared runners to disabled. @@ -93,7 +93,7 @@ setup a specific runner for this project. To register the runner, run the command below and follow instructions: ``` -sudo gitlab-runner register +sudo gitlab-ci-multi-runner register ``` ### Making an existing Shared Runner Specific diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index 70fb81492d6..137b080a8f7 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -34,6 +34,7 @@ The `API_TOKEN` will take the Secure Variable value: `SECURE`. | **CI_BUILD_ID** | all | The unique id of the current build that GitLab CI uses internally | | **CI_BUILD_REPO** | all | The URL to clone the Git repository | | **CI_BUILD_TRIGGERED** | 0.5 | The flag to indicate that build was [triggered] | +| **CI_BUILD_TOKEN** | 1.2 | Token used for authenticating with the GitLab Container Registry | | **CI_PROJECT_ID** | all | The unique id of the current project that GitLab CI uses internally | | **CI_PROJECT_DIR** | all | The full path where the repository is cloned and where the build is ran | @@ -50,6 +51,7 @@ export CI_BUILD_TAG="1.0.0" export CI_BUILD_NAME="spec:other" export CI_BUILD_STAGE="test" export CI_BUILD_TRIGGERED="true" +export CI_BUILD_TOKEN="abcde-1234ABCD5678ef" export CI_PROJECT_DIR="/builds/gitlab-org/gitlab-ce" export CI_PROJECT_ID="34" export CI_SERVER="yes" diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 0707555e393..9c98f9c98c6 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -28,9 +28,11 @@ If you want a quick introduction to GitLab CI, follow our - [only and except](#only-and-except) - [tags](#tags) - [when](#when) + - [environment](#environment) - [artifacts](#artifacts) - [artifacts:name](#artifacts-name) - [artifacts:when](#artifacts-when) + - [artifacts:expire_in](#artifacts-expire_in) - [dependencies](#dependencies) - [before_script and after_script](#before_script-and-after_script) - [Hidden jobs](#hidden-jobs) @@ -353,6 +355,7 @@ job_name: | cache | no | Define list of files that should be cached between subsequent runs | | before_script | no | Override a set of commands that are executed before build | | after_script | no | Override a set of commands that are executed after build | +| environment | no | Defines a name of environment to which deployment is done by this build | ### script @@ -524,6 +527,31 @@ The above script will: 1. Execute `cleanup_build_job` only when `build_job` fails 2. Always execute `cleanup_job` as the last step in pipeline. +### environment + +>**Note:** +Introduced in GitLab v8.9.0. + +`environment` is used to define that job does deployment to specific environment. +This allows to easily track all deployments to your environments straight from GitLab. + +If `environment` is specified and no environment under that name does exist a new one will be created automatically. + +The `environment` name must contain only letters, digits, '-' and '_'. + +--- + +**Example configurations** + +``` +deploy to production: + stage: deploy + script: git push production HEAD:master + environment: production +``` + +The `deploy to production` job will be marked as doing deployment to `production` environment. + ### artifacts >**Notes:** @@ -678,6 +706,40 @@ job: when: on_failure ``` +#### artifacts:expire_in + +>**Note:** +Introduced in GitLab 8.9 and GitLab Runner v1.3.0. + +`artifacts:expire_in` is used to remove uploaded artifacts after specified time. +By default artifacts are stored on GitLab forver. +`expire_in` allows to specify after what time the artifacts should be removed. +The artifacts will expire counting from the moment when they are uploaded and stored on GitLab. + +After artifacts uploading you can use the **Keep** button on build page to keep the artifacts forever. + +Artifacts are removed every hour, but they are not accessible after expire date. + +The value of `expire_in` is a elapsed time. The example of parsable values: +- '3 mins 4 sec' +- '2 hrs 20 min' +- '2h20min' +- '6 mos 1 day' +- '47 yrs 6 mos and 4d' +- '3 weeks and 2 days' + +--- + +**Example configurations** + +To expire artifacts after 1 week from the moment that they are uploaded: + +```yaml +job: + artifacts: + expire_in: 1 week +``` + ### dependencies >**Note:** diff --git a/doc/container_registry/README.md b/doc/container_registry/README.md index 4df24ef13cc..1b465434498 100644 --- a/doc/container_registry/README.md +++ b/doc/container_registry/README.md @@ -79,27 +79,8 @@ delete them. This feature requires GitLab 8.8 and GitLab Runner 1.2. Make sure that your GitLab Runner is configured to allow building docker images. -You have to check the [Using Docker Build documentation](../../ci/docker/using_docker_build.md). - -You can use [docker:dind](https://hub.docker.com/_/docker/) to build your images, -and this is how `.gitlab-ci.yml` should look like: - -``` - build_image: - image: docker:git - services: - - docker:dind - stage: build - script: - - docker login -u gitlab-ci-token -p $CI_BUILD_TOKEN registry.example.com - - docker build -t registry.example.com/group/project:latest . - - docker push registry.example.com/group/project:latest -``` - -You have to use the credentials of the special `gitlab-ci-token` user with its -password stored in `$CI_BUILD_TOKEN` in order to push to the Registry connected -to your project. This allows you to automated building and deployment of your -Docker images. +You have to check the [Using Docker Build documentation](../ci/docker/using_docker_build.md). +Then see the CI documentation on [Using the GitLab Container Registry](../ci/docker/using_docker_build.md#using-the-gitlab-container-registry). ## Limitations diff --git a/doc/development/instrumentation.md b/doc/development/instrumentation.md index 9168c70945a..6cd9b274d11 100644 --- a/doc/development/instrumentation.md +++ b/doc/development/instrumentation.md @@ -15,8 +15,8 @@ instrument code: * `instrument_instance_method`: instruments a single instance method. * `instrument_class_hierarchy`: given a Class this method will recursively instrument all sub-classes (both class and instance methods). -* `instrument_methods`: instruments all public class methods of a Module. -* `instrument_instance_methods`: instruments all public instance methods of a +* `instrument_methods`: instruments all public and private class methods of a Module. +* `instrument_instance_methods`: instruments all public and private instance methods of a Module. To remove the need for typing the full `Gitlab::Metrics::Instrumentation` @@ -97,15 +97,16 @@ def #{name}(#{args_signature}) trans = Gitlab::Metrics::Instrumentation.transaction if trans - start = Time.now - retval = super - duration = (Time.now - start) * 1000.0 + start = Time.now + cpu_start = Gitlab::Metrics::System.cpu_time + retval = super + duration = (Time.now - start) * 1000.0 if duration >= Gitlab::Metrics.method_call_threshold - trans.increment(:method_duration, duration) + cpu_duration = Gitlab::Metrics::System.cpu_time - cpu_start trans.add_metric(Gitlab::Metrics::Instrumentation::SERIES, - { duration: duration }, + { duration: duration, cpu_duration: cpu_duration }, method: #{label.inspect}) end diff --git a/doc/permissions/permissions.md b/doc/permissions/permissions.md index b76ce31cbad..963b35de3a0 100644 --- a/doc/permissions/permissions.md +++ b/doc/permissions/permissions.md @@ -28,6 +28,7 @@ documentation](../workflow/add-user/add-user.md). | Manage labels | | ✓ | ✓ | ✓ | ✓ | | See a commit status | | ✓ | ✓ | ✓ | ✓ | | See a container registry | | ✓ | ✓ | ✓ | ✓ | +| See environments | | ✓ | ✓ | ✓ | ✓ | | Manage merge requests | | | ✓ | ✓ | ✓ | | Create new merge request | | | ✓ | ✓ | ✓ | | Create new branches | | | ✓ | ✓ | ✓ | @@ -40,6 +41,7 @@ documentation](../workflow/add-user/add-user.md). | Create or update commit status | | | ✓ | ✓ | ✓ | | Update a container registry | | | ✓ | ✓ | ✓ | | Remove a container registry image | | | ✓ | ✓ | ✓ | +| Create new environments | | | ✓ | ✓ | ✓ | | Create new milestones | | | | ✓ | ✓ | | Add new team members | | | | ✓ | ✓ | | Push to protected branches | | | | ✓ | ✓ | @@ -52,6 +54,7 @@ documentation](../workflow/add-user/add-user.md). | Manage runners | | | | ✓ | ✓ | | Manage build triggers | | | | ✓ | ✓ | | Manage variables | | | | ✓ | ✓ | +| Delete environments | | | | ✓ | ✓ | | Switch visibility level | | | | | ✓ | | Transfer project to another namespace | | | | | ✓ | | Remove project | | | | | ✓ | diff --git a/features/admin/active_tab.feature b/features/admin/active_tab.feature index 5de07e90e28..f5bb06dea7d 100644 --- a/features/admin/active_tab.feature +++ b/features/admin/active_tab.feature @@ -5,28 +5,36 @@ Feature: Admin Active Tab Scenario: On Admin Home Given I visit admin page - Then the active main tab should be Home + Then the active main tab should be Overview And no other main tabs should be active Scenario: On Admin Projects Given I visit admin projects page - Then the active main tab should be Projects + Then the active main tab should be Overview + And the active sub tab should be Projects And no other main tabs should be active + And no other sub tabs should be active Scenario: On Admin Groups Given I visit admin groups page - Then the active main tab should be Groups + Then the active main tab should be Overview + And the active sub tab should be Groups And no other main tabs should be active + And no other sub tabs should be active Scenario: On Admin Users Given I visit admin users page - Then the active main tab should be Users + Then the active main tab should be Overview + And the active sub tab should be Users And no other main tabs should be active + And no other sub tabs should be active Scenario: On Admin Logs Given I visit admin logs page - Then the active main tab should be Logs + Then the active main tab should be Monitoring + And the active sub tab should be Logs And no other main tabs should be active + And no other sub tabs should be active Scenario: On Admin Messages Given I visit admin messages page @@ -40,5 +48,7 @@ Feature: Admin Active Tab Scenario: On Admin Resque Given I visit admin Resque page - Then the active main tab should be Resque + Then the active main tab should be Monitoring + And the active sub tab should be Resque And no other main tabs should be active + And no other sub tabs should be active diff --git a/features/steps/admin/active_tab.rb b/features/steps/admin/active_tab.rb index f2db1801389..9b1689a8198 100644 --- a/features/steps/admin/active_tab.rb +++ b/features/steps/admin/active_tab.rb @@ -1,45 +1,41 @@ class Spinach::Features::AdminActiveTab < Spinach::FeatureSteps include SharedAuthentication include SharedPaths - include SharedSidebarActiveTab + include SharedActiveTab - step 'the active main tab should be Home' do + step 'the active main tab should be Overview' do ensure_active_main_tab('Overview') end - step 'the active main tab should be Projects' do - ensure_active_main_tab('Projects') + step 'the active sub tab should be Projects' do + ensure_active_sub_tab('Projects') end - step 'the active main tab should be Groups' do - ensure_active_main_tab('Groups') + step 'the active sub tab should be Groups' do + ensure_active_sub_tab('Groups') end - step 'the active main tab should be Users' do - ensure_active_main_tab('Users') - end - - step 'the active main tab should be Logs' do - ensure_active_main_tab('Logs') + step 'the active sub tab should be Users' do + ensure_active_sub_tab('Users') end step 'the active main tab should be Hooks' do ensure_active_main_tab('Hooks') end - step 'the active main tab should be Resque' do - ensure_active_main_tab('Background Jobs') + step 'the active main tab should be Monitoring' do + ensure_active_main_tab('Monitoring') end - step 'the active main tab should be Messages' do - ensure_active_main_tab('Messages') + step 'the active sub tab should be Resque' do + ensure_active_sub_tab('Background Jobs') end - step 'no other main tabs should be active' do - expect(page).to have_selector('.nav-sidebar > li.active', count: 1) + step 'the active sub tab should be Logs' do + ensure_active_sub_tab('Logs') end - def ensure_active_main_tab(content) - expect(find('.nav-sidebar > li.active')).to have_content(content) + step 'the active main tab should be Messages' do + ensure_active_main_tab('Messages') end end diff --git a/features/steps/dashboard/group.rb b/features/steps/dashboard/group.rb index 0c6a0ae3725..9b79a3be49b 100644 --- a/features/steps/dashboard/group.rb +++ b/features/steps/dashboard/group.rb @@ -62,6 +62,6 @@ class Spinach::Features::DashboardGroup < Spinach::FeatureSteps end step 'I should see the "Can not leave message"' do - expect(page).to have_content "You can not leave Owned group because you're the last owner" + expect(page).to have_content "You can not leave the \"Owned\" group." end end diff --git a/features/steps/group/members.rb b/features/steps/group/members.rb index 0706df3aec5..dfa2fa75def 100644 --- a/features/steps/group/members.rb +++ b/features/steps/group/members.rb @@ -53,7 +53,7 @@ class Spinach::Features::GroupMembers < Spinach::FeatureSteps step 'I should see "sjobs@apple.com" in team list as invited "Reporter"' do page.within '.content-list' do expect(page).to have_content('sjobs@apple.com') - expect(page).to have_content('invited') + expect(page).to have_content('Invited') expect(page).to have_content('Reporter') end end @@ -116,11 +116,9 @@ class Spinach::Features::GroupMembers < Spinach::FeatureSteps member = mary_jane_member page.within "#group_member_#{member.id}" do - find(".js-toggle-button").click - page.within "#edit_group_member_#{member.id}" do - select 'Developer', from: 'group_member_access_level' - click_on 'Save' - end + click_button "Edit access level" + select 'Developer', from: 'group_member_access_level' + click_on 'Save' end end @@ -128,9 +126,7 @@ class Spinach::Features::GroupMembers < Spinach::FeatureSteps member = mary_jane_member page.within "#group_member_#{member.id}" do - page.within '.member-access-level' do - expect(page).to have_content "Developer" - end + expect(page).to have_content "Developer" end end diff --git a/features/steps/project/team_management.rb b/features/steps/project/team_management.rb index c6ced747370..f32576d2cb1 100644 --- a/features/steps/project/team_management.rb +++ b/features/steps/project/team_management.rb @@ -26,8 +26,11 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps end step 'I should see "Mike" in team list as "Reporter"' do - page.within ".access-reporter" do + user = User.find_by(name: 'Mike') + project_member = project.project_members.find_by(user_id: user.id) + page.within "#project_member_#{project_member.id}" do expect(page).to have_content('Mike') + expect(page).to have_content('Reporter') end end @@ -40,16 +43,20 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps end step 'I should see "sjobs@apple.com" in team list as invited "Reporter"' do - page.within ".access-reporter" do + project_member = project.project_members.find_by(invite_email: 'sjobs@apple.com') + page.within "#project_member_#{project_member.id}" do expect(page).to have_content('sjobs@apple.com') - expect(page).to have_content('invited') + expect(page).to have_content('Invited') expect(page).to have_content('Reporter') end end step 'I should see "Dmitriy" in team list as "Developer"' do - page.within ".access-developer" do + user = User.find_by(name: 'Dmitriy') + project_member = project.project_members.find_by(user_id: user.id) + page.within "#project_member_#{project_member.id}" do expect(page).to have_content('Dmitriy') + expect(page).to have_content('Developer') end end @@ -65,15 +72,14 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps end step 'I should see "Dmitriy" in team list as "Reporter"' do - page.within ".access-reporter" do + user = User.find_by(name: 'Dmitriy') + project_member = project.project_members.find_by(user_id: user.id) + page.within "#project_member_#{project_member.id}" do expect(page).to have_content('Dmitriy') + expect(page).to have_content('Reporter') end end - step 'I click link "Remove from team"' do - click_link "Remove from team" - end - step 'I should not see "Dmitriy" in team list' do user = User.find_by(name: "Dmitriy") expect(page).not_to have_content(user.name) @@ -120,7 +126,7 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps user = User.find_by(name: 'Dmitriy') project_member = project.project_members.find_by(user_id: user.id) page.within "#project_member_#{project_member.id}" do - click_link('Remove user from team') + click_link('Remove user from project') end end diff --git a/lib/api/builds.rb b/lib/api/builds.rb index 0ff8fa74a84..979328efe0e 100644 --- a/lib/api/builds.rb +++ b/lib/api/builds.rb @@ -142,7 +142,7 @@ module API return not_found!(build) unless build return forbidden!('Build is not retryable') unless build.retryable? - build = Ci::Build.retry(build) + build = Ci::Build.retry(build, current_user) present build, with: Entities::Build, user_can_download_artifacts: can?(current_user, :read_build, user_project) @@ -166,6 +166,26 @@ module API present build, with: Entities::Build, user_can_download_artifacts: can?(current_user, :download_build_artifacts, user_project) end + + # Keep the artifacts to prevent them from being deleted + # + # Parameters: + # id (required) - the id of a project + # build_id (required) - The ID of a build + # Example Request: + # POST /projects/:id/builds/:build_id/artifacts/keep + post ':id/builds/:build_id/artifacts/keep' do + authorize_update_builds! + + build = get_build(params[:build_id]) + return not_found!(build) unless build && build.artifacts? + + build.keep_artifacts! + + status 200 + present build, with: Entities::Build, + user_can_download_artifacts: can?(current_user, :read_build, user_project) + end end helpers do diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 14370ac218d..cc29c7ef428 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -88,10 +88,7 @@ module API class Group < Grape::Entity expose :id, :name, :path, :description, :visibility_level expose :avatar_url - - expose :web_url do |group, options| - Gitlab::Routing.url_helpers.group_url(group) - end + expose :web_url end class GroupDetail < Group diff --git a/lib/api/project_members.rb b/lib/api/project_members.rb index 4aefdf319c6..b703da0557a 100644 --- a/lib/api/project_members.rb +++ b/lib/api/project_members.rb @@ -46,7 +46,7 @@ module API required_attributes! [:user_id, :access_level] # either the user is already a team member or a new one - project_member = user_project.project_member_by_id(params[:user_id]) + project_member = user_project.project_member(params[:user_id]) if project_member.nil? project_member = user_project.project_members.new( user_id: params[:user_id], diff --git a/lib/banzai/reference_parser/issue_parser.rb b/lib/banzai/reference_parser/issue_parser.rb index 24076e3d9ec..f306079d833 100644 --- a/lib/banzai/reference_parser/issue_parser.rb +++ b/lib/banzai/reference_parser/issue_parser.rb @@ -25,7 +25,21 @@ module Banzai def issues_for_nodes(nodes) @issues_for_nodes ||= grouped_objects_for_nodes( nodes, - Issue.all.includes(:author, :assignee, :project), + Issue.all.includes( + :author, + :assignee, + { + # These associations are primarily used for checking permissions. + # Eager loading these ensures we don't end up running dozens of + # queries in this process. + project: [ + { namespace: :owner }, + { group: [:owners, :group_members] }, + :invited_groups, + :project_members + ] + } + ), self.class.data_attribute ) end diff --git a/lib/ci/api/builds.rb b/lib/ci/api/builds.rb index 607359769d1..9f270f7b387 100644 --- a/lib/ci/api/builds.rb +++ b/lib/ci/api/builds.rb @@ -114,6 +114,7 @@ module Ci # id (required) - The ID of a build # token (required) - The build authorization token # file (required) - Artifacts file + # expire_in (optional) - Specify when artifacts should expire (ex. 7d) # Parameters (accelerated by GitLab Workhorse): # file.path - path to locally stored body (generated by Workhorse) # file.name - real filename as send in Content-Disposition @@ -145,6 +146,7 @@ module Ci build.artifacts_file = artifacts build.artifacts_metadata = metadata + build.artifacts_expire_in = params['expire_in'] if build.save present(build, with: Entities::BuildDetails) diff --git a/lib/ci/api/entities.rb b/lib/ci/api/entities.rb index a902ced35d7..3f5bdaba3f5 100644 --- a/lib/ci/api/entities.rb +++ b/lib/ci/api/entities.rb @@ -20,7 +20,7 @@ module Ci expose :name, :token, :stage expose :project_id expose :project_name - expose :artifacts_file, using: ArtifactFile, if: lambda { |build, opts| build.artifacts? } + expose :artifacts_file, using: ArtifactFile, if: ->(build, _) { build.artifacts? } end class BuildDetails < Build @@ -29,6 +29,7 @@ module Ci expose :before_sha expose :allow_git_fetch expose :token + expose :artifacts_expire_at, if: ->(build, _) { build.artifacts? } expose :options do |model| model.options diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index 4531f40eced..a66602f9194 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -2,19 +2,24 @@ module Ci class GitlabCiYamlProcessor class ValidationError < StandardError; end + include Gitlab::Ci::Config::Node::ValidationHelpers + DEFAULT_STAGES = %w(build test deploy) DEFAULT_STAGE = 'test' ALLOWED_YAML_KEYS = [:before_script, :after_script, :image, :services, :types, :stages, :variables, :cache] ALLOWED_JOB_KEYS = [:tags, :script, :only, :except, :type, :image, :services, :allow_failure, :type, :stage, :when, :artifacts, :cache, - :dependencies, :before_script, :after_script, :variables] + :dependencies, :before_script, :after_script, :variables, + :environment] ALLOWED_CACHE_KEYS = [:key, :untracked, :paths] - ALLOWED_ARTIFACTS_KEYS = [:name, :untracked, :paths, :when] + ALLOWED_ARTIFACTS_KEYS = [:name, :untracked, :paths, :when, :expire_in] - attr_reader :before_script, :after_script, :image, :services, :path, :cache + attr_reader :after_script, :image, :services, :path, :cache def initialize(config, path = nil) - @config = Gitlab::Ci::Config.new(config).to_hash + @ci_config = Gitlab::Ci::Config.new(config) + @config = @ci_config.to_hash + @path = path initial_parsing @@ -55,7 +60,6 @@ module Ci private def initial_parsing - @before_script = @config[:before_script] || [] @after_script = @config[:after_script] @image = @config[:image] @services = @config[:services] @@ -83,13 +87,14 @@ module Ci { stage_idx: stages.index(job[:stage]), stage: job[:stage], - commands: [job[:before_script] || @before_script, job[:script]].flatten.join("\n"), + commands: [job[:before_script] || [@ci_config.before_script], job[:script]].flatten.compact.join("\n"), tag_list: job[:tags] || [], name: name, only: job[:only], except: job[:except], allow_failure: job[:allow_failure] || false, when: job[:when] || 'on_success', + environment: job[:environment], options: { image: job[:image] || @image, services: job[:services] || @services, @@ -102,6 +107,10 @@ module Ci end def validate! + unless @ci_config.valid? + raise ValidationError, @ci_config.errors.first + end + validate_global! @jobs.each do |name, job| @@ -112,10 +121,6 @@ module Ci end def validate_global! - unless validate_array_of_strings(@before_script) - raise ValidationError, "before_script should be an array of strings" - end - unless @after_script.nil? || validate_array_of_strings(@after_script) raise ValidationError, "after_script should be an array of strings" end @@ -214,6 +219,10 @@ module Ci if job[:when] && !job[:when].in?(%w[on_success on_failure always]) raise ValidationError, "#{name} job: when parameter should be on_success, on_failure or always" end + + if job[:environment] && !validate_environment(job[:environment]) + raise ValidationError, "#{name} job: environment parameter #{Gitlab::Regex.environment_name_regex_message}" + end end def validate_job_script!(name, job) @@ -285,6 +294,10 @@ module Ci if job[:artifacts][:when] && !job[:artifacts][:when].in?(%w[on_success on_failure always]) raise ValidationError, "#{name} job: artifacts:when parameter should be on_success, on_failure or always" end + + if job[:artifacts][:expire_in] && !validate_duration(job[:artifacts][:expire_in]) + raise ValidationError, "#{name} job: artifacts:expire_in parameter should be a duration" + end end def validate_job_dependencies!(name, job) @@ -303,22 +316,6 @@ module Ci end end - def validate_array_of_strings(values) - values.is_a?(Array) && values.all? { |value| validate_string(value) } - end - - def validate_variables(variables) - variables.is_a?(Hash) && variables.all? { |key, value| validate_string(key) && validate_string(value) } - end - - def validate_string(value) - value.is_a?(String) || value.is_a?(Symbol) - end - - def validate_boolean(value) - value.in?([true, false]) - end - def process?(only_params, except_params, ref, tag, trigger_request) if only_params.present? return false unless matching?(only_params, ref, tag, trigger_request) diff --git a/lib/container_registry/blob.rb b/lib/container_registry/blob.rb index 4e20dc4f875..eb5a2596177 100644 --- a/lib/container_registry/blob.rb +++ b/lib/container_registry/blob.rb @@ -18,7 +18,7 @@ module ContainerRegistry end def digest - config['digest'] + config['digest'] || config['blobSum'] end def type diff --git a/lib/container_registry/client.rb b/lib/container_registry/client.rb index 4d726692f45..e0b3f14d384 100644 --- a/lib/container_registry/client.rb +++ b/lib/container_registry/client.rb @@ -47,7 +47,9 @@ module ContainerRegistry conn.request :json conn.headers['Accept'] = MANIFEST_VERSION - conn.response :json, content_type: /\bjson$/ + conn.response :json, content_type: 'application/vnd.docker.distribution.manifest.v1+prettyjws' + conn.response :json, content_type: 'application/vnd.docker.distribution.manifest.v1+json' + conn.response :json, content_type: 'application/vnd.docker.distribution.manifest.v2+json' if options[:user] && options[:password] conn.request(:basic_auth, options[:user].to_s, options[:password].to_s) diff --git a/lib/container_registry/tag.rb b/lib/container_registry/tag.rb index 43f8d6dc8c2..7a0929d774e 100644 --- a/lib/container_registry/tag.rb +++ b/lib/container_registry/tag.rb @@ -12,6 +12,14 @@ module ContainerRegistry manifest.present? end + def v1? + manifest && manifest['schemaVersion'] == 1 + end + + def v2? + manifest && manifest['schemaVersion'] == 2 + end + def manifest return @manifest if defined?(@manifest) @@ -57,7 +65,9 @@ module ContainerRegistry return @layers if defined?(@layers) return unless manifest - @layers = manifest['layers'].map do |layer| + layers = manifest['layers'] || manifest['fsLayers'] + + @layers = layers.map do |layer| repository.blob(layer) end end @@ -65,7 +75,7 @@ module ContainerRegistry def total_size return unless layers - layers.map(&:size).sum + layers.map(&:size).sum if v2? end def delete diff --git a/lib/gitlab/backend/grack_auth.rb b/lib/gitlab/backend/grack_auth.rb index adbf5941a96..7e3f5abba62 100644 --- a/lib/gitlab/backend/grack_auth.rb +++ b/lib/gitlab/backend/grack_auth.rb @@ -1,5 +1,3 @@ -require_relative 'shell_env' - module Grack class AuthSpawner def self.call(env) @@ -61,11 +59,6 @@ module Grack end @user = authenticate_user(login, password) - - if @user - Gitlab::ShellEnv.set_env(@user) - @env['REMOTE_USER'] = @auth.username - end end def ci_request?(login, password) diff --git a/lib/gitlab/backend/shell_env.rb b/lib/gitlab/backend/shell_env.rb deleted file mode 100644 index 9f5adee594a..00000000000 --- a/lib/gitlab/backend/shell_env.rb +++ /dev/null @@ -1,28 +0,0 @@ -module Gitlab - # This module provide 2 methods - # to set specific ENV variables for GitLab Shell - module ShellEnv - extend self - - def set_env(user) - # Set GL_ID env variable - if user - ENV['GL_ID'] = gl_id(user) - end - end - - def reset_env - # Reset GL_ID env variable - ENV['GL_ID'] = nil - end - - def gl_id(user) - if user.present? - "user-#{user.id}" - else - # This empty string is used in the render_grack_auth_ok method - "" - end - end - end -end diff --git a/lib/gitlab/ci/config.rb b/lib/gitlab/ci/config.rb index ffe633d4b63..b48d3592f16 100644 --- a/lib/gitlab/ci/config.rb +++ b/lib/gitlab/ci/config.rb @@ -1,11 +1,21 @@ module Gitlab module Ci + ## + # Base GitLab CI Configuration facade + # class Config - class LoaderError < StandardError; end + delegate :valid?, :errors, to: :@global + + ## + # Temporary delegations that should be removed after refactoring + # + delegate :before_script, to: :@global def initialize(config) - loader = Loader.new(config) - @config = loader.load! + @config = Loader.new(config).load! + + @global = Node::Global.new(@config) + @global.process! end def to_hash diff --git a/lib/gitlab/ci/config/node/configurable.rb b/lib/gitlab/ci/config/node/configurable.rb new file mode 100644 index 00000000000..d60f87f3f94 --- /dev/null +++ b/lib/gitlab/ci/config/node/configurable.rb @@ -0,0 +1,61 @@ +module Gitlab + module Ci + class Config + module Node + ## + # This mixin is responsible for adding DSL, which purpose is to + # simplifly process of adding child nodes. + # + # This can be used only if parent node is a configuration entry that + # holds a hash as a configuration value, for example: + # + # job: + # script: ... + # artifacts: ... + # + module Configurable + extend ActiveSupport::Concern + + def allowed_nodes + self.class.allowed_nodes || {} + end + + private + + def prevalidate! + unless @value.is_a?(Hash) + @errors << 'should be a configuration entry with hash value' + end + end + + def create_node(key, factory) + factory.with(value: @value[key]) + factory.nullify! unless @value.has_key?(key) + factory.create! + end + + class_methods do + def allowed_nodes + Hash[@allowed_nodes.map { |key, factory| [key, factory.dup] }] + end + + private + + def allow_node(symbol, entry_class, metadata) + factory = Node::Factory.new(entry_class) + .with(description: metadata[:description]) + + define_method(symbol) do + raise Entry::InvalidError unless valid? + + @nodes[symbol].try(:value) + end + + (@allowed_nodes ||= {}).merge!(symbol => factory) + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/node/entry.rb b/lib/gitlab/ci/config/node/entry.rb new file mode 100644 index 00000000000..52758a962f3 --- /dev/null +++ b/lib/gitlab/ci/config/node/entry.rb @@ -0,0 +1,77 @@ +module Gitlab + module Ci + class Config + module Node + ## + # Base abstract class for each configuration entry node. + # + class Entry + class InvalidError < StandardError; end + + attr_accessor :description + + def initialize(value) + @value = value + @nodes = {} + @errors = [] + + prevalidate! + end + + def process! + return if leaf? + return unless valid? + + compose! + + nodes.each(&:process!) + nodes.each(&:validate!) + end + + def nodes + @nodes.values + end + + def valid? + errors.none? + end + + def leaf? + allowed_nodes.none? + end + + def errors + @errors + nodes.map(&:errors).flatten + end + + def allowed_nodes + {} + end + + def validate! + raise NotImplementedError + end + + def value + raise NotImplementedError + end + + private + + def prevalidate! + end + + def compose! + allowed_nodes.each do |key, essence| + @nodes[key] = create_node(key, essence) + end + end + + def create_node(key, essence) + raise NotImplementedError + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/node/factory.rb b/lib/gitlab/ci/config/node/factory.rb new file mode 100644 index 00000000000..787ca006f5a --- /dev/null +++ b/lib/gitlab/ci/config/node/factory.rb @@ -0,0 +1,39 @@ +module Gitlab + module Ci + class Config + module Node + ## + # Factory class responsible for fabricating node entry objects. + # + # It uses Fluent Interface pattern to set all necessary attributes. + # + class Factory + class InvalidFactory < StandardError; end + + def initialize(entry_class) + @entry_class = entry_class + @attributes = {} + end + + def with(attributes) + @attributes.merge!(attributes) + self + end + + def nullify! + @entry_class = Node::Null + self + end + + def create! + raise InvalidFactory unless @attributes.has_key?(:value) + + @entry_class.new(@attributes[:value]).tap do |entry| + entry.description = @attributes[:description] + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/node/global.rb b/lib/gitlab/ci/config/node/global.rb new file mode 100644 index 00000000000..044603423d5 --- /dev/null +++ b/lib/gitlab/ci/config/node/global.rb @@ -0,0 +1,18 @@ +module Gitlab + module Ci + class Config + module Node + ## + # This class represents a global entry - root node for entire + # GitLab CI Configuration file. + # + class Global < Entry + include Configurable + + allow_node :before_script, Script, + description: 'Script that will be executed before each job.' + end + end + end + end +end diff --git a/lib/gitlab/ci/config/node/null.rb b/lib/gitlab/ci/config/node/null.rb new file mode 100644 index 00000000000..4f590f6bec8 --- /dev/null +++ b/lib/gitlab/ci/config/node/null.rb @@ -0,0 +1,27 @@ +module Gitlab + module Ci + class Config + module Node + ## + # This class represents a configuration entry that is not being used + # in configuration file. + # + # This implements Null Object pattern. + # + class Null < Entry + def value + nil + end + + def validate! + nil + end + + def method_missing(*) + nil + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/node/script.rb b/lib/gitlab/ci/config/node/script.rb new file mode 100644 index 00000000000..5072bf0db7d --- /dev/null +++ b/lib/gitlab/ci/config/node/script.rb @@ -0,0 +1,29 @@ +module Gitlab + module Ci + class Config + module Node + ## + # Entry that represents a script. + # + # Each element in the value array is a command that will be executed + # by GitLab Runner. Currently we concatenate these commands with + # new line character as a separator, what is compatible with + # implementation in Runner. + # + class Script < Entry + include ValidationHelpers + + def value + @value.join("\n") + end + + def validate! + unless validate_array_of_strings(@value) + @errors << 'before_script should be an array of strings' + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/node/validation_helpers.rb b/lib/gitlab/ci/config/node/validation_helpers.rb new file mode 100644 index 00000000000..3900fc89391 --- /dev/null +++ b/lib/gitlab/ci/config/node/validation_helpers.rb @@ -0,0 +1,38 @@ +module Gitlab + module Ci + class Config + module Node + module ValidationHelpers + private + + def validate_duration(value) + value.is_a?(String) && ChronicDuration.parse(value) + rescue ChronicDuration::DurationParseError + false + end + + def validate_array_of_strings(values) + values.is_a?(Array) && values.all? { |value| validate_string(value) } + end + + def validate_variables(variables) + variables.is_a?(Hash) && + variables.all? { |key, value| validate_string(key) && validate_string(value) } + end + + def validate_string(value) + value.is_a?(String) || value.is_a?(Symbol) + end + + def validate_environment(value) + value.is_a?(String) && value =~ Gitlab::Regex.environment_name_regex + end + + def validate_boolean(value) + value.in?([true, false]) + end + end + end + end + end +end diff --git a/lib/gitlab/database.rb b/lib/gitlab/database.rb index 04fa6a3a5de..d76ecb54017 100644 --- a/lib/gitlab/database.rb +++ b/lib/gitlab/database.rb @@ -30,6 +30,10 @@ module Gitlab order end + def self.random + Gitlab::Database.postgresql? ? "RANDOM()" : "RAND()" + end + def true_value if Gitlab::Database.postgresql? "'t'" diff --git a/lib/gitlab/gl_id.rb b/lib/gitlab/gl_id.rb new file mode 100644 index 00000000000..624fd00367e --- /dev/null +++ b/lib/gitlab/gl_id.rb @@ -0,0 +1,11 @@ +module Gitlab + module GlId + def self.gl_id(user) + if user.present? + "user-#{user.id}" + else + "" + end + end + end +end diff --git a/lib/gitlab/metrics/instrumentation.rb b/lib/gitlab/metrics/instrumentation.rb index 0f115893a15..d81d26754fe 100644 --- a/lib/gitlab/metrics/instrumentation.rb +++ b/lib/gitlab/metrics/instrumentation.rb @@ -56,7 +56,7 @@ module Gitlab end end - # Instruments all public methods of a module. + # Instruments all public and private methods of a module. # # This method optionally takes a block that can be used to determine if a # method should be instrumented or not. The block is passed the receiving @@ -65,7 +65,8 @@ module Gitlab # # mod - The module to instrument. def self.instrument_methods(mod) - mod.public_methods(false).each do |name| + methods = mod.methods(false) + mod.private_methods(false) + methods.each do |name| method = mod.method(name) if method.owner == mod.singleton_class @@ -76,13 +77,14 @@ module Gitlab end end - # Instruments all public instance methods of a module. + # Instruments all public and private instance methods of a module. # # See `instrument_methods` for more information. # # mod - The module to instrument. def self.instrument_instance_methods(mod) - mod.public_instance_methods(false).each do |name| + methods = mod.instance_methods(false) + mod.private_instance_methods(false) + methods.each do |name| method = mod.instance_method(name) if method.owner == mod @@ -149,13 +151,16 @@ module Gitlab trans = Gitlab::Metrics::Instrumentation.transaction if trans - start = Time.now - retval = super - duration = (Time.now - start) * 1000.0 + start = Time.now + cpu_start = Gitlab::Metrics::System.cpu_time + retval = super + duration = (Time.now - start) * 1000.0 if duration >= Gitlab::Metrics.method_call_threshold + cpu_duration = Gitlab::Metrics::System.cpu_time - cpu_start + trans.add_metric(Gitlab::Metrics::Instrumentation::SERIES, - { duration: duration }, + { duration: duration, cpu_duration: cpu_duration }, method: #{label.inspect}) end diff --git a/lib/gitlab/metrics/rack_middleware.rb b/lib/gitlab/metrics/rack_middleware.rb index 6f179789d3e..3fe27779d03 100644 --- a/lib/gitlab/metrics/rack_middleware.rb +++ b/lib/gitlab/metrics/rack_middleware.rb @@ -1,8 +1,9 @@ module Gitlab module Metrics - # Rack middleware for tracking Rails requests. + # Rack middleware for tracking Rails and Grape requests. class RackMiddleware CONTROLLER_KEY = 'action_controller.instance' + ENDPOINT_KEY = 'api.endpoint' def initialize(app) @app = app @@ -21,6 +22,8 @@ module Gitlab ensure if env[CONTROLLER_KEY] tag_controller(trans, env) + elsif env[ENDPOINT_KEY] + tag_endpoint(trans, env) end trans.finish @@ -42,6 +45,26 @@ module Gitlab controller = env[CONTROLLER_KEY] trans.action = "#{controller.class.name}##{controller.action_name}" end + + def tag_endpoint(trans, env) + endpoint = env[ENDPOINT_KEY] + path = endpoint_paths_cache[endpoint.route.route_method][endpoint.route.route_path] + trans.action = "Grape##{endpoint.route.route_method} #{path}" + end + + private + + def endpoint_paths_cache + @endpoint_paths_cache ||= Hash.new do |hash, http_method| + hash[http_method] = Hash.new do |inner_hash, raw_path| + inner_hash[raw_path] = endpoint_instrumentable_path(raw_path) + end + end + end + + def endpoint_instrumentable_path(raw_path) + raw_path.sub('(.:format)', '').sub('/:version', '') + end end end end diff --git a/lib/gitlab/metrics/sampler.rb b/lib/gitlab/metrics/sampler.rb index fc709222a9b..0000450d9bb 100644 --- a/lib/gitlab/metrics/sampler.rb +++ b/lib/gitlab/metrics/sampler.rb @@ -66,7 +66,11 @@ module Gitlab def sample_objects sample = Allocations.to_hash counts = sample.each_with_object({}) do |(klass, count), hash| - hash[klass.name] = count + name = klass.name + + next unless name + + hash[name] = count end # Symbols aren't allocated so we'll need to add those manually. diff --git a/lib/gitlab/regex.rb b/lib/gitlab/regex.rb index 1cbd6d945a0..c84c68f96f6 100644 --- a/lib/gitlab/regex.rb +++ b/lib/gitlab/regex.rb @@ -100,5 +100,13 @@ module Gitlab def container_registry_reference_regex git_reference_regex end + + def environment_name_regex + @environment_name_regex ||= /\A[a-zA-Z0-9_-]+\z/.freeze + end + + def environment_name_regex_message + "can contain only letters, digits, '-' and '_'." + end end end diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb index 388f84dbe0e..40e8299c36b 100644 --- a/lib/gitlab/workhorse.rb +++ b/lib/gitlab/workhorse.rb @@ -8,7 +8,7 @@ module Gitlab class << self def git_http_ok(repository, user) { - 'GL_ID' => Gitlab::ShellEnv.gl_id(user), + 'GL_ID' => Gitlab::GlId.gl_id(user), 'RepoPath' => repository.path_to_repo, } end diff --git a/spec/controllers/blob_controller_spec.rb b/spec/controllers/blob_controller_spec.rb index eb91e577b87..465013231f9 100644 --- a/spec/controllers/blob_controller_spec.rb +++ b/spec/controllers/blob_controller_spec.rb @@ -38,6 +38,11 @@ describe Projects::BlobController do let(:id) { 'invalid-branch/README.md' } it { is_expected.to respond_with(:not_found) } end + + context "binary file" do + let(:id) { 'binary-encoding/encoding/binary-1.bin' } + it { is_expected.to respond_with(:success) } + end end describe 'GET show with tree path' do diff --git a/spec/controllers/groups/group_members_controller_spec.rb b/spec/controllers/groups/group_members_controller_spec.rb index a5986598715..89c2c26a367 100644 --- a/spec/controllers/groups/group_members_controller_spec.rb +++ b/spec/controllers/groups/group_members_controller_spec.rb @@ -4,17 +4,211 @@ describe Groups::GroupMembersController do let(:user) { create(:user) } let(:group) { create(:group) } - context "index" do + describe '#index' do before do group.add_owner(user) stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PUBLIC]) end it 'renders index with group members' do - get :index, group_id: group.path + get :index, group_id: group expect(response.status).to eq(200) expect(response).to render_template(:index) end end + + describe '#destroy' do + let(:group) { create(:group, :public) } + + context 'when member is not found' do + it 'returns 403' do + delete :destroy, group_id: group, + id: 42 + + expect(response.status).to eq(403) + end + end + + context 'when member is found' do + let(:user) { create(:user) } + let(:group_user) { create(:user) } + let(:member) do + group.add_developer(group_user) + group.members.find_by(user_id: group_user) + end + + context 'when user does not have enough rights' do + before do + group.add_developer(user) + sign_in(user) + end + + it 'returns 403' do + delete :destroy, group_id: group, + id: member + + expect(response.status).to eq(403) + expect(group.users).to include group_user + end + end + + context 'when user has enough rights' do + before do + group.add_owner(user) + sign_in(user) + end + + it '[HTML] removes user from members' do + delete :destroy, group_id: group, + id: member + + expect(response).to set_flash.to 'User was successfully removed from group.' + expect(response).to redirect_to(group_group_members_path(group)) + expect(group.users).not_to include group_user + end + + it '[JS] removes user from members' do + xhr :delete, :destroy, group_id: group, + id: member + + expect(response).to be_success + expect(group.users).not_to include group_user + end + end + end + end + + describe '#leave' do + let(:group) { create(:group, :public) } + let(:user) { create(:user) } + + context 'when member is not found' do + before { sign_in(user) } + + it 'returns 403' do + delete :leave, group_id: group + + expect(response.status).to eq(403) + end + end + + context 'when member is found' do + context 'and is not an owner' do + before do + group.add_developer(user) + sign_in(user) + end + + it 'removes user from members' do + delete :leave, group_id: group + + expect(response).to set_flash.to "You left the \"#{group.name}\" group." + expect(response).to redirect_to(dashboard_groups_path) + expect(group.users).not_to include user + end + end + + context 'and is an owner' do + before do + group.add_owner(user) + sign_in(user) + end + + it 'cannot removes himself from the group' do + delete :leave, group_id: group + + expect(response).to redirect_to(group_path(group)) + expect(response).to set_flash[:alert].to "You can not leave the \"#{group.name}\" group. Transfer or delete the group." + expect(group.users).to include user + end + end + + context 'and is a requester' do + before do + group.request_access(user) + sign_in(user) + end + + it 'removes user from members' do + delete :leave, group_id: group + + expect(response).to set_flash.to 'Your access request to the group has been withdrawn.' + expect(response).to redirect_to(dashboard_groups_path) + expect(group.members.request).to be_empty + expect(group.users).not_to include user + end + end + end + end + + describe '#request_access' do + let(:group) { create(:group, :public) } + let(:user) { create(:user) } + + before do + sign_in(user) + end + + it 'creates a new GroupMember that is not a team member' do + post :request_access, group_id: group + + expect(response).to set_flash.to 'Your request for access has been queued for review.' + expect(response).to redirect_to(group_path(group)) + expect(group.members.request.exists?(user_id: user)).to be_truthy + expect(group.users).not_to include user + end + end + + describe '#approve_access_request' do + let(:group) { create(:group, :public) } + + context 'when member is not found' do + it 'returns 403' do + post :approve_access_request, group_id: group, + id: 42 + + expect(response.status).to eq(403) + end + end + + context 'when member is found' do + let(:user) { create(:user) } + let(:group_requester) { create(:user) } + let(:member) do + group.request_access(group_requester) + group.members.request.find_by(user_id: group_requester) + end + + context 'when user does not have enough rights' do + before do + group.add_developer(user) + sign_in(user) + end + + it 'returns 403' do + post :approve_access_request, group_id: group, + id: member + + expect(response.status).to eq(403) + expect(group.users).not_to include group_requester + end + end + + context 'when user has enough rights' do + before do + group.add_owner(user) + sign_in(user) + end + + it 'adds user to members' do + post :approve_access_request, group_id: group, + id: member + + expect(response).to redirect_to(group_group_members_path(group)) + expect(group.users).to include group_requester + end + end + end + end end diff --git a/spec/controllers/projects/commit_controller_spec.rb b/spec/controllers/projects/commit_controller_spec.rb index 438e776ec4b..6e3db10e451 100644 --- a/spec/controllers/projects/commit_controller_spec.rb +++ b/spec/controllers/projects/commit_controller_spec.rb @@ -2,6 +2,8 @@ require 'rails_helper' describe Projects::CommitController do describe 'GET show' do + render_views + let(:project) { create(:project) } before do @@ -27,6 +29,16 @@ describe Projects::CommitController do end end + it 'handles binary files' do + get(:show, + namespace_id: project.namespace.to_param, + project_id: project.to_param, + id: TestEnv::BRANCH_SHA['binary-encoding'], + format: "html") + + expect(response).to be_success + end + def go(id:) get :show, namespace_id: project.namespace.to_param, diff --git a/spec/controllers/projects/project_members_controller_spec.rb b/spec/controllers/projects/project_members_controller_spec.rb index 750fbecdd07..fc5f458e795 100644 --- a/spec/controllers/projects/project_members_controller_spec.rb +++ b/spec/controllers/projects/project_members_controller_spec.rb @@ -1,22 +1,22 @@ require('spec_helper') describe Projects::ProjectMembersController do - let(:project) { create(:project) } - let(:another_project) { create(:project, :private) } - let(:user) { create(:user) } - let(:member) { create(:user) } - - before do - project.team << [user, :master] - another_project.team << [member, :guest] - sign_in(user) - end - describe '#apply_import' do + let(:project) { create(:project) } + let(:another_project) { create(:project, :private) } + let(:user) { create(:user) } + let(:member) { create(:user) } + + before do + project.team << [user, :master] + another_project.team << [member, :guest] + sign_in(user) + end + shared_context 'import applied' do before do - post(:apply_import, namespace_id: project.namespace.to_param, - project_id: project.to_param, + post(:apply_import, namespace_id: project.namespace, + project_id: project, source_project_id: another_project.id) end end @@ -48,18 +48,231 @@ describe Projects::ProjectMembersController do end describe '#index' do - let(:project) { create(:project, :private) } - context 'when user is member' do - let(:member) { create(:user) } - before do + project = create(:project, :private) + member = create(:user) project.team << [member, :guest] sign_in(member) - get :index, namespace_id: project.namespace.to_param, project_id: project.to_param + + get :index, namespace_id: project.namespace, project_id: project end it { expect(response.status).to eq(200) } end end + + describe '#destroy' do + let(:project) { create(:project, :public) } + + context 'when member is not found' do + it 'returns 404' do + delete :destroy, namespace_id: project.namespace, + project_id: project, + id: 42 + + expect(response.status).to eq(404) + end + end + + context 'when member is found' do + let(:user) { create(:user) } + let(:team_user) { create(:user) } + let(:member) do + project.team << [team_user, :developer] + project.members.find_by(user_id: team_user.id) + end + + context 'when user does not have enough rights' do + before do + project.team << [user, :developer] + sign_in(user) + end + + it 'returns 404' do + delete :destroy, namespace_id: project.namespace, + project_id: project, + id: member + + expect(response.status).to eq(404) + expect(project.users).to include team_user + end + end + + context 'when user has enough rights' do + before do + project.team << [user, :master] + sign_in(user) + end + + it '[HTML] removes user from members' do + delete :destroy, namespace_id: project.namespace, + project_id: project, + id: member + + expect(response).to redirect_to( + namespace_project_project_members_path(project.namespace, project) + ) + expect(project.users).not_to include team_user + end + + it '[JS] removes user from members' do + xhr :delete, :destroy, namespace_id: project.namespace, + project_id: project, + id: member + + expect(response).to be_success + expect(project.users).not_to include team_user + end + end + end + end + + describe '#leave' do + let(:project) { create(:project, :public) } + let(:user) { create(:user) } + + context 'when member is not found' do + before { sign_in(user) } + + it 'returns 403' do + delete :leave, namespace_id: project.namespace, + project_id: project + + expect(response.status).to eq(403) + end + end + + context 'when member is found' do + context 'and is not an owner' do + before do + project.team << [user, :developer] + sign_in(user) + end + + it 'removes user from members' do + delete :leave, namespace_id: project.namespace, + project_id: project + + expect(response).to set_flash.to "You left the \"#{project.human_name}\" project." + expect(response).to redirect_to(dashboard_projects_path) + expect(project.users).not_to include user + end + end + + context 'and is an owner' do + before do + project.update(namespace_id: user.namespace_id) + project.team << [user, :master, user] + sign_in(user) + end + + it 'cannot remove himself from the project' do + delete :leave, namespace_id: project.namespace, + project_id: project + + expect(response).to redirect_to( + namespace_project_path(project.namespace, project) + ) + expect(response).to set_flash[:alert].to "You can not leave the \"#{project.human_name}\" project. Transfer or delete the project." + expect(project.users).to include user + end + end + + context 'and is a requester' do + before do + project.request_access(user) + sign_in(user) + end + + it 'removes user from members' do + delete :leave, namespace_id: project.namespace, + project_id: project + + expect(response).to set_flash.to 'Your access request to the project has been withdrawn.' + expect(response).to redirect_to(dashboard_projects_path) + expect(project.members.request).to be_empty + expect(project.users).not_to include user + end + end + end + end + + describe '#request_access' do + let(:project) { create(:project, :public) } + let(:user) { create(:user) } + + before do + sign_in(user) + end + + it 'creates a new ProjectMember that is not a team member' do + post :request_access, namespace_id: project.namespace, + project_id: project + + expect(response).to set_flash.to 'Your request for access has been queued for review.' + expect(response).to redirect_to( + namespace_project_path(project.namespace, project) + ) + expect(project.members.request.exists?(user_id: user)).to be_truthy + expect(project.users).not_to include user + end + end + + describe '#approve' do + let(:project) { create(:project, :public) } + + context 'when member is not found' do + it 'returns 404' do + post :approve_access_request, namespace_id: project.namespace, + project_id: project, + id: 42 + + expect(response.status).to eq(404) + end + end + + context 'when member is found' do + let(:user) { create(:user) } + let(:team_requester) { create(:user) } + let(:member) do + project.request_access(team_requester) + project.members.request.find_by(user_id: team_requester.id) + end + + context 'when user does not have enough rights' do + before do + project.team << [user, :developer] + sign_in(user) + end + + it 'returns 404' do + post :approve_access_request, namespace_id: project.namespace, + project_id: project, + id: member + + expect(response.status).to eq(404) + expect(project.users).not_to include team_requester + end + end + + context 'when user has enough rights' do + before do + project.team << [user, :master] + sign_in(user) + end + + it 'adds user to members' do + post :approve_access_request, namespace_id: project.namespace, + project_id: project, + id: member + + expect(response).to redirect_to( + namespace_project_project_members_path(project.namespace, project) + ) + expect(project.users).to include team_requester + end + end + end + end end diff --git a/spec/factories/deployments.rb b/spec/factories/deployments.rb new file mode 100644 index 00000000000..82591604fcb --- /dev/null +++ b/spec/factories/deployments.rb @@ -0,0 +1,13 @@ +FactoryGirl.define do + factory :deployment, class: Deployment do + sha '97de212e80737a608d939f648d959671fb0a0142' + ref 'master' + tag false + + environment factory: :environment + + after(:build) do |deployment, evaluator| + deployment.project = deployment.environment.project + end + end +end diff --git a/spec/factories/environments.rb b/spec/factories/environments.rb new file mode 100644 index 00000000000..07265c26ca3 --- /dev/null +++ b/spec/factories/environments.rb @@ -0,0 +1,7 @@ +FactoryGirl.define do + factory :environment, class: Environment do + sequence(:name) { |n| "environment#{n}" } + + project factory: :empty_project + end +end diff --git a/spec/features/admin/admin_hooks_spec.rb b/spec/features/admin/admin_hooks_spec.rb index 7265cdac7a7..31633817d53 100644 --- a/spec/features/admin/admin_hooks_spec.rb +++ b/spec/features/admin/admin_hooks_spec.rb @@ -12,9 +12,11 @@ describe "Admin::Hooks", feature: true do describe "GET /admin/hooks" do it "should be ok" do visit admin_root_path - page.within ".sidebar-wrapper" do + + page.within ".layout-nav" do click_on "Hooks" end + expect(current_path).to eq(admin_hooks_path) end diff --git a/spec/features/builds_spec.rb b/spec/features/builds_spec.rb index b8ecc356b4d..16832c297ac 100644 --- a/spec/features/builds_spec.rb +++ b/spec/features/builds_spec.rb @@ -97,6 +97,42 @@ describe "Builds" do end end + context 'Artifacts expire date' do + before do + @build.update_attributes(artifacts_file: artifacts_file, artifacts_expire_at: expire_at) + visit namespace_project_build_path(@project.namespace, @project, @build) + end + + context 'no expire date defined' do + let(:expire_at) { nil } + + it 'does not have the Keep button' do + expect(page).not_to have_content 'Keep' + end + end + + context 'when expire date is defined' do + let(:expire_at) { Time.now + 7.days } + + it 'keeps artifacts when Keep button is clicked' do + expect(page).to have_content 'The artifacts will be removed' + click_link 'Keep' + + expect(page).not_to have_link 'Keep' + expect(page).not_to have_content 'The artifacts will be removed' + end + end + + context 'when artifacts expired' do + let(:expire_at) { Time.now - 7.days } + + it 'does not have the Keep button' do + expect(page).to have_content 'The artifacts were removed' + expect(page).not_to have_link 'Keep' + end + end + end + context 'Build raw trace' do before do @build.run! diff --git a/spec/features/environments_spec.rb b/spec/features/environments_spec.rb new file mode 100644 index 00000000000..40fea5211e9 --- /dev/null +++ b/spec/features/environments_spec.rb @@ -0,0 +1,160 @@ +require 'spec_helper' + +feature 'Environments', feature: true do + given(:project) { create(:empty_project) } + given(:user) { create(:user) } + given(:role) { :developer } + + background do + login_as(user) + project.team << [user, role] + end + + describe 'when showing environments' do + given!(:environment) { } + given!(:deployment) { } + + before do + visit namespace_project_environments_path(project.namespace, project) + end + + context 'without environments' do + scenario 'does show no environments' do + expect(page).to have_content('No environments to show') + end + end + + context 'with environments' do + given(:environment) { create(:environment, project: project) } + + scenario 'does show environment name' do + expect(page).to have_link(environment.name) + end + + context 'without deployments' do + scenario 'does show no deployments' do + expect(page).to have_content('No deployments yet') + end + end + + context 'with deployments' do + given(:deployment) { create(:deployment, environment: environment) } + + scenario 'does show deployment SHA' do + expect(page).to have_link(deployment.short_sha) + end + end + end + + scenario 'does have a New environment button' do + expect(page).to have_link('New environment') + end + end + + describe 'when showing the environment' do + given(:environment) { create(:environment, project: project) } + given!(:deployment) { } + + before do + visit namespace_project_environment_path(project.namespace, project, environment) + end + + context 'without deployments' do + scenario 'does show no deployments' do + expect(page).to have_content('No deployments for') + end + end + + context 'with deployments' do + given(:deployment) { create(:deployment, environment: environment) } + + scenario 'does show deployment SHA' do + expect(page).to have_link(deployment.short_sha) + end + + scenario 'does not show a retry button for deployment without build' do + expect(page).not_to have_link('Retry') + end + + context 'with build' do + given(:build) { create(:ci_build, project: project) } + given(:deployment) { create(:deployment, environment: environment, deployable: build) } + + scenario 'does show build name' do + expect(page).to have_link("#{build.name} (##{build.id})") + end + + scenario 'does show retry button' do + expect(page).to have_link('Retry') + end + end + end + end + + describe 'when creating a new environment' do + before do + visit namespace_project_environments_path(project.namespace, project) + end + + context 'when logged as developer' do + before do + click_link 'New environment' + end + + context 'for valid name' do + before do + fill_in('Name', with: 'production') + click_on 'Create environment' + end + + scenario 'does create a new pipeline' do + expect(page).to have_content('production') + end + end + + context 'for invalid name' do + before do + fill_in('Name', with: 'name with spaces') + click_on 'Create environment' + end + + scenario 'does show errors' do + expect(page).to have_content('Name can contain only letters') + end + end + end + + context 'when logged as reporter' do + given(:role) { :reporter } + + scenario 'does not have a New environment link' do + expect(page).not_to have_link('New environment') + end + end + end + + describe 'when deleting existing environment' do + given(:environment) { create(:environment, project: project) } + + before do + visit namespace_project_environment_path(project.namespace, project, environment) + end + + context 'when logged as master' do + given(:role) { :master } + + scenario 'does delete environment' do + click_link 'Destroy' + expect(page).not_to have_link(environment.name) + end + end + + context 'when logged as developer' do + given(:role) { :developer } + + scenario 'does not have a Destroy link' do + expect(page).not_to have_link('Destroy') + end + end + end +end diff --git a/spec/features/groups/members/owner_manages_access_requests_spec.rb b/spec/features/groups/members/owner_manages_access_requests_spec.rb new file mode 100644 index 00000000000..22525ce530b --- /dev/null +++ b/spec/features/groups/members/owner_manages_access_requests_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +feature 'Groups > Members > Owner manages access requests', feature: true do + let(:user) { create(:user) } + let(:owner) { create(:user) } + let(:group) { create(:group, :public) } + + background do + group.request_access(user) + group.add_owner(owner) + login_as(owner) + end + + scenario 'owner can see access requests' do + visit group_group_members_path(group) + + expect_visible_access_request(group, user) + end + + scenario 'master can grant access' do + visit group_group_members_path(group) + + expect_visible_access_request(group, user) + + perform_enqueued_jobs { click_on 'Grant access' } + + expect(ActionMailer::Base.deliveries.last.to).to eq [user.notification_email] + expect(ActionMailer::Base.deliveries.last.subject).to match "Access to the #{group.name} group was granted" + end + + scenario 'master can deny access' do + visit group_group_members_path(group) + + expect_visible_access_request(group, user) + + perform_enqueued_jobs { click_on 'Deny access' } + + expect(ActionMailer::Base.deliveries.last.to).to eq [user.notification_email] + expect(ActionMailer::Base.deliveries.last.subject).to match "Access to the #{group.name} group was denied" + end + + + def expect_visible_access_request(group, user) + expect(group.members.request.exists?(user_id: user)).to be_truthy + expect(page).to have_content "#{group.name} access requests (1)" + expect(page).to have_content user.name + end +end diff --git a/spec/features/groups/members/user_requests_access_spec.rb b/spec/features/groups/members/user_requests_access_spec.rb new file mode 100644 index 00000000000..a878a96b6ee --- /dev/null +++ b/spec/features/groups/members/user_requests_access_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +feature 'Groups > Members > User requests access', feature: true do + let(:user) { create(:user) } + let(:owner) { create(:user) } + let(:group) { create(:group, :public) } + + background do + group.add_owner(owner) + login_as(user) + visit group_path(group) + end + + scenario 'user can request access to a group' do + perform_enqueued_jobs { click_link 'Request Access' } + + expect(ActionMailer::Base.deliveries.last.to).to eq [owner.notification_email] + expect(ActionMailer::Base.deliveries.last.subject).to match "Request to join the #{group.name} group" + + expect(group.members.request.exists?(user_id: user)).to be_truthy + expect(page).to have_content 'Your request for access has been queued for review.' + + expect(page).to have_content 'Withdraw Access Request' + end + + scenario 'user is not listed in the group members page' do + click_link 'Request Access' + + expect(group.members.request.exists?(user_id: user)).to be_truthy + + click_link 'Members' + + page.within('.content') do + expect(page).not_to have_content(user.name) + end + end + + scenario 'user can withdraw its request for access' do + click_link 'Request Access' + + expect(group.members.request.exists?(user_id: user)).to be_truthy + + click_link 'Withdraw Access Request' + + expect(group.members.request.exists?(user_id: user)).to be_falsey + expect(page).to have_content 'Your access request to the group has been withdrawn.' + end +end diff --git a/spec/features/issues/filter_by_labels_spec.rb b/spec/features/issues/filter_by_labels_spec.rb index 16c619c9288..5ea02b8d39c 100644 --- a/spec/features/issues/filter_by_labels_spec.rb +++ b/spec/features/issues/filter_by_labels_spec.rb @@ -56,8 +56,9 @@ feature 'Issue filtering by Labels', feature: true do end it 'should remove label "bug"' do - first('.js-label-filter-remove').click - expect(find('.filtered-labels')).to have_no_content "bug" + find('.js-label-filter-remove').click + wait_for_ajax + expect(find('.filtered-labels', visible: false)).to have_no_content "bug" end end @@ -142,7 +143,8 @@ feature 'Issue filtering by Labels', feature: true do end it 'should remove label "enhancement"' do - first('.js-label-filter-remove').click + find('.js-label-filter-remove', match: :first).click + wait_for_ajax expect(find('.filtered-labels')).to have_no_content "enhancement" end end @@ -179,6 +181,7 @@ feature 'Issue filtering by Labels', feature: true do before do page.within '.labels-filter' do click_button 'Label' + wait_for_ajax click_link 'bug' find('.dropdown-menu-close').click end @@ -189,14 +192,11 @@ feature 'Issue filtering by Labels', feature: true do end it 'should allow user to remove filtered labels' do - page.within '.filtered-labels' do - first('.js-label-filter-remove').click - expect(page).not_to have_content 'bug' - end + first('.js-label-filter-remove').click + wait_for_ajax - page.within '.labels-filter' do - expect(page).not_to have_content 'bug' - end + expect(find('.filtered-labels', visible: false)).not_to have_content 'bug' + expect(find('.labels-filter')).not_to have_content 'bug' end end diff --git a/spec/features/issues/filter_issues_spec.rb b/spec/features/issues/filter_issues_spec.rb index 1f0594e6b02..4bcb105b17d 100644 --- a/spec/features/issues/filter_issues_spec.rb +++ b/spec/features/issues/filter_issues_spec.rb @@ -1,6 +1,7 @@ require 'rails_helper' describe 'Filter issues', feature: true do + include WaitForAjax let!(:project) { create(:project) } let!(:user) { create(:user)} @@ -21,7 +22,7 @@ describe 'Filter issues', feature: true do find('.dropdown-menu-user-link', text: user.username).click - sleep 2 + wait_for_ajax end context 'assignee', js: true do @@ -53,7 +54,7 @@ describe 'Filter issues', feature: true do find('.milestone-filter .dropdown-content a', text: milestone.title).click - sleep 2 + wait_for_ajax end context 'milestone', js: true do @@ -80,23 +81,21 @@ describe 'Filter issues', feature: true do before do visit namespace_project_issues_path(project.namespace, project) find('.js-label-select').click + wait_for_ajax end it 'should filter by any label' do find('.dropdown-menu-labels a', text: 'Any Label').click page.first('.labels-filter .dropdown-title .dropdown-menu-close-icon').click - sleep 2 + wait_for_ajax - page.within '.labels-filter' do - expect(page).to have_content 'Any Label' - end - expect(find('.js-label-select .dropdown-toggle-text')).to have_content('Any Label') + expect(find('.labels-filter')).to have_content 'Label' end it 'should filter by no label' do find('.dropdown-menu-labels a', text: 'No Label').click page.first('.labels-filter .dropdown-title .dropdown-menu-close-icon').click - sleep 2 + wait_for_ajax page.within '.labels-filter' do expect(page).to have_content 'No Label' @@ -122,14 +121,14 @@ describe 'Filter issues', feature: true do find('.dropdown-menu-user-link', text: user.username).click - sleep 2 + wait_for_ajax find('.js-label-select').click find('.dropdown-menu-labels .dropdown-content a', text: label.title).click page.first('.labels-filter .dropdown-title .dropdown-menu-close-icon').click - sleep 2 + wait_for_ajax end context 'assignee and label', js: true do @@ -276,9 +275,12 @@ describe 'Filter issues', feature: true do it 'should be able to filter and sort issues' do click_button 'Label' + wait_for_ajax page.within '.labels-filter' do click_link 'bug' end + find('.dropdown-menu-close-icon').click + wait_for_ajax page.within '.issues-list' do expect(page).to have_selector('.issue', count: 2) @@ -288,6 +290,7 @@ describe 'Filter issues', feature: true do page.within '.dropdown-menu-sort' do click_link 'Oldest created' end + wait_for_ajax page.within '.issues-list' do expect(first('.issue')).to have_content('Frontend') diff --git a/spec/features/issues/move_spec.rb b/spec/features/issues/move_spec.rb index c7019c5aea1..7773c486b4e 100644 --- a/spec/features/issues/move_spec.rb +++ b/spec/features/issues/move_spec.rb @@ -26,6 +26,7 @@ feature 'issue move to another project' do context 'user has permission to move issue' do let!(:mr) { create(:merge_request, source_project: old_project) } let(:new_project) { create(:project) } + let(:new_project_search) { create(:project) } let(:text) { 'Text with !1' } let(:cross_reference) { old_project.to_reference } @@ -47,6 +48,21 @@ feature 'issue move to another project' do expect(page).to have_content(issue.title) end + scenario 'searching project dropdown', js: true do + new_project_search.team << [user, :reporter] + + page.within '.js-move-dropdown' do + first('.select2-choice').click + end + + fill_in('s2id_autogen2_search', with: new_project_search.name) + + page.within '.select2-drop' do + expect(page).to have_content(new_project_search.name) + expect(page).not_to have_content(new_project.name) + end + end + context 'user does not have permission to move the issue to a project', js: true do let!(:private_project) { create(:project, :private) } let(:another_project) { create(:project) } diff --git a/spec/features/issues/todo_spec.rb b/spec/features/issues/todo_spec.rb new file mode 100644 index 00000000000..b69cce3e7d7 --- /dev/null +++ b/spec/features/issues/todo_spec.rb @@ -0,0 +1,33 @@ +require 'rails_helper' + +feature 'Manually create a todo item from issue', feature: true, js: true do + let!(:project) { create(:project) } + let!(:issue) { create(:issue, project: project) } + let!(:user) { create(:user)} + + before do + project.team << [user, :master] + login_as(user) + visit namespace_project_issue_path(project.namespace, project, issue) + end + + it 'should create todo when clicking button' do + page.within '.issuable-sidebar' do + click_button 'Add Todo' + expect(page).to have_content 'Mark Done' + end + + page.within '.header-content .todos-pending-count' do + expect(page).to have_content '1' + end + end + + it 'should mark a todo as done' do + page.within '.issuable-sidebar' do + click_button 'Add Todo' + click_button 'Mark Done' + end + + expect(page).to have_selector('.todos-pending-count', visible: false) + end +end diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb index f6fb6a72d22..65fe918e2e8 100644 --- a/spec/features/issues_spec.rb +++ b/spec/features/issues_spec.rb @@ -396,6 +396,27 @@ describe 'Issues', feature: true do expect(page).to have_content @user.name end end + + it 'allows user to unselect themselves', js: true do + issue2 = create(:issue, project: project, author: @user) + visit namespace_project_issue_path(project.namespace, project, issue2) + + page.within '.assignee' do + click_link 'Edit' + click_link @user.name + + page.within '.value' do + expect(page).to have_content @user.name + end + + click_link 'Edit' + click_link @user.name + + page.within '.value' do + expect(page).to have_content "No assignee" + end + end + end end context 'by unauthorized user' do @@ -440,6 +461,26 @@ describe 'Issues', feature: true do expect(issue.reload.milestone).to be_nil end + + it 'allows user to de-select milestone', js: true do + visit namespace_project_issue_path(project.namespace, project, issue) + + page.within('.milestone') do + click_link 'Edit' + click_link milestone.title + + page.within '.value' do + expect(page).to have_content milestone.title + end + + click_link 'Edit' + click_link milestone.title + + page.within '.value' do + expect(page).to have_content 'None' + end + end + end end context 'by unauthorized user' do diff --git a/spec/features/projects/members/master_manages_access_requests_spec.rb b/spec/features/projects/members/master_manages_access_requests_spec.rb new file mode 100644 index 00000000000..5fe4caa12f0 --- /dev/null +++ b/spec/features/projects/members/master_manages_access_requests_spec.rb @@ -0,0 +1,47 @@ +require 'spec_helper' + +feature 'Projects > Members > Master manages access requests', feature: true do + let(:user) { create(:user) } + let(:master) { create(:user) } + let(:project) { create(:project, :public) } + + background do + project.request_access(user) + project.team << [master, :master] + login_as(master) + end + + scenario 'master can see access requests' do + visit namespace_project_project_members_path(project.namespace, project) + + expect_visible_access_request(project, user) + end + + scenario 'master can grant access' do + visit namespace_project_project_members_path(project.namespace, project) + + expect_visible_access_request(project, user) + + perform_enqueued_jobs { click_on 'Grant access' } + + expect(ActionMailer::Base.deliveries.last.to).to eq [user.notification_email] + expect(ActionMailer::Base.deliveries.last.subject).to match "Access to the #{project.name_with_namespace} project was granted" + end + + scenario 'master can deny access' do + visit namespace_project_project_members_path(project.namespace, project) + + expect_visible_access_request(project, user) + + perform_enqueued_jobs { click_on 'Deny access' } + + expect(ActionMailer::Base.deliveries.last.to).to eq [user.notification_email] + expect(ActionMailer::Base.deliveries.last.subject).to match "Access to the #{project.name_with_namespace} project was denied" + end + + def expect_visible_access_request(project, user) + expect(project.members.request.exists?(user_id: user)).to be_truthy + expect(page).to have_content "#{project.name} access requests (1)" + expect(page).to have_content user.name + end +end diff --git a/spec/features/projects/members/user_requests_access_spec.rb b/spec/features/projects/members/user_requests_access_spec.rb new file mode 100644 index 00000000000..fd92a3a2f0c --- /dev/null +++ b/spec/features/projects/members/user_requests_access_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +feature 'Projects > Members > User requests access', feature: true do + let(:user) { create(:user) } + let(:master) { create(:user) } + let(:project) { create(:project, :public) } + + background do + project.team << [master, :master] + login_as(user) + visit namespace_project_path(project.namespace, project) + end + + scenario 'user can request access to a project' do + perform_enqueued_jobs { click_link 'Request Access' } + + expect(ActionMailer::Base.deliveries.last.to).to eq [master.notification_email] + expect(ActionMailer::Base.deliveries.last.subject).to eq "Request to join the #{project.name_with_namespace} project" + + expect(project.members.request.exists?(user_id: user)).to be_truthy + expect(page).to have_content 'Your request for access has been queued for review.' + + expect(page).to have_content 'Withdraw Access Request' + end + + scenario 'user is not listed in the project members page' do + click_link 'Request Access' + + expect(project.members.request.exists?(user_id: user)).to be_truthy + + open_project_settings_menu + click_link 'Members' + + visit namespace_project_project_members_path(project.namespace, project) + page.within('.content') do + expect(page).not_to have_content(user.name) + end + end + + scenario 'user can withdraw its request for access' do + click_link 'Request Access' + + expect(project.members.request.exists?(user_id: user)).to be_truthy + + click_link 'Withdraw Access Request' + + expect(project.members.request.exists?(user_id: user)).to be_falsey + expect(page).to have_content 'Your access request to the project has been withdrawn.' + end + + def open_project_settings_menu + find('#project-settings-button').click + end +end diff --git a/spec/features/security/project/public_access_spec.rb b/spec/features/security/project/public_access_spec.rb index c5f741709ad..f6c6687e162 100644 --- a/spec/features/security/project/public_access_spec.rb +++ b/spec/features/security/project/public_access_spec.rb @@ -175,6 +175,49 @@ describe "Public Project Access", feature: true do end end + describe "GET /:project_path/environments" do + subject { namespace_project_environments_path(project.namespace, project) } + + it { is_expected.to be_allowed_for :admin } + it { is_expected.to be_allowed_for owner } + it { is_expected.to be_allowed_for master } + it { is_expected.to be_allowed_for developer } + it { is_expected.to be_allowed_for reporter } + it { is_expected.to be_denied_for guest } + it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_denied_for :visitor } + end + + describe "GET /:project_path/environments/:id" do + let(:environment) { create(:environment, project: project) } + subject { namespace_project_environments_path(project.namespace, project, environment) } + + it { is_expected.to be_allowed_for :admin } + it { is_expected.to be_allowed_for owner } + it { is_expected.to be_allowed_for master } + it { is_expected.to be_allowed_for developer } + it { is_expected.to be_allowed_for reporter } + it { is_expected.to be_denied_for guest } + it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_denied_for :visitor } + end + + describe "GET /:project_path/environments/new" do + subject { new_namespace_project_environment_path(project.namespace, project) } + + it { is_expected.to be_allowed_for :admin } + it { is_expected.to be_allowed_for owner } + it { is_expected.to be_allowed_for master } + it { is_expected.to be_allowed_for developer } + it { is_expected.to be_denied_for reporter } + it { is_expected.to be_denied_for guest } + it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_denied_for :visitor } + end + describe "GET /:project_path/blob" do let(:commit) { project.repository.commit } diff --git a/spec/features/u2f_spec.rb b/spec/features/u2f_spec.rb index 366a90228b1..14613754f74 100644 --- a/spec/features/u2f_spec.rb +++ b/spec/features/u2f_spec.rb @@ -12,39 +12,24 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: describe "registration" do let(:user) { create(:user) } - before { login_as(user) } - describe 'when 2FA via OTP is disabled' do - it 'allows registering a new device' do - visit profile_account_path - click_on 'Enable Two-Factor Authentication' - - register_u2f_device + before do + login_as(user) + user.update_attribute(:otp_required_for_login, true) + end - expect(page.body).to match('Your U2F device was registered') - end + describe 'when 2FA via OTP is disabled' do + before { user.update_attribute(:otp_required_for_login, false) } - it 'allows registering more than one device' do + it 'does not allow registering a new device' do visit profile_account_path - - # First device click_on 'Enable Two-Factor Authentication' - register_u2f_device - expect(page.body).to match('Your U2F device was registered') - - # Second device - click_on 'Manage Two-Factor Authentication' - register_u2f_device - expect(page.body).to match('Your U2F device was registered') - click_on 'Manage Two-Factor Authentication' - expect(page.body).to match('You have 2 U2F devices registered') + expect(page).to have_button('Setup New U2F Device', disabled: true) end end describe 'when 2FA via OTP is enabled' do - before { user.update_attributes(otp_required_for_login: true) } - it 'allows registering a new device' do visit profile_account_path click_on 'Manage Two-Factor Authentication' @@ -67,7 +52,6 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: click_on 'Manage Two-Factor Authentication' register_u2f_device expect(page.body).to match('Your U2F device was registered') - click_on 'Manage Two-Factor Authentication' expect(page.body).to match('You have 2 U2F devices registered') end @@ -76,15 +60,16 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: it 'allows the same device to be registered for multiple users' do # First user visit profile_account_path - click_on 'Enable Two-Factor Authentication' + click_on 'Manage Two-Factor Authentication' u2f_device = register_u2f_device expect(page.body).to match('Your U2F device was registered') logout # Second user - login_as(:user) + user = login_as(:user) + user.update_attribute(:otp_required_for_login, true) visit profile_account_path - click_on 'Enable Two-Factor Authentication' + click_on 'Manage Two-Factor Authentication' register_u2f_device(u2f_device) expect(page.body).to match('Your U2F device was registered') @@ -94,7 +79,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: context "when there are form errors" do it "doesn't register the device if there are errors" do visit profile_account_path - click_on 'Enable Two-Factor Authentication' + click_on 'Manage Two-Factor Authentication' # Have the "u2f device" respond with bad data page.execute_script("u2f.register = function(_,_,_,callback) { callback('bad response'); };") @@ -109,7 +94,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: it "allows retrying registration" do visit profile_account_path - click_on 'Enable Two-Factor Authentication' + click_on 'Manage Two-Factor Authentication' # Failed registration page.execute_script("u2f.register = function(_,_,_,callback) { callback('bad response'); };") @@ -133,8 +118,9 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: before do # Register and logout login_as(user) + user.update_attribute(:otp_required_for_login, true) visit profile_account_path - click_on 'Enable Two-Factor Authentication' + click_on 'Manage Two-Factor Authentication' @u2f_device = register_u2f_device logout end @@ -154,7 +140,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: describe "when 2FA via OTP is enabled" do it "allows logging in with the U2F device" do - user.update_attributes(otp_required_for_login: true) + user.update_attribute(:otp_required_for_login, true) login_with(user) @u2f_device.respond_to_u2f_authentication @@ -171,8 +157,9 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: it "does not allow logging in with that particular device" do # Register current user with the different U2F device current_user = login_as(:user) + current_user.update_attribute(:otp_required_for_login, true) visit profile_account_path - click_on 'Enable Two-Factor Authentication' + click_on 'Manage Two-Factor Authentication' register_u2f_device logout @@ -191,8 +178,9 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: it "allows logging in with that particular device" do # Register current user with the same U2F device current_user = login_as(:user) + current_user.update_attribute(:otp_required_for_login, true) visit profile_account_path - click_on 'Enable Two-Factor Authentication' + click_on 'Manage Two-Factor Authentication' register_u2f_device(@u2f_device) logout @@ -227,8 +215,9 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: before do login_as(user) + user.update_attribute(:otp_required_for_login, true) visit profile_account_path - click_on 'Enable Two-Factor Authentication' + click_on 'Manage Two-Factor Authentication' register_u2f_device end diff --git a/spec/finders/notes_finder_spec.rb b/spec/finders/notes_finder_spec.rb index c83824b900d..639b28d49ee 100644 --- a/spec/finders/notes_finder_spec.rb +++ b/spec/finders/notes_finder_spec.rb @@ -34,5 +34,21 @@ describe NotesFinder do notes = NotesFinder.new.execute(project, user, params) expect(notes).to eq([note1]) end + + context 'confidential issue notes' do + let(:confidential_issue) { create(:issue, :confidential, project: project, author: user) } + let!(:confidential_note) { create(:note, noteable: confidential_issue, project: confidential_issue.project) } + + let(:params) { { target_id: confidential_issue.id, target_type: 'issue', last_fetched_at: 1.hour.ago.to_i } } + + it 'returns notes if user can see the issue' do + expect(NotesFinder.new.execute(project, user, params)).to eq([confidential_note]) + end + + it 'raises an error if user can not see the issue' do + user = create(:user) + expect { NotesFinder.new.execute(project, user, params) }.to raise_error(ActiveRecord::RecordNotFound) + end + end end end diff --git a/spec/fixtures/container_registry/tag_manifest_1.json b/spec/fixtures/container_registry/tag_manifest_1.json new file mode 100644 index 00000000000..d09ede5bea7 --- /dev/null +++ b/spec/fixtures/container_registry/tag_manifest_1.json @@ -0,0 +1,32 @@ +{ + "schemaVersion": 1, + "name": "library/alpine", + "tag": "2.6", + "architecture": "amd64", + "fsLayers": [ + { + "blobSum": "sha256:2a3ebcb7fbcc29bf40c4f62863008bb573acdea963454834d9483b3e5300c45d" + } + ], + "history": [ + { + "v1Compatibility": "{\"id\":\"dd807873c9a21bcc82e30317c283e6601d7e19f5cf7867eec34cdd1aeb3f099e\",\"created\":\"2016-01-18T18:32:39.162138276Z\",\"container\":\"556a728876db7b0e621adc029c87c649d32520804f8f15defd67bb070dc1a88d\",\"container_config\":{\"Hostname\":\"556a728876db\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":null,\"Cmd\":[\"/bin/sh\",\"-c\",\"#(nop) ADD file:7dee8a455bcc39013aa168d27ece9227aad155adbaacbd153d94ca60113f59fc in /\"],\"Image\":\"\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":null,\"Labels\":null},\"docker_version\":\"1.8.3\",\"config\":{\"Hostname\":\"556a728876db\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":null,\"Cmd\":null,\"Image\":\"\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":null,\"Labels\":null},\"architecture\":\"amd64\",\"os\":\"linux\",\"Size\":4501436}" + } + ], + "signatures": [ + { + "header": { + "jwk": { + "crv": "P-256", + "kid": "4MZL:Z5ZP:2RPA:Q3TD:QOHA:743L:EM2G:QY6Q:ZJCX:BSD7:CRYC:LQ6T", + "kty": "EC", + "x": "qmWOaxPUk7QsE5iTPdeG1e9yNE-wranvQEnWzz9FhWM", + "y": "WeeBpjTOYnTNrfCIxtFY5qMrJNNk9C1vc5ryxbbMD_M" + }, + "alg": "ES256" + }, + "signature": "0zmjTJ4m21yVwAeteLc3SsQ0miScViCDktFPR67W-ozGjjI3iBjlDjwOl6o2sds5ZI9U6bSIKOeLDinGOhHoOQ", + "protected": "eyJmb3JtYXRMZW5ndGgiOjEzNzIsImZvcm1hdFRhaWwiOiJDbjAiLCJ0aW1lIjoiMjAxNi0wNi0xNVQxMDo0NDoxNFoifQ" + } + ] +} diff --git a/spec/helpers/gitlab_routing_helper_spec.rb b/spec/helpers/gitlab_routing_helper_spec.rb new file mode 100644 index 00000000000..14847d0a49e --- /dev/null +++ b/spec/helpers/gitlab_routing_helper_spec.rb @@ -0,0 +1,79 @@ +require 'spec_helper' + +describe GitlabRoutingHelper do + describe 'Project URL helpers' do + describe '#project_members_url' do + let(:project) { build_stubbed(:empty_project) } + + it { expect(project_members_url(project)).to eq namespace_project_project_members_url(project.namespace, project) } + end + + describe '#project_member_path' do + let(:project_member) { create(:project_member) } + + it { expect(project_member_path(project_member)).to eq namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) } + end + + describe '#request_access_project_members_path' do + let(:project) { build_stubbed(:empty_project) } + + it { expect(request_access_project_members_path(project)).to eq request_access_namespace_project_project_members_path(project.namespace, project) } + end + + describe '#leave_project_members_path' do + let(:project) { build_stubbed(:empty_project) } + + it { expect(leave_project_members_path(project)).to eq leave_namespace_project_project_members_path(project.namespace, project) } + end + + describe '#approve_access_request_project_member_path' do + let(:project_member) { create(:project_member) } + + it { expect(approve_access_request_project_member_path(project_member)).to eq approve_access_request_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) } + end + + describe '#resend_invite_project_member_path' do + let(:project_member) { create(:project_member) } + + it { expect(resend_invite_project_member_path(project_member)).to eq resend_invite_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) } + end + end + + describe 'Group URL helpers' do + describe '#group_members_url' do + let(:group) { build_stubbed(:group) } + + it { expect(group_members_url(group)).to eq group_group_members_url(group) } + end + + describe '#group_member_path' do + let(:group_member) { create(:group_member) } + + it { expect(group_member_path(group_member)).to eq group_group_member_path(group_member.source, group_member) } + end + + describe '#request_access_group_members_path' do + let(:group) { build_stubbed(:group) } + + it { expect(request_access_group_members_path(group)).to eq request_access_group_group_members_path(group) } + end + + describe '#leave_group_members_path' do + let(:group) { build_stubbed(:group) } + + it { expect(leave_group_members_path(group)).to eq leave_group_group_members_path(group) } + end + + describe '#approve_access_request_group_member_path' do + let(:group_member) { create(:group_member) } + + it { expect(approve_access_request_group_member_path(group_member)).to eq approve_access_request_group_group_member_path(group_member.source, group_member) } + end + + describe '#resend_invite_group_member_path' do + let(:group_member) { create(:group_member) } + + it { expect(resend_invite_group_member_path(group_member)).to eq resend_invite_group_group_member_path(group_member.source, group_member) } + end + end +end diff --git a/spec/helpers/members_helper_spec.rb b/spec/helpers/members_helper_spec.rb new file mode 100644 index 00000000000..0b1a76156e0 --- /dev/null +++ b/spec/helpers/members_helper_spec.rb @@ -0,0 +1,72 @@ +require 'spec_helper' + +describe MembersHelper do + describe '#action_member_permission' do + let(:project_member) { build(:project_member) } + let(:group_member) { build(:group_member) } + + it { expect(action_member_permission(:admin, project_member)).to eq :admin_project_member } + it { expect(action_member_permission(:admin, group_member)).to eq :admin_group_member } + end + + describe '#can_see_member_roles?' do + let(:project) { create(:empty_project) } + let(:group) { create(:group) } + let(:user) { build(:user) } + let(:admin) { build(:user, :admin) } + let(:project_member) { create(:project_member, project: project) } + let(:group_member) { create(:group_member, group: group) } + + it { expect(can_see_member_roles?(source: project, user: nil)).to be_falsy } + it { expect(can_see_member_roles?(source: group, user: nil)).to be_falsy } + it { expect(can_see_member_roles?(source: project, user: admin)).to be_truthy } + it { expect(can_see_member_roles?(source: group, user: admin)).to be_truthy } + it { expect(can_see_member_roles?(source: project, user: project_member.user)).to be_truthy } + it { expect(can_see_member_roles?(source: group, user: group_member.user)).to be_truthy } + end + + describe '#remove_member_message' do + let(:requester) { build(:user) } + let(:project) { create(:project) } + let(:project_member) { build(:project_member, project: project) } + let(:project_member_invite) { build(:project_member, project: project).tap { |m| m.generate_invite_token! } } + let(:project_member_request) { project.request_access(requester) } + let(:group) { create(:group) } + let(:group_member) { build(:group_member, group: group) } + let(:group_member_invite) { build(:group_member, group: group).tap { |m| m.generate_invite_token! } } + let(:group_member_request) { group.request_access(requester) } + + it { expect(remove_member_message(project_member)).to eq "Are you sure you want to remove #{project_member.user.name} from the #{project.name_with_namespace} project?" } + it { expect(remove_member_message(project_member_invite)).to eq "Are you sure you want to revoke the invitation for #{project_member_invite.invite_email} to join the #{project.name_with_namespace} project?" } + it { expect(remove_member_message(project_member_request)).to eq "Are you sure you want to deny #{requester.name}'s request to join the #{project.name_with_namespace} project?" } + it { expect(remove_member_message(project_member_request, user: requester)).to eq "Are you sure you want to withdraw your access request for the #{project.name_with_namespace} project?" } + it { expect(remove_member_message(group_member)).to eq "Are you sure you want to remove #{group_member.user.name} from the #{group.name} group?" } + it { expect(remove_member_message(group_member_invite)).to eq "Are you sure you want to revoke the invitation for #{group_member_invite.invite_email} to join the #{group.name} group?" } + it { expect(remove_member_message(group_member_request)).to eq "Are you sure you want to deny #{requester.name}'s request to join the #{group.name} group?" } + it { expect(remove_member_message(group_member_request, user: requester)).to eq "Are you sure you want to withdraw your access request for the #{group.name} group?" } + end + + describe '#remove_member_title' do + let(:requester) { build(:user) } + let(:project) { create(:project) } + let(:project_member) { build(:project_member, project: project) } + let(:project_member_request) { project.request_access(requester) } + let(:group) { create(:group) } + let(:group_member) { build(:group_member, group: group) } + let(:group_member_request) { group.request_access(requester) } + + it { expect(remove_member_title(project_member)).to eq 'Remove user from project' } + it { expect(remove_member_title(project_member_request)).to eq 'Deny access request from project' } + it { expect(remove_member_title(group_member)).to eq 'Remove user from group' } + it { expect(remove_member_title(group_member_request)).to eq 'Deny access request from group' } + end + + describe '#leave_confirmation_message' do + let(:project) { build_stubbed(:project) } + let(:group) { build_stubbed(:group) } + let(:user) { build_stubbed(:user) } + + it { expect(leave_confirmation_message(project)).to eq "Are you sure you want to leave the \"#{project.name_with_namespace}\" project?" } + it { expect(leave_confirmation_message(group)).to eq "Are you sure you want to leave the \"#{group.name}\" group?" } + end +end diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb index ac5af8740dc..09e0bbfd00b 100644 --- a/spec/helpers/projects_helper_spec.rb +++ b/spec/helpers/projects_helper_spec.rb @@ -45,16 +45,6 @@ describe ProjectsHelper do end end - describe 'user_max_access_in_project' do - let(:project) { create(:project) } - let(:user) { create(:user) } - before do - project.team.add_user(user, Gitlab::Access::MASTER) - end - - it { expect(helper.user_max_access_in_project(user.id, project)).to eq('Master') } - end - describe "readme_cache_key" do let(:project) { create(:project) } diff --git a/spec/javascripts/application_spec.js.coffee b/spec/javascripts/application_spec.js.coffee new file mode 100644 index 00000000000..8af39c41f2f --- /dev/null +++ b/spec/javascripts/application_spec.js.coffee @@ -0,0 +1,30 @@ +#= require lib/common_utils + +describe 'Application', -> + describe 'disable buttons', -> + fixture.preload('application.html') + + beforeEach -> + fixture.load('application.html') + + it 'should prevent default action for disabled buttons', -> + + gl.utils.preventDisabledButtons() + + isClicked = false + $button = $ '#test-button' + + $button.click -> isClicked = true + $button.trigger 'click' + + expect(isClicked).toBe false + + + it 'should be on the same page if a disabled link clicked', -> + + locationBeforeLinkClick = window.location.href + gl.utils.preventDisabledButtons() + + $('#test-link').click() + + expect(window.location.href).toBe locationBeforeLinkClick diff --git a/spec/javascripts/fixtures/application.html.haml b/spec/javascripts/fixtures/application.html.haml new file mode 100644 index 00000000000..3fc6114407d --- /dev/null +++ b/spec/javascripts/fixtures/application.html.haml @@ -0,0 +1,2 @@ +%a#test-link.btn.disabled{:href => "/foo"} Test link +%button#test-button.btn.disabled Test Button diff --git a/spec/javascripts/fixtures/u2f/register.html.haml b/spec/javascripts/fixtures/u2f/register.html.haml index 393c0613fd3..5ed51be689c 100644 --- a/spec/javascripts/fixtures/u2f/register.html.haml +++ b/spec/javascripts/fixtures/u2f/register.html.haml @@ -1 +1,2 @@ -= render partial: "u2f/register", locals: { create_u2f_profile_two_factor_auth_path: '/profile/two_factor_auth/create_u2f' } +- user = FactoryGirl.build(:user, :two_factor_via_otp) += render partial: "u2f/register", locals: { create_u2f_profile_two_factor_auth_path: '/profile/two_factor_auth/create_u2f', current_user: user } diff --git a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb index 304290d6608..143e2e6d238 100644 --- a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb +++ b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb @@ -26,7 +26,8 @@ module Ci tag_list: [], options: {}, allow_failure: false, - when: "on_success" + when: "on_success", + environment: nil, }) end @@ -387,7 +388,8 @@ module Ci services: ["mysql"] }, allow_failure: false, - when: "on_success" + when: "on_success", + environment: nil, }) end @@ -415,7 +417,8 @@ module Ci services: ["postgresql"] }, allow_failure: false, - when: "on_success" + when: "on_success", + environment: nil, }) end end @@ -573,7 +576,12 @@ module Ci services: ["mysql"], before_script: ["pwd"], rspec: { - artifacts: { paths: ["logs/", "binaries/"], untracked: true, name: "custom_name" }, + artifacts: { + paths: ["logs/", "binaries/"], + untracked: true, + name: "custom_name", + expire_in: "7d" + }, script: "rspec" } }) @@ -595,11 +603,13 @@ module Ci artifacts: { name: "custom_name", paths: ["logs/", "binaries/"], - untracked: true + untracked: true, + expire_in: "7d" } }, when: "on_success", - allow_failure: false + allow_failure: false, + environment: nil, }) end @@ -621,6 +631,51 @@ module Ci end end + describe '#environment' do + let(:config) do + { + deploy_to_production: { stage: 'deploy', script: 'test', environment: environment } + } + end + + let(:processor) { GitlabCiYamlProcessor.new(YAML.dump(config)) } + let(:builds) { processor.builds_for_stage_and_ref('deploy', 'master') } + + context 'when a production environment is specified' do + let(:environment) { 'production' } + + it 'does return production' do + expect(builds.size).to eq(1) + expect(builds.first[:environment]).to eq(environment) + end + end + + context 'when no environment is specified' do + let(:environment) { nil } + + it 'does return nil environment' do + expect(builds.size).to eq(1) + expect(builds.first[:environment]).to be_nil + end + end + + context 'is not a string' do + let(:environment) { 1 } + + it 'raises error' do + expect { builds }.to raise_error("deploy_to_production job: environment parameter #{Gitlab::Regex.environment_name_regex_message}") + end + end + + context 'is not a valid string' do + let(:environment) { 'production staging' } + + it 'raises error' do + expect { builds }.to raise_error("deploy_to_production job: environment parameter #{Gitlab::Regex.environment_name_regex_message}") + end + end + end + describe "Dependencies" do let(:config) do { @@ -682,7 +737,8 @@ module Ci tag_list: [], options: {}, when: "on_success", - allow_failure: false + allow_failure: false, + environment: nil, }) end end @@ -727,7 +783,8 @@ module Ci tag_list: [], options: {}, when: "on_success", - allow_failure: false + allow_failure: false, + environment: nil, }) expect(subject.second).to eq({ except: nil, @@ -739,7 +796,8 @@ module Ci tag_list: [], options: {}, when: "on_success", - allow_failure: false + allow_failure: false, + environment: nil, }) end end @@ -992,6 +1050,20 @@ EOT end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: artifacts:when parameter should be on_success, on_failure or always") end + it "returns errors if job artifacts:expire_in is not an a string" do + config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", artifacts: { expire_in: 1 } } }) + expect do + GitlabCiYamlProcessor.new(config) + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: artifacts:expire_in parameter should be a duration") + end + + it "returns errors if job artifacts:expire_in is not an a valid duration" do + config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", artifacts: { expire_in: "7 elephants" } } }) + expect do + GitlabCiYamlProcessor.new(config) + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: artifacts:expire_in parameter should be a duration") + 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 diff --git a/spec/lib/container_registry/tag_spec.rb b/spec/lib/container_registry/tag_spec.rb index 858cb0bb134..c7324c2bf77 100644 --- a/spec/lib/container_registry/tag_spec.rb +++ b/spec/lib/container_registry/tag_spec.rb @@ -17,46 +17,85 @@ describe ContainerRegistry::Tag do end context 'manifest processing' do - before do - stub_request(:get, 'http://example.com/v2/group/test/manifests/tag'). - with(headers: headers). - to_return( - status: 200, - body: File.read(Rails.root + 'spec/fixtures/container_registry/tag_manifest.json'), - headers: { 'Content-Type' => 'application/vnd.docker.distribution.manifest.v2+json' }) - end + context 'schema v1' do + before do + stub_request(:get, 'http://example.com/v2/group/test/manifests/tag'). + with(headers: headers). + to_return( + status: 200, + body: File.read(Rails.root + 'spec/fixtures/container_registry/tag_manifest_1.json'), + headers: { 'Content-Type' => 'application/vnd.docker.distribution.manifest.v1+prettyjws' }) + end - context '#layers' do - subject { tag.layers } + context '#layers' do + subject { tag.layers } - it { expect(subject.length).to eq(1) } - end + it { expect(subject.length).to eq(1) } + end + + context '#total_size' do + subject { tag.total_size } - context '#total_size' do - subject { tag.total_size } + it { is_expected.to be_nil } + end - it { is_expected.to eq(2319870) } + context 'config processing' do + context '#config' do + subject { tag.config } + + it { is_expected.to be_nil } + end + + context '#created_at' do + subject { tag.created_at } + + it { is_expected.to be_nil } + end + end end - context 'config processing' do + context 'schema v2' do before do - stub_request(:get, 'http://example.com/v2/group/test/blobs/sha256:d7a513a663c1a6dcdba9ed832ca53c02ac2af0c333322cd6ca92936d1d9917ac'). - with(headers: { 'Accept' => 'application/octet-stream' }). + stub_request(:get, 'http://example.com/v2/group/test/manifests/tag'). + with(headers: headers). to_return( status: 200, - body: File.read(Rails.root + 'spec/fixtures/container_registry/config_blob.json')) + body: File.read(Rails.root + 'spec/fixtures/container_registry/tag_manifest.json'), + headers: { 'Content-Type' => 'application/vnd.docker.distribution.manifest.v2+json' }) end - context '#config' do - subject { tag.config } + context '#layers' do + subject { tag.layers } - it { is_expected.not_to be_nil } + it { expect(subject.length).to eq(1) } end - context '#created_at' do - subject { tag.created_at } + context '#total_size' do + subject { tag.total_size } + + it { is_expected.to eq(2319870) } + end + + context 'config processing' do + before do + stub_request(:get, 'http://example.com/v2/group/test/blobs/sha256:d7a513a663c1a6dcdba9ed832ca53c02ac2af0c333322cd6ca92936d1d9917ac'). + with(headers: { 'Accept' => 'application/octet-stream' }). + to_return( + status: 200, + body: File.read(Rails.root + 'spec/fixtures/container_registry/config_blob.json')) + end + + context '#config' do + subject { tag.config } + + it { is_expected.not_to be_nil } + end + + context '#created_at' do + subject { tag.created_at } - it { is_expected.not_to be_nil } + it { is_expected.not_to be_nil } + end end end end diff --git a/spec/lib/gitlab/ci/config/node/configurable_spec.rb b/spec/lib/gitlab/ci/config/node/configurable_spec.rb new file mode 100644 index 00000000000..47c68f96dc8 --- /dev/null +++ b/spec/lib/gitlab/ci/config/node/configurable_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' + +describe Gitlab::Ci::Config::Node::Configurable do + let(:node) { Class.new } + + before do + node.include(described_class) + end + + describe 'allowed nodes' do + before do + node.class_eval do + allow_node :object, Object, description: 'test object' + end + end + + describe '#allowed_nodes' do + it 'has valid allowed nodes' do + expect(node.allowed_nodes).to include :object + end + + it 'creates a node factory' do + expect(node.allowed_nodes[:object]) + .to be_an_instance_of Gitlab::Ci::Config::Node::Factory + end + + it 'returns a duplicated factory object' do + first_factory = node.allowed_nodes[:object] + second_factory = node.allowed_nodes[:object] + + expect(first_factory).not_to be_equal(second_factory) + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/node/factory_spec.rb b/spec/lib/gitlab/ci/config/node/factory_spec.rb new file mode 100644 index 00000000000..d681aa32456 --- /dev/null +++ b/spec/lib/gitlab/ci/config/node/factory_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' + +describe Gitlab::Ci::Config::Node::Factory do + describe '#create!' do + let(:factory) { described_class.new(entry_class) } + let(:entry_class) { Gitlab::Ci::Config::Node::Script } + + context 'when value setting value' do + it 'creates entry with valid value' do + entry = factory + .with(value: ['ls', 'pwd']) + .create! + + expect(entry.value).to eq "ls\npwd" + end + + context 'when setting description' do + it 'creates entry with description' do + entry = factory + .with(value: ['ls', 'pwd']) + .with(description: 'test description') + .create! + + expect(entry.value).to eq "ls\npwd" + expect(entry.description).to eq 'test description' + end + end + end + + context 'when not setting value' do + it 'raises error' do + expect { factory.create! }.to raise_error( + Gitlab::Ci::Config::Node::Factory::InvalidFactory + ) + end + end + + context 'when creating a null entry' do + it 'creates a null entry' do + entry = factory + .with(value: nil) + .nullify! + .create! + + expect(entry).to be_an_instance_of Gitlab::Ci::Config::Node::Null + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/node/global_spec.rb b/spec/lib/gitlab/ci/config/node/global_spec.rb new file mode 100644 index 00000000000..b1972172435 --- /dev/null +++ b/spec/lib/gitlab/ci/config/node/global_spec.rb @@ -0,0 +1,104 @@ +require 'spec_helper' + +describe Gitlab::Ci::Config::Node::Global do + let(:global) { described_class.new(hash) } + + describe '#allowed_nodes' do + it 'can contain global config keys' do + expect(global.allowed_nodes).to include :before_script + end + + it 'returns a hash' do + expect(global.allowed_nodes).to be_a Hash + end + end + + context 'when hash is valid' do + let(:hash) do + { before_script: ['ls', 'pwd'] } + end + + describe '#process!' do + before { global.process! } + + it 'creates nodes hash' do + expect(global.nodes).to be_an Array + end + + it 'creates node object for each entry' do + expect(global.nodes.count).to eq 1 + end + + it 'creates node object using valid class' do + expect(global.nodes.first) + .to be_an_instance_of Gitlab::Ci::Config::Node::Script + end + + it 'sets correct description for nodes' do + expect(global.nodes.first.description) + .to eq 'Script that will be executed before each job.' + end + end + + describe '#leaf?' do + it 'is not leaf' do + expect(global).not_to be_leaf + end + end + + describe '#before_script' do + context 'when processed' do + before { global.process! } + + it 'returns correct script' do + expect(global.before_script).to eq "ls\npwd" + end + end + + context 'when not processed' do + it 'returns nil' do + expect(global.before_script).to be nil + end + end + end + end + + context 'when hash is not valid' do + before { global.process! } + + let(:hash) do + { before_script: 'ls' } + end + + describe '#valid?' do + it 'is not valid' do + expect(global).not_to be_valid + end + end + + describe '#errors' do + it 'reports errors from child nodes' do + expect(global.errors) + .to include 'before_script should be an array of strings' + end + end + + describe '#before_script' do + it 'raises error' do + expect { global.before_script }.to raise_error( + Gitlab::Ci::Config::Node::Entry::InvalidError + ) + end + end + end + + context 'when value is not a hash' do + let(:hash) { [] } + + describe '#valid?' do + it 'is not valid' do + expect(global).not_to be_valid + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/node/null_spec.rb b/spec/lib/gitlab/ci/config/node/null_spec.rb new file mode 100644 index 00000000000..36101c62462 --- /dev/null +++ b/spec/lib/gitlab/ci/config/node/null_spec.rb @@ -0,0 +1,23 @@ +require 'spec_helper' + +describe Gitlab::Ci::Config::Node::Null do + let(:entry) { described_class.new(nil) } + + describe '#leaf?' do + it 'is leaf node' do + expect(entry).to be_leaf + end + end + + describe '#any_method' do + it 'responds with nil' do + expect(entry.any_method).to be nil + end + end + + describe '#value' do + it 'returns nil' do + expect(entry.value).to be nil + end + end +end diff --git a/spec/lib/gitlab/ci/config/node/script_spec.rb b/spec/lib/gitlab/ci/config/node/script_spec.rb new file mode 100644 index 00000000000..e4d6481f8a5 --- /dev/null +++ b/spec/lib/gitlab/ci/config/node/script_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +describe Gitlab::Ci::Config::Node::Script do + let(:entry) { described_class.new(value) } + + describe '#validate!' do + before { entry.validate! } + + context 'when entry value is correct' do + let(:value) { ['ls', 'pwd'] } + + describe '#value' do + it 'returns concatenated command' do + expect(entry.value).to eq "ls\npwd" + end + end + + describe '#errors' do + it 'does not append errors' do + expect(entry.errors).to be_empty + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + + context 'when entry value is not correct' do + let(:value) { 'ls' } + + describe '#errors' do + it 'saves errors' do + expect(entry.errors) + .to include /should be an array of strings/ + end + end + + describe '#valid?' do + it 'is not valid' do + expect(entry).not_to be_valid + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/config_spec.rb b/spec/lib/gitlab/ci/config_spec.rb index 4d46abe520f..3871d939feb 100644 --- a/spec/lib/gitlab/ci/config_spec.rb +++ b/spec/lib/gitlab/ci/config_spec.rb @@ -29,17 +29,43 @@ describe Gitlab::Ci::Config do expect(config.to_hash).to eq hash end + + describe '#valid?' do + it 'is valid' do + expect(config).to be_valid + end + + it 'has no errors' do + expect(config.errors).to be_empty + end + end end context 'when config is invalid' do - let(:yml) { '// invalid' } - - describe '.new' do - it 'raises error' do - expect { config }.to raise_error( - Gitlab::Ci::Config::Loader::FormatError, - /Invalid configuration format/ - ) + context 'when yml is incorrect' do + let(:yml) { '// invalid' } + + describe '.new' do + it 'raises error' do + expect { config }.to raise_error( + Gitlab::Ci::Config::Loader::FormatError, + /Invalid configuration format/ + ) + end + end + end + + context 'when config logic is incorrect' do + let(:yml) { 'before_script: "ls"' } + + describe '#valid?' do + it 'is not valid' do + expect(config).not_to be_valid + end + + it 'has errors' do + expect(config.errors).not_to be_empty + end end end end diff --git a/spec/lib/gitlab/metrics/instrumentation_spec.rb b/spec/lib/gitlab/metrics/instrumentation_spec.rb index 220e86924a2..cdf641341cb 100644 --- a/spec/lib/gitlab/metrics/instrumentation_spec.rb +++ b/spec/lib/gitlab/metrics/instrumentation_spec.rb @@ -9,9 +9,31 @@ describe Gitlab::Metrics::Instrumentation do text end + class << self + def buzz(text = 'buzz') + text + end + private :buzz + + def flaky(text = 'flaky') + text + end + protected :flaky + end + def bar(text = 'bar') text end + + def wadus(text = 'wadus') + text + end + private :wadus + + def chaf(text = 'chaf') + text + end + protected :chaf end allow(@dummy).to receive(:name).and_return('Dummy') @@ -57,7 +79,7 @@ describe Gitlab::Metrics::Instrumentation do and_return(transaction) expect(transaction).to receive(:add_metric). - with(described_class::SERIES, an_instance_of(Hash), + with(described_class::SERIES, hash_including(:duration, :cpu_duration), method: 'Dummy.foo') @dummy.foo @@ -137,7 +159,7 @@ describe Gitlab::Metrics::Instrumentation do and_return(transaction) expect(transaction).to receive(:add_metric). - with(described_class::SERIES, an_instance_of(Hash), + with(described_class::SERIES, hash_including(:duration, :cpu_duration), method: 'Dummy#bar') @dummy.new.bar @@ -208,6 +230,21 @@ describe Gitlab::Metrics::Instrumentation do described_class.instrument_methods(@dummy) expect(described_class.instrumented?(@dummy.singleton_class)).to eq(true) + expect(@dummy.method(:foo).source_location.first).to match(/instrumentation\.rb/) + end + + it 'instruments all protected class methods' do + described_class.instrument_methods(@dummy) + + expect(described_class.instrumented?(@dummy.singleton_class)).to eq(true) + expect(@dummy.method(:flaky).source_location.first).to match(/instrumentation\.rb/) + end + + it 'instruments all private instance methods' do + described_class.instrument_methods(@dummy) + + expect(described_class.instrumented?(@dummy.singleton_class)).to eq(true) + expect(@dummy.method(:buzz).source_location.first).to match(/instrumentation\.rb/) end it 'only instruments methods directly defined in the module' do @@ -241,6 +278,21 @@ describe Gitlab::Metrics::Instrumentation do described_class.instrument_instance_methods(@dummy) expect(described_class.instrumented?(@dummy)).to eq(true) + expect(@dummy.new.method(:bar).source_location.first).to match(/instrumentation\.rb/) + end + + it 'instruments all protected instance methods' do + described_class.instrument_instance_methods(@dummy) + + expect(described_class.instrumented?(@dummy)).to eq(true) + expect(@dummy.new.method(:chaf).source_location.first).to match(/instrumentation\.rb/) + end + + it 'instruments all private instance methods' do + described_class.instrument_instance_methods(@dummy) + + expect(described_class.instrumented?(@dummy)).to eq(true) + expect(@dummy.new.method(:wadus).source_location.first).to match(/instrumentation\.rb/) end it 'only instruments methods directly defined in the module' do @@ -253,7 +305,7 @@ describe Gitlab::Metrics::Instrumentation do described_class.instrument_instance_methods(@dummy) - expect(@dummy.method_defined?(:_original_kittens)).to eq(false) + expect(@dummy.new.method(:kittens).source_location.first).not_to match(/instrumentation\.rb/) end it 'can take a block to determine if a method should be instrumented' do @@ -261,7 +313,7 @@ describe Gitlab::Metrics::Instrumentation do false end - expect(@dummy.method_defined?(:_original_bar)).to eq(false) + expect(@dummy.new.method(:bar).source_location.first).not_to match(/instrumentation\.rb/) end end end diff --git a/spec/lib/gitlab/metrics/rack_middleware_spec.rb b/spec/lib/gitlab/metrics/rack_middleware_spec.rb index b99be4e1060..40289f8b972 100644 --- a/spec/lib/gitlab/metrics/rack_middleware_spec.rb +++ b/spec/lib/gitlab/metrics/rack_middleware_spec.rb @@ -31,6 +31,20 @@ describe Gitlab::Metrics::RackMiddleware do middleware.call(env) end + + it 'tags a transaction with the method andpath of the route in the grape endpoint' do + route = double(:route, route_method: "GET", route_path: "/:version/projects/:id/archive(.:format)") + endpoint = double(:endpoint, route: route) + + env['api.endpoint'] = endpoint + + allow(app).to receive(:call).with(env) + + expect(middleware).to receive(:tag_endpoint). + with(an_instance_of(Gitlab::Metrics::Transaction), env) + + middleware.call(env) + end end describe '#transaction_from_env' do @@ -60,4 +74,19 @@ describe Gitlab::Metrics::RackMiddleware do expect(transaction.action).to eq('TestController#show') end end + + describe '#tag_endpoint' do + let(:transaction) { middleware.transaction_from_env(env) } + + it 'tags a transaction with the method and path of the route in the grape endpount' do + route = double(:route, route_method: "GET", route_path: "/:version/projects/:id/archive(.:format)") + endpoint = double(:endpoint, route: route) + + env['api.endpoint'] = endpoint + + middleware.tag_endpoint(transaction, env) + + expect(transaction.action).to eq('Grape#GET /projects/:id/archive') + end + end end diff --git a/spec/lib/gitlab/metrics/sampler_spec.rb b/spec/lib/gitlab/metrics/sampler_spec.rb index 59db127674a..1ab923b58cf 100644 --- a/spec/lib/gitlab/metrics/sampler_spec.rb +++ b/spec/lib/gitlab/metrics/sampler_spec.rb @@ -72,14 +72,25 @@ describe Gitlab::Metrics::Sampler do end end - describe '#sample_objects' do - it 'adds a metric containing the amount of allocated objects' do - expect(sampler).to receive(:add_metric). - with(/object_counts/, an_instance_of(Hash), an_instance_of(Hash)). - at_least(:once). - and_call_original + if Gitlab::Metrics.mri? + describe '#sample_objects' do + it 'adds a metric containing the amount of allocated objects' do + expect(sampler).to receive(:add_metric). + with(/object_counts/, an_instance_of(Hash), an_instance_of(Hash)). + at_least(:once). + and_call_original + + sampler.sample_objects + end - sampler.sample_objects + it 'ignores classes without a name' do + expect(Allocations).to receive(:to_hash).and_return({ Class.new => 4 }) + + expect(sampler).not_to receive(:add_metric). + with('object_counts', an_instance_of(Hash), type: nil) + + sampler.sample_objects + end end end diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 818825b1477..1e6eb20ab39 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -400,26 +400,136 @@ describe Notify do end end + describe 'project access requested' do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:project_member) do + project.request_access(user) + project.members.request.find_by(user_id: user.id) + end + subject { Notify.member_access_requested_email('project', project_member.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it 'contains all the useful information' do + is_expected.to have_subject "Request to join the #{project.name_with_namespace} project" + is_expected.to have_body_text /#{project.name_with_namespace}/ + is_expected.to have_body_text /#{namespace_project_project_members_url(project.namespace, project)}/ + is_expected.to have_body_text /#{project_member.human_access}/ + end + end + + describe 'project access denied' do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:project_member) do + project.request_access(user) + project.members.request.find_by(user_id: user.id) + end + subject { Notify.member_access_denied_email('project', project.id, user.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it 'contains all the useful information' do + is_expected.to have_subject "Access to the #{project.name_with_namespace} project was denied" + is_expected.to have_body_text /#{project.name_with_namespace}/ + is_expected.to have_body_text /#{project.web_url}/ + end + end + describe 'project access changed' do let(:project) { create(:project) } let(:user) { create(:user) } let(:project_member) { create(:project_member, project: project, user: user) } - subject { Notify.project_access_granted_email(project_member.id) } + subject { Notify.member_access_granted_email('project', project_member.id) } it_behaves_like 'an email sent from GitLab' it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it 'has the correct subject' do - is_expected.to have_subject /Access to project was granted/ + it 'contains all the useful information' do + is_expected.to have_subject "Access to the #{project.name_with_namespace} project was granted" + is_expected.to have_body_text /#{project.name_with_namespace}/ + is_expected.to have_body_text /#{project.web_url}/ + is_expected.to have_body_text /#{project_member.human_access}/ end + end - it 'contains name of project' do - is_expected.to have_body_text /#{project.name}/ - end + def invite_to_project(project:, email:, inviter:) + ProjectMember.add_user(project.project_members, 'toto@example.com', Gitlab::Access::DEVELOPER, inviter) - it 'contains new user role' do + project.project_members.invite.last + end + + describe 'project invitation' do + let(:project) { create(:project) } + let(:master) { create(:user).tap { |u| project.team << [u, :master] } } + let(:project_member) { invite_to_project(project: project, email: 'toto@example.com', inviter: master) } + + subject { Notify.member_invited_email('project', project_member.id, project_member.invite_token) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it 'contains all the useful information' do + is_expected.to have_subject "Invitation to join the #{project.name_with_namespace} project" + is_expected.to have_body_text /#{project.name_with_namespace}/ + is_expected.to have_body_text /#{project.web_url}/ is_expected.to have_body_text /#{project_member.human_access}/ + is_expected.to have_body_text /#{project_member.invite_token}/ + end + end + + describe 'project invitation accepted' do + let(:project) { create(:project) } + let(:invited_user) { create(:user) } + let(:master) { create(:user).tap { |u| project.team << [u, :master] } } + let(:project_member) do + invitee = invite_to_project(project: project, email: 'toto@example.com', inviter: master) + invitee.accept_invite!(invited_user) + invitee + end + + subject { Notify.member_invite_accepted_email('project', project_member.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it 'contains all the useful information' do + is_expected.to have_subject 'Invitation accepted' + is_expected.to have_body_text /#{project.name_with_namespace}/ + is_expected.to have_body_text /#{project.web_url}/ + is_expected.to have_body_text /#{project_member.invite_email}/ + is_expected.to have_body_text /#{invited_user.name}/ + end + end + + describe 'project invitation declined' do + let(:project) { create(:project) } + let(:master) { create(:user).tap { |u| project.team << [u, :master] } } + let(:project_member) do + invitee = invite_to_project(project: project, email: 'toto@example.com', inviter: master) + invitee.decline_invite! + invitee + end + + subject { Notify.member_invite_declined_email('project', project.id, project_member.invite_email, master.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it 'contains all the useful information' do + is_expected.to have_subject 'Invitation declined' + is_expected.to have_body_text /#{project.name_with_namespace}/ + is_expected.to have_body_text /#{project.web_url}/ + is_expected.to have_body_text /#{project_member.invite_email}/ end end @@ -535,27 +645,139 @@ describe Notify do end end - describe 'group access changed' do - let(:group) { create(:group) } - let(:user) { create(:user) } - let(:membership) { create(:group_member, group: group, user: user) } + context 'for a group' do + describe 'group access requested' do + let(:group) { create(:group) } + let(:user) { create(:user) } + let(:group_member) do + group.request_access(user) + group.members.request.find_by(user_id: user.id) + end + subject { Notify.member_access_requested_email('group', group_member.id) } - subject { Notify.group_access_granted_email(membership.id) } + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" - it_behaves_like 'an email sent from GitLab' - it_behaves_like 'it should not have Gmail Actions links' - it_behaves_like "a user cannot unsubscribe through footer link" + it 'contains all the useful information' do + is_expected.to have_subject "Request to join the #{group.name} group" + is_expected.to have_body_text /#{group.name}/ + is_expected.to have_body_text /#{group_group_members_url(group)}/ + is_expected.to have_body_text /#{group_member.human_access}/ + end + end - it 'has the correct subject' do - is_expected.to have_subject /Access to group was granted/ + describe 'group access denied' do + let(:group) { create(:group) } + let(:user) { create(:user) } + let(:group_member) do + group.request_access(user) + group.members.request.find_by(user_id: user.id) + end + subject { Notify.member_access_denied_email('group', group.id, user.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it 'contains all the useful information' do + is_expected.to have_subject "Access to the #{group.name} group was denied" + is_expected.to have_body_text /#{group.name}/ + is_expected.to have_body_text /#{group.web_url}/ + end + end + + describe 'group access changed' do + let(:group) { create(:group) } + let(:user) { create(:user) } + let(:group_member) { create(:group_member, group: group, user: user) } + + subject { Notify.member_access_granted_email('group', group_member.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it 'contains all the useful information' do + is_expected.to have_subject "Access to the #{group.name} group was granted" + is_expected.to have_body_text /#{group.name}/ + is_expected.to have_body_text /#{group.web_url}/ + is_expected.to have_body_text /#{group_member.human_access}/ + end + end + + def invite_to_group(group:, email:, inviter:) + GroupMember.add_user(group.group_members, 'toto@example.com', Gitlab::Access::DEVELOPER, inviter) + + group.group_members.invite.last end - it 'contains name of project' do - is_expected.to have_body_text /#{group.name}/ + describe 'group invitation' do + let(:group) { create(:group) } + let(:owner) { create(:user).tap { |u| group.add_user(u, Gitlab::Access::OWNER) } } + let(:group_member) { invite_to_group(group: group, email: 'toto@example.com', inviter: owner) } + + subject { Notify.member_invited_email('group', group_member.id, group_member.invite_token) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it 'contains all the useful information' do + is_expected.to have_subject "Invitation to join the #{group.name} group" + is_expected.to have_body_text /#{group.name}/ + is_expected.to have_body_text /#{group.web_url}/ + is_expected.to have_body_text /#{group_member.human_access}/ + is_expected.to have_body_text /#{group_member.invite_token}/ + end end - it 'contains new user role' do - is_expected.to have_body_text /#{membership.human_access}/ + describe 'group invitation accepted' do + let(:group) { create(:group) } + let(:invited_user) { create(:user) } + let(:owner) { create(:user).tap { |u| group.add_user(u, Gitlab::Access::OWNER) } } + let(:group_member) do + invitee = invite_to_group(group: group, email: 'toto@example.com', inviter: owner) + invitee.accept_invite!(invited_user) + invitee + end + + subject { Notify.member_invite_accepted_email('group', group_member.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it 'contains all the useful information' do + is_expected.to have_subject 'Invitation accepted' + is_expected.to have_body_text /#{group.name}/ + is_expected.to have_body_text /#{group.web_url}/ + is_expected.to have_body_text /#{group_member.invite_email}/ + is_expected.to have_body_text /#{invited_user.name}/ + end + end + + describe 'group invitation declined' do + let(:group) { create(:group) } + let(:owner) { create(:user).tap { |u| group.add_user(u, Gitlab::Access::OWNER) } } + let(:group_member) do + invitee = invite_to_group(group: group, email: 'toto@example.com', inviter: owner) + invitee.decline_invite! + invitee + end + + subject { Notify.member_invite_declined_email('group', group.id, group_member.invite_email, owner.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it 'contains all the useful information' do + is_expected.to have_subject 'Invitation declined' + is_expected.to have_body_text /#{group.name}/ + is_expected.to have_body_text /#{group.web_url}/ + is_expected.to have_body_text /#{group_member.invite_email}/ + end end end diff --git a/spec/models/build_spec.rb b/spec/models/build_spec.rb index 2beb6cc598d..5d1fa8226e5 100644 --- a/spec/models/build_spec.rb +++ b/spec/models/build_spec.rb @@ -397,9 +397,34 @@ describe Ci::Build, models: true do context 'artifacts archive exists' do let(:build) { create(:ci_build, :artifacts) } it { is_expected.to be_truthy } + + context 'is expired' do + before { build.update(artifacts_expire_at: Time.now - 7.days) } + it { is_expected.to be_falsy } + end + + context 'is not expired' do + before { build.update(artifacts_expire_at: Time.now + 7.days) } + it { is_expected.to be_truthy } + end end end + describe '#artifacts_expired?' do + subject { build.artifacts_expired? } + + context 'is expired' do + before { build.update(artifacts_expire_at: Time.now - 7.days) } + + it { is_expected.to be_truthy } + end + + context 'is not expired' do + before { build.update(artifacts_expire_at: Time.now + 7.days) } + + it { is_expected.to be_falsey } + end + end describe '#artifacts_metadata?' do subject { build.artifacts_metadata? } @@ -412,7 +437,6 @@ describe Ci::Build, models: true do it { is_expected.to be_truthy } end end - describe '#repo_url' do let(:build) { create(:ci_build) } let(:project) { build.project } @@ -427,6 +451,50 @@ describe Ci::Build, models: true do it { is_expected.to include(project.web_url[7..-1]) } end + describe '#artifacts_expire_in' do + subject { build.artifacts_expire_in } + it { is_expected.to be_nil } + + context 'when artifacts_expire_at is specified' do + let(:expire_at) { Time.now + 7.days } + + before { build.artifacts_expire_at = expire_at } + + it { is_expected.to be_within(5).of(expire_at - Time.now) } + end + end + + describe '#artifacts_expire_in=' do + subject { build.artifacts_expire_in } + + it 'when assigning valid duration' do + build.artifacts_expire_in = '7 days' + + is_expected.to be_within(10).of(7.days.to_i) + end + + it 'when assigning invalid duration' do + expect { build.artifacts_expire_in = '7 elephants' }.to raise_error(ChronicDuration::DurationParseError) + is_expected.to be_nil + end + + it 'when resseting value' do + build.artifacts_expire_in = nil + + is_expected.to be_nil + end + end + + describe '#keep_artifacts!' do + let(:build) { create(:ci_build, artifacts_expire_at: Time.now + 7.days) } + + it 'to reset expire_at' do + build.keep_artifacts! + + expect(build.artifacts_expire_at).to be_nil + end + end + describe '#depends_on_builds' do let!(:build) { create(:ci_build, pipeline: pipeline, name: 'build', stage_idx: 0, stage: 'build') } let!(:rspec_test) { create(:ci_build, pipeline: pipeline, name: 'rspec', stage_idx: 1, stage: 'test') } diff --git a/spec/models/concerns/access_requestable_spec.rb b/spec/models/concerns/access_requestable_spec.rb new file mode 100644 index 00000000000..98307876962 --- /dev/null +++ b/spec/models/concerns/access_requestable_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +describe AccessRequestable do + describe 'Group' do + describe '#request_access' do + let(:group) { create(:group, :public) } + let(:user) { create(:user) } + + it { expect(group.request_access(user)).to be_a(GroupMember) } + it { expect(group.request_access(user).user).to eq(user) } + end + + describe '#access_requested?' do + let(:group) { create(:group, :public) } + let(:user) { create(:user) } + + before { group.request_access(user) } + + it { expect(group.members.request.exists?(user_id: user)).to be_truthy } + end + end + + describe 'Project' do + describe '#request_access' do + let(:project) { create(:empty_project, :public) } + let(:user) { create(:user) } + + it { expect(project.request_access(user)).to be_a(ProjectMember) } + end + + describe '#access_requested?' do + let(:project) { create(:empty_project, :public) } + let(:user) { create(:user) } + + before { project.request_access(user) } + + it { expect(project.members.request.exists?(user_id: user)).to be_truthy } + end + end +end diff --git a/spec/models/deployment_spec.rb b/spec/models/deployment_spec.rb new file mode 100644 index 00000000000..b273018707f --- /dev/null +++ b/spec/models/deployment_spec.rb @@ -0,0 +1,17 @@ +require 'spec_helper' + +describe Deployment, models: true do + subject { build(:deployment) } + + it { is_expected.to belong_to(:project) } + it { is_expected.to belong_to(:environment) } + it { is_expected.to belong_to(:user) } + it { is_expected.to belong_to(:deployable) } + + it { is_expected.to delegate_method(:name).to(:environment).with_prefix } + it { is_expected.to delegate_method(:commit).to(:project) } + it { is_expected.to delegate_method(:commit_title).to(:commit).as(:try) } + + it { is_expected.to validate_presence_of(:ref) } + it { is_expected.to validate_presence_of(:sha) } +end diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb new file mode 100644 index 00000000000..7629af6a570 --- /dev/null +++ b/spec/models/environment_spec.rb @@ -0,0 +1,14 @@ +require 'spec_helper' + +describe Environment, models: true do + let(:environment) { create(:environment) } + + it { is_expected.to belong_to(:project) } + it { is_expected.to have_many(:deployments) } + + it { is_expected.to delegate_method(:last_deployment).to(:deployments).as(:last) } + + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_uniqueness_of(:name).scoped_to(:project_id) } + it { is_expected.to validate_length_of(:name).is_within(0..255) } +end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 6fa16be7f04..ccdcb29f773 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -5,7 +5,11 @@ describe Group, models: true do describe 'associations' do it { is_expected.to have_many :projects } - it { is_expected.to have_many :group_members } + it { is_expected.to have_many(:group_members).dependent(:destroy) } + it { is_expected.to have_many(:users).through(:group_members) } + it { is_expected.to have_many(:project_group_links).dependent(:destroy) } + it { is_expected.to have_many(:shared_projects).through(:project_group_links) } + it { is_expected.to have_many(:notification_settings).dependent(:destroy) } end describe 'modules' do @@ -131,4 +135,46 @@ describe Group, models: true do expect(described_class.search(group.path.upcase)).to eq([group]) end end + + describe '#has_owner?' do + before { @members = setup_group_members(group) } + + it { expect(group.has_owner?(@members[:owner])).to be_truthy } + it { expect(group.has_owner?(@members[:master])).to be_falsey } + it { expect(group.has_owner?(@members[:developer])).to be_falsey } + it { expect(group.has_owner?(@members[:reporter])).to be_falsey } + it { expect(group.has_owner?(@members[:guest])).to be_falsey } + it { expect(group.has_owner?(@members[:requester])).to be_falsey } + end + + describe '#has_master?' do + before { @members = setup_group_members(group) } + + it { expect(group.has_master?(@members[:owner])).to be_falsey } + it { expect(group.has_master?(@members[:master])).to be_truthy } + it { expect(group.has_master?(@members[:developer])).to be_falsey } + it { expect(group.has_master?(@members[:reporter])).to be_falsey } + it { expect(group.has_master?(@members[:guest])).to be_falsey } + it { expect(group.has_master?(@members[:requester])).to be_falsey } + end + + def setup_group_members(group) + members = { + owner: create(:user), + master: create(:user), + developer: create(:user), + reporter: create(:user), + guest: create(:user), + requester: create(:user) + } + + group.add_user(members[:owner], GroupMember::OWNER) + group.add_user(members[:master], GroupMember::MASTER) + group.add_user(members[:developer], GroupMember::DEVELOPER) + group.add_user(members[:reporter], GroupMember::REPORTER) + group.add_user(members[:guest], GroupMember::GUEST) + group.request_access(members[:requester]) + + members + end end diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb index 6e51730eecd..3ed3202ac6c 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -55,11 +55,97 @@ describe Member, models: true do end end + describe 'Scopes & finders' do + before do + project = create(:project) + group = create(:group) + @owner_user = create(:user).tap { |u| group.add_owner(u) } + @owner = group.members.find_by(user_id: @owner_user.id) + + @master_user = create(:user).tap { |u| project.team << [u, :master] } + @master = project.members.find_by(user_id: @master_user.id) + + ProjectMember.add_user(project.members, 'toto1@example.com', Gitlab::Access::DEVELOPER, @master_user) + @invited_member = project.members.invite.find_by_invite_email('toto1@example.com') + + accepted_invite_user = build(:user) + ProjectMember.add_user(project.members, 'toto2@example.com', Gitlab::Access::DEVELOPER, @master_user) + @accepted_invite_member = project.members.invite.find_by_invite_email('toto2@example.com').tap { |u| u.accept_invite!(accepted_invite_user) } + + requested_user = create(:user).tap { |u| project.request_access(u) } + @requested_member = project.members.request.find_by(user_id: requested_user.id) + + accepted_request_user = create(:user).tap { |u| project.request_access(u) } + @accepted_request_member = project.members.request.find_by(user_id: accepted_request_user.id).tap { |m| m.accept_request } + end + + describe '.invite' do + it { expect(described_class.invite).not_to include @master } + it { expect(described_class.invite).to include @invited_member } + it { expect(described_class.invite).not_to include @accepted_invite_member } + it { expect(described_class.invite).not_to include @requested_member } + it { expect(described_class.invite).not_to include @accepted_request_member } + end + + describe '.non_invite' do + it { expect(described_class.non_invite).to include @master } + it { expect(described_class.non_invite).not_to include @invited_member } + it { expect(described_class.non_invite).to include @accepted_invite_member } + it { expect(described_class.non_invite).to include @requested_member } + it { expect(described_class.non_invite).to include @accepted_request_member } + end + + describe '.request' do + it { expect(described_class.request).not_to include @master } + it { expect(described_class.request).not_to include @invited_member } + it { expect(described_class.request).not_to include @accepted_invite_member } + it { expect(described_class.request).to include @requested_member } + it { expect(described_class.request).not_to include @accepted_request_member } + end + + describe '.non_request' do + it { expect(described_class.non_request).to include @master } + it { expect(described_class.non_request).to include @invited_member } + it { expect(described_class.non_request).to include @accepted_invite_member } + it { expect(described_class.non_request).not_to include @requested_member } + it { expect(described_class.non_request).to include @accepted_request_member } + end + + describe '.non_pending' do + it { expect(described_class.non_pending).to include @master } + it { expect(described_class.non_pending).not_to include @invited_member } + it { expect(described_class.non_pending).to include @accepted_invite_member } + it { expect(described_class.non_pending).not_to include @requested_member } + it { expect(described_class.non_pending).to include @accepted_request_member } + end + + describe '.owners_and_masters' do + it { expect(described_class.owners_and_masters).to include @owner } + it { expect(described_class.owners_and_masters).to include @master } + it { expect(described_class.owners_and_masters).not_to include @invited_member } + it { expect(described_class.owners_and_masters).not_to include @accepted_invite_member } + it { expect(described_class.owners_and_masters).not_to include @requested_member } + it { expect(described_class.owners_and_masters).not_to include @accepted_request_member } + end + end + describe "Delegate methods" do it { is_expected.to respond_to(:user_name) } it { is_expected.to respond_to(:user_email) } end + describe 'Callbacks' do + describe 'after_destroy :post_decline_request, if: :request?' do + let(:member) { create(:project_member, requested_at: Time.now.utc) } + + it 'calls #post_decline_request' do + expect(member).to receive(:post_decline_request) + + member.destroy + end + end + end + describe ".add_user" do let!(:user) { create(:user) } let(:project) { create(:project) } @@ -97,6 +183,44 @@ describe Member, models: true do end end + describe '#accept_request' do + let(:member) { create(:project_member, requested_at: Time.now.utc) } + + it { expect(member.accept_request).to be_truthy } + + it 'clears requested_at' do + member.accept_request + + expect(member.requested_at).to be_nil + end + + it 'calls #after_accept_request' do + expect(member).to receive(:after_accept_request) + + member.accept_request + end + end + + describe '#invite?' do + subject { create(:project_member, invite_email: "user@example.com", user: nil) } + + it { is_expected.to be_invite } + end + + describe '#request?' do + subject { create(:project_member, requested_at: Time.now.utc) } + + it { is_expected.to be_request } + end + + describe '#pending?' do + let(:invited_member) { create(:project_member, invite_email: "user@example.com", user: nil) } + let(:requester) { create(:project_member, requested_at: Time.now.utc) } + + it { expect(invited_member).to be_invite } + it { expect(requester).to be_pending } + end + describe "#accept_invite!" do let!(:member) { create(:project_member, invite_email: "user@example.com", user: nil) } let(:user) { create(:user) } diff --git a/spec/models/members/group_member_spec.rb b/spec/models/members/group_member_spec.rb index 5424c9b9cba..eeb74a462ac 100644 --- a/spec/models/members/group_member_spec.rb +++ b/spec/models/members/group_member_spec.rb @@ -20,7 +20,7 @@ require 'spec_helper' describe GroupMember, models: true do - context 'notification' do + describe 'notifications' do describe "#after_create" do it "should send email to user" do membership = build(:group_member) @@ -50,5 +50,31 @@ describe GroupMember, models: true do @group_member.update_attribute(:access_level, GroupMember::OWNER) end end + + describe '#after_accept_request' do + it 'calls NotificationService.accept_group_access_request' do + member = create(:group_member, user: build_stubbed(:user), requested_at: Time.now) + + expect_any_instance_of(NotificationService).to receive(:new_group_member) + + member.__send__(:after_accept_request) + end + end + + describe '#post_decline_request' do + it 'calls NotificationService.decline_group_access_request' do + member = create(:group_member, user: build_stubbed(:user), requested_at: Time.now) + + expect_any_instance_of(NotificationService).to receive(:decline_group_access_request) + + member.__send__(:post_decline_request) + end + end + + describe '#real_source_type' do + subject { create(:group_member).real_source_type } + + it { is_expected.to eq 'Group' } + end end end diff --git a/spec/models/members/project_member_spec.rb b/spec/models/members/project_member_spec.rb index 9f13874b532..1e466f9c620 100644 --- a/spec/models/members/project_member_spec.rb +++ b/spec/models/members/project_member_spec.rb @@ -33,6 +33,12 @@ describe ProjectMember, models: true do it { is_expected.to include_module(Gitlab::ShellAdapter) } end + describe '#real_source_type' do + subject { create(:project_member).real_source_type } + + it { is_expected.to eq 'Project' } + end + describe "#destroy" do let(:owner) { create(:project_member, access_level: ProjectMember::OWNER) } let(:project) { owner.project } @@ -135,4 +141,26 @@ describe ProjectMember, models: true do it { expect(@project_1.users).to be_empty } it { expect(@project_2.users).to be_empty } end + + describe 'notifications' do + describe '#after_accept_request' do + it 'calls NotificationService.new_project_member' do + member = create(:project_member, user: build_stubbed(:user), requested_at: Time.now) + + expect_any_instance_of(NotificationService).to receive(:new_project_member) + + member.__send__(:after_accept_request) + end + end + + describe '#post_decline_request' do + it 'calls NotificationService.decline_project_access_request' do + member = create(:project_member, user: build_stubbed(:user), requested_at: Time.now) + + expect_any_instance_of(NotificationService).to receive(:decline_project_access_request) + + member.__send__(:post_decline_request) + end + end + end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index de8815f5a38..fedab1f913b 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -28,6 +28,8 @@ 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(:environments).dependent(:destroy) } + it { is_expected.to have_many(:deployments).dependent(:destroy) } it { is_expected.to have_many(:todos).dependent(:destroy) } end @@ -89,11 +91,17 @@ describe Project, models: true do it { is_expected.to respond_to(:repo_exists?) } it { is_expected.to respond_to(:update_merge_requests) } it { is_expected.to respond_to(:execute_hooks) } - it { is_expected.to respond_to(:name_with_namespace) } it { is_expected.to respond_to(:owner) } it { is_expected.to respond_to(:path_with_namespace) } end + describe '#name_with_namespace' do + let(:project) { build_stubbed(:empty_project) } + + it { expect(project.name_with_namespace).to eq "#{project.namespace.human_name} / #{project.name}" } + it { expect(project.human_name).to eq project.name_with_namespace } + end + describe '#to_reference' do let(:project) { create(:empty_project) } diff --git a/spec/models/project_team_spec.rb b/spec/models/project_team_spec.rb index 8bebd6a9447..9262aeb6ed8 100644 --- a/spec/models/project_team_spec.rb +++ b/spec/models/project_team_spec.rb @@ -73,47 +73,42 @@ 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) + describe '#find_member' do + context 'personal project' do + let(:project) { create(:empty_project) } + let(:requester) { create(:user) } + + before do + project.team << [master, :master] + project.team << [reporter, :reporter] + project.team << [guest, :guest] + project.request_access(requester) + end + + it { expect(project.team.find_member(master.id)).to be_a(ProjectMember) } + it { expect(project.team.find_member(reporter.id)).to be_a(ProjectMember) } + it { expect(project.team.find_member(guest.id)).to be_a(ProjectMember) } + it { expect(project.team.find_member(nonmember.id)).to be_nil } + it { expect(project.team.find_member(requester.id)).to be_nil } 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 + context 'group project' do + let(:group) { create(:group) } + let(:project) { create(:empty_project, group: group) } + let(:requester) { create(:user) } + + before do + group.add_master(master) + group.add_reporter(reporter) + group.add_guest(guest) + group.request_access(requester) + end + + it { expect(project.team.find_member(master.id)).to be_a(GroupMember) } + it { expect(project.team.find_member(reporter.id)).to be_a(GroupMember) } + it { expect(project.team.find_member(guest.id)).to be_a(GroupMember) } + it { expect(project.team.find_member(nonmember.id)).to be_nil } + it { expect(project.team.find_member(requester.id)).to be_nil } end end @@ -138,4 +133,69 @@ describe ProjectTeam, models: true do expect(project.team.human_max_access(user.id)).to eq 'Owner' end end + + describe '#max_member_access' do + let(:requester) { create(:user) } + + context 'personal project' do + let(:project) { create(:empty_project) } + + context 'when project is not shared with group' do + before do + project.team << [master, :master] + project.team << [reporter, :reporter] + project.team << [guest, :guest] + project.request_access(requester) + end + + it { expect(project.team.max_member_access(master.id)).to eq(Gitlab::Access::MASTER) } + it { expect(project.team.max_member_access(reporter.id)).to eq(Gitlab::Access::REPORTER) } + it { expect(project.team.max_member_access(guest.id)).to eq(Gitlab::Access::GUEST) } + it { expect(project.team.max_member_access(nonmember.id)).to be_nil } + it { expect(project.team.max_member_access(requester.id)).to be_nil } + end + + context 'when project is shared with group' do + before do + group = create(:group) + project.project_group_links.create( + group: group, + group_access: Gitlab::Access::DEVELOPER) + + group.add_master(master) + group.add_reporter(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 { expect(project.team.max_member_access(requester.id)).to be_nil } + + context 'but share_with_group_lock is true' do + before { project.namespace.update(share_with_group_lock: true) } + + it { expect(project.team.max_member_access(master.id)).to be_nil } + it { expect(project.team.max_member_access(reporter.id)).to be_nil } + end + end + end + + context 'group project' do + let(:group) { create(:group) } + let(:project) { create(:empty_project, group: group) } + + before do + group.add_master(master) + group.add_reporter(reporter) + group.add_guest(guest) + group.request_access(requester) + end + + it { expect(project.team.max_member_access(master.id)).to eq(Gitlab::Access::MASTER) } + it { expect(project.team.max_member_access(reporter.id)).to eq(Gitlab::Access::REPORTER) } + it { expect(project.team.max_member_access(guest.id)).to eq(Gitlab::Access::GUEST) } + it { expect(project.team.max_member_access(nonmember.id)).to be_nil } + it { expect(project.team.max_member_access(requester.id)).to be_nil } + end + end end diff --git a/spec/requests/api/builds_spec.rb b/spec/requests/api/builds_spec.rb index 6cb7be188ef..ac85f340922 100644 --- a/spec/requests/api/builds_spec.rb +++ b/spec/requests/api/builds_spec.rb @@ -241,4 +241,30 @@ describe API::API, api: true do end end end + + describe 'POST /projects/:id/builds/:build_id/artifacts/keep' do + before do + post api("/projects/#{project.id}/builds/#{build.id}/artifacts/keep", user) + end + + context 'artifacts did not expire' do + let(:build) do + create(:ci_build, :trace, :artifacts, :success, + project: project, pipeline: pipeline, artifacts_expire_at: Time.now + 7.days) + end + + it 'keeps artifacts' do + expect(response.status).to eq 200 + expect(build.reload.artifacts_expire_at).to be_nil + end + end + + context 'no artifacts' do + let(:build) { create(:ci_build, project: project, pipeline: pipeline) } + + it 'responds with not found' do + expect(response.status).to eq 404 + end + end + end end diff --git a/spec/requests/ci/api/builds_spec.rb b/spec/requests/ci/api/builds_spec.rb index e8508f8f950..7e50bea90d1 100644 --- a/spec/requests/ci/api/builds_spec.rb +++ b/spec/requests/ci/api/builds_spec.rb @@ -364,6 +364,42 @@ describe Ci::API::API do end end + context 'with an expire date' do + let!(:artifacts) { file_upload } + + let(:post_data) do + { 'file.path' => artifacts.path, + 'file.name' => artifacts.original_filename, + 'expire_in' => expire_in } + end + + before do + post(post_url, post_data, headers_with_token) + end + + context 'with an expire_in given' do + let(:expire_in) { '7 days' } + + it 'updates when specified' do + build.reload + expect(response.status).to eq(201) + expect(json_response['artifacts_expire_at']).not_to be_empty + expect(build.artifacts_expire_at).to be_within(5.minutes).of(Time.now + 7.days) + end + end + + context 'with no expire_in given' do + let(:expire_in) { nil } + + it 'ignores if not specified' do + build.reload + expect(response.status).to eq(201) + expect(json_response['artifacts_expire_at']).to be_nil + expect(build.artifacts_expire_at).to be_nil + end + end + end + context "artifacts file is too large" do it "should fail to post too large artifact" do stub_application_setting(max_artifacts_size: 0) diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb index c44a4a7a1fc..fd26ca97818 100644 --- a/spec/requests/git_http_spec.rb +++ b/spec/requests/git_http_spec.rb @@ -340,7 +340,7 @@ describe 'Git HTTP requests', lib: true do end end - context "when the file exists" do + context "when the file does not exist" do before { get "/#{project.path_with_namespace}/blob/master/info/refs" } it "returns not found" do diff --git a/spec/services/create_deployment_service_spec.rb b/spec/services/create_deployment_service_spec.rb new file mode 100644 index 00000000000..654e441f3cd --- /dev/null +++ b/spec/services/create_deployment_service_spec.rb @@ -0,0 +1,119 @@ +require 'spec_helper' + +describe CreateDeploymentService, services: true do + let(:project) { create(:empty_project) } + let(:user) { create(:user) } + + let(:service) { described_class.new(project, user, params) } + + describe '#execute' do + let(:params) do + { environment: 'production', + ref: 'master', + tag: false, + sha: '97de212e80737a608d939f648d959671fb0a0142', + } + end + + subject { service.execute } + + context 'when no environments exist' do + it 'does create a new environment' do + expect { subject }.to change { Environment.count }.by(1) + end + + it 'does create a deployment' do + expect(subject).to be_persisted + end + end + + context 'when environment exist' do + before { create(:environment, project: project, name: 'production') } + + it 'does not create a new environment' do + expect { subject }.not_to change { Environment.count } + end + + it 'does create a deployment' do + expect(subject).to be_persisted + end + end + + context 'for environment with invalid name' do + let(:params) do + { environment: 'name with spaces', + ref: 'master', + tag: false, + sha: '97de212e80737a608d939f648d959671fb0a0142', + } + end + + it 'does not create a new environment' do + expect { subject }.not_to change { Environment.count } + end + + it 'does not create a deployment' do + expect(subject).not_to be_persisted + end + end + end + + describe 'processing of builds' do + let(:environment) { nil } + + shared_examples 'does not create environment and deployment' do + it 'does not create a new environment' do + expect { subject }.not_to change { Environment.count } + end + + it 'does not create a new deployment' do + expect { subject }.not_to change { Deployment.count } + end + + it 'does not call a service' do + expect_any_instance_of(described_class).not_to receive(:execute) + subject + end + end + + shared_examples 'does create environment and deployment' do + it 'does create a new environment' do + expect { subject }.to change { Environment.count }.by(1) + end + + it 'does create a new deployment' do + expect { subject }.to change { Deployment.count }.by(1) + end + + it 'does call a service' do + expect_any_instance_of(described_class).to receive(:execute) + subject + end + end + + context 'without environment specified' do + let(:build) { create(:ci_build, project: project) } + + it_behaves_like 'does not create environment and deployment' do + subject { build.success } + end + end + + context 'when environment is specified' do + let(:pipeline) { create(:ci_pipeline, project: project) } + let(:build) { create(:ci_build, pipeline: pipeline, environment: 'production') } + + context 'when build succeeds' do + it_behaves_like 'does create environment and deployment' do + subject { build.success } + end + end + + context 'when build fails' do + it_behaves_like 'does not create environment and deployment' do + subject { build.drop } + end + end + end + end +end diff --git a/spec/services/todo_service_spec.rb b/spec/services/todo_service_spec.rb index 549a936b060..26f09cdbaf9 100644 --- a/spec/services/todo_service_spec.rb +++ b/spec/services/todo_service_spec.rb @@ -228,6 +228,14 @@ describe TodoService, services: true do should_not_create_any_todo { service.new_note(note_on_project_snippet, john_doe) } end end + + describe '#mark_todo' do + it 'creates a todo from a issue' do + service.mark_todo(unassigned_issue, author) + + should_create_todo(user: author, target: unassigned_issue, action: Todo::MARKED) + end + end end describe 'Merge Requests' do @@ -361,6 +369,14 @@ describe TodoService, services: true do expect(second_todo.reload).not_to be_done end end + + describe '#mark_todo' do + it 'creates a todo from a merge request' do + service.mark_todo(mr_unassigned, author) + + should_create_todo(user: author, target: mr_unassigned, action: Todo::MARKED) + end + end end def should_create_todo(attributes = {}) diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb index 71664bb192e..498bd4bf800 100644 --- a/spec/support/test_env.rb +++ b/spec/support/test_env.rb @@ -16,6 +16,7 @@ module TestEnv 'master' => '5937ac0', "'test'" => 'e56497b', 'orphaned-branch' => '45127a9', + 'binary-encoding' => '7b1cf43', } # gitlab-test-fork is a fork of gitlab-fork, but we don't necessarily diff --git a/spec/workers/expire_build_artifacts_worker_spec.rb b/spec/workers/expire_build_artifacts_worker_spec.rb new file mode 100644 index 00000000000..e3827cae9a6 --- /dev/null +++ b/spec/workers/expire_build_artifacts_worker_spec.rb @@ -0,0 +1,57 @@ +require 'spec_helper' + +describe ExpireBuildArtifactsWorker do + include RepoHelpers + + let(:worker) { described_class.new } + + describe '#perform' do + before { build } + + subject! { worker.perform } + + context 'with expired artifacts' do + let(:build) { create(:ci_build, :artifacts, artifacts_expire_at: Time.now - 7.days) } + + it 'does expire' do + expect(build.reload.artifacts_expired?).to be_truthy + end + + it 'does remove files' do + expect(build.reload.artifacts_file.exists?).to be_falsey + end + end + + context 'with not yet expired artifacts' do + let(:build) { create(:ci_build, :artifacts, artifacts_expire_at: Time.now + 7.days) } + + it 'does not expire' do + expect(build.reload.artifacts_expired?).to be_falsey + end + + it 'does not remove files' do + expect(build.reload.artifacts_file.exists?).to be_truthy + end + end + + context 'without expire date' do + let(:build) { create(:ci_build, :artifacts) } + + it 'does not expire' do + expect(build.reload.artifacts_expired?).to be_falsey + end + + it 'does not remove files' do + expect(build.reload.artifacts_file.exists?).to be_truthy + end + end + + context 'for expired artifacts' do + let(:build) { create(:ci_build, artifacts_expire_at: Time.now - 7.days) } + + it 'is still expired' do + expect(build.reload.artifacts_expired?).to be_truthy + end + end + end +end diff --git a/spec/workers/stuck_ci_builds_worker_spec.rb b/spec/workers/stuck_ci_builds_worker_spec.rb index 665ec20f224..801fa31b45d 100644 --- a/spec/workers/stuck_ci_builds_worker_spec.rb +++ b/spec/workers/stuck_ci_builds_worker_spec.rb @@ -2,6 +2,7 @@ require "spec_helper" describe StuckCiBuildsWorker do let!(:build) { create :ci_build } + let(:worker) { described_class.new } subject do build.reload @@ -16,13 +17,13 @@ describe StuckCiBuildsWorker do it 'gets dropped if it was updated over 2 days ago' do build.update!(updated_at: 2.days.ago) - StuckCiBuildsWorker.new.perform + worker.perform is_expected.to eq('failed') end it "is still #{status}" do build.update!(updated_at: 1.minute.ago) - StuckCiBuildsWorker.new.perform + worker.perform is_expected.to eq(status) end end @@ -36,9 +37,21 @@ describe StuckCiBuildsWorker do it "is still #{status}" do build.update!(updated_at: 2.days.ago) - StuckCiBuildsWorker.new.perform + worker.perform is_expected.to eq(status) end end end + + context "for deleted project" do + before do + build.update!(status: :running, updated_at: 2.days.ago) + build.project.update(pending_delete: true) + end + + it "does not drop build" do + expect_any_instance_of(Ci::Build).not_to receive(:drop) + worker.perform + end + end end |